mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-08 19:42:12 +00:00
M7.1: proofing — spelling with suggestions, grammar checks, squiggles, F7, right-click menu, thesaurus; web app; hyphenation dictionary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bcbe09df87
5 files changed
+1113
No files matched your search
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "wordcraft-proof"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "WordCraft proofing: spelling (public-domain word list), suggestions, grammar checks, hyphenation"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
log = { workspace = true }
|
||||||
|
miniz_oxide = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
proptest = { workspace = true }
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
//! Hyphenation exception dictionary: lowercase words with their break points.
|
||||||
|
//!
|
||||||
|
//! On-disk format (`assets/hyphenation/en-us.dic`): raw-deflate of
|
||||||
|
//! `"DCHYPH1\n"` followed by one line per word, sorted by the plain word's bytes. Each line is
|
||||||
|
//! front-coded against the previous one: a byte with the shared prefix length (in bytes of the
|
||||||
|
//! hyphenated form), then the rest of the hyphenated form (`-` at every break), then `\n`.
|
||||||
|
|
||||||
|
/// Sorted word list with a break bitmask per word (bit `i` = hyphen before char `i`).
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct Dictionary {
|
||||||
|
words: String,
|
||||||
|
offs: Vec<u32>,
|
||||||
|
masks: Vec<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAGIC: &[u8] = b"DCHYPH1\n";
|
||||||
|
|
||||||
|
impl Dictionary {
|
||||||
|
/// Build from `(lowercase word, break mask)` pairs (sorted and de-duplicated here; the first
|
||||||
|
/// entry of a duplicate wins).
|
||||||
|
pub fn from_entries(mut entries: Vec<(String, u64)>) -> Dictionary {
|
||||||
|
entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
|
||||||
|
entries.dedup_by(|b, a| a.0 == b.0);
|
||||||
|
let mut d = Dictionary { words: String::new(), offs: vec![0], masks: Vec::with_capacity(entries.len()) };
|
||||||
|
for (w, m) in entries {
|
||||||
|
d.words.push_str(&w);
|
||||||
|
d.offs.push(d.words.len() as u32);
|
||||||
|
d.masks.push(m);
|
||||||
|
}
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.masks.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.masks.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn word(&self, i: usize) -> &str {
|
||||||
|
&self.words[self.offs[i] as usize..self.offs[i + 1] as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Break mask of an exact (already lowercased) word.
|
||||||
|
pub fn get(&self, word: &str) -> Option<u64> {
|
||||||
|
let (mut lo, mut hi) = (0usize, self.len());
|
||||||
|
while lo < hi {
|
||||||
|
let mid = (lo + hi) / 2;
|
||||||
|
match self.word(mid).as_bytes().cmp(word.as_bytes()) {
|
||||||
|
std::cmp::Ordering::Less => lo = mid + 1,
|
||||||
|
std::cmp::Ordering::Greater => hi = mid,
|
||||||
|
std::cmp::Ordering::Equal => return Some(self.masks[mid]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate `(word, mask)`.
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (&str, u64)> + '_ {
|
||||||
|
(0..self.len()).map(|i| (self.word(i), self.masks[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize (deflate-compressed).
|
||||||
|
pub fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut raw = MAGIC.to_vec();
|
||||||
|
let mut prev: Vec<u8> = Vec::new();
|
||||||
|
for (w, m) in self.iter() {
|
||||||
|
let h = hyphenated(w, m).into_bytes();
|
||||||
|
let p = prev.iter().zip(&h).take_while(|(a, b)| a == b).count().min(255);
|
||||||
|
raw.push(p as u8);
|
||||||
|
raw.extend_from_slice(&h[p..]);
|
||||||
|
raw.push(b'\n');
|
||||||
|
prev = h;
|
||||||
|
}
|
||||||
|
miniz_oxide::deflate::compress_to_vec(&raw, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize data written by [`Dictionary::to_bytes`].
|
||||||
|
pub fn from_bytes(data: &[u8]) -> Result<Dictionary, String> {
|
||||||
|
let raw = miniz_oxide::inflate::decompress_to_vec(data).map_err(|e| format!("hyphenation dictionary: inflate failed: {e:?}"))?;
|
||||||
|
let body = raw.strip_prefix(MAGIC).ok_or("hyphenation dictionary: bad header")?;
|
||||||
|
let mut d = Dictionary { words: String::with_capacity(body.len()), offs: vec![0], masks: Vec::new() };
|
||||||
|
let mut cur: Vec<u8> = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < body.len() {
|
||||||
|
let p = body[i] as usize;
|
||||||
|
let end = body[i + 1..].iter().position(|&b| b == b'\n').map(|e| i + 1 + e).ok_or("hyphenation dictionary: truncated")?;
|
||||||
|
if p > cur.len() {
|
||||||
|
return Err("hyphenation dictionary: bad prefix".into());
|
||||||
|
}
|
||||||
|
cur.truncate(p);
|
||||||
|
cur.extend_from_slice(&body[i + 1..end]);
|
||||||
|
i = end + 1;
|
||||||
|
let s = std::str::from_utf8(&cur).map_err(|_| "hyphenation dictionary: bad utf-8")?;
|
||||||
|
let (w, m) = parse_hyphenated(s);
|
||||||
|
d.words.push_str(&w);
|
||||||
|
d.offs.push(d.words.len() as u32);
|
||||||
|
d.masks.push(m);
|
||||||
|
}
|
||||||
|
Ok(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `word` with `-` before every char whose bit is set in `mask`.
|
||||||
|
pub fn hyphenated(word: &str, mask: u64) -> String {
|
||||||
|
let mut s = String::with_capacity(word.len() + 8);
|
||||||
|
for (i, c) in word.chars().enumerate() {
|
||||||
|
if i > 0 && i < 64 && mask & (1 << i) != 0 {
|
||||||
|
s.push('-');
|
||||||
|
}
|
||||||
|
s.push(c);
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of [`hyphenated`].
|
||||||
|
pub fn parse_hyphenated(s: &str) -> (String, u64) {
|
||||||
|
let mut w = String::with_capacity(s.len());
|
||||||
|
let mut m = 0u64;
|
||||||
|
let mut n = 0;
|
||||||
|
for c in s.chars() {
|
||||||
|
if c == '-' {
|
||||||
|
if n > 0 && n < 64 {
|
||||||
|
m |= 1 << n;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
w.push(c);
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(w, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip() {
|
||||||
|
let e: Vec<(String, u64)> = ["ty-pog-ra-phy", "cat", "hap-pen", "o'clock", "naïve-té", "naïve"].iter().map(|s| parse_hyphenated(s)).collect();
|
||||||
|
let d = Dictionary::from_entries(e);
|
||||||
|
let back = Dictionary::from_bytes(&d.to_bytes()).unwrap();
|
||||||
|
assert_eq!(back.len(), 6);
|
||||||
|
assert_eq!(back.get("typography").map(|m| hyphenated("typography", m)), Some("ty-pog-ra-phy".to_string()));
|
||||||
|
assert_eq!(back.get("cat"), Some(0));
|
||||||
|
assert_eq!(back.get("naïveté").map(|m| hyphenated("naïveté", m)), Some("naïve-té".to_string()));
|
||||||
|
assert!(back.get("dog").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
//! English hyphenation.
|
||||||
|
//!
|
||||||
|
//! Lookup order for each word:
|
||||||
|
//! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list
|
||||||
|
//! by Grady Ward (~160k words), stored compactly in `assets/hyphenation/en-us.dic`; simple
|
||||||
|
//! inflections (`'s`, `-s`, `-es`, `-ed`, `-d`) inherit the breaks of their stem;
|
||||||
|
//! 2. **Liang patterns** ([`Patterns::en_us`]) for words not in the dictionary — our own pattern
|
||||||
|
//! set, trained from the same list by DesignCraft's patgen (generated by DesignCraft's `hyphgen` example);
|
||||||
|
//! 3. a small syllable **heuristic** for words outside the pattern alphabet.
|
||||||
|
//!
|
||||||
|
//! Then the paragraph's limits ([`Limits`]) are applied.
|
||||||
|
|
||||||
|
pub use crate::dict;
|
||||||
|
|
||||||
|
pub use crate::patterns;
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
pub use dict::Dictionary;
|
||||||
|
pub use patterns::Patterns;
|
||||||
|
|
||||||
|
static EN_US_DIC: &[u8] = include_bytes!("../../../assets/hyphenation/en-us.dic");
|
||||||
|
static EN_US_PAT: &str = include_str!("../../../assets/hyphenation/en-us.pat");
|
||||||
|
|
||||||
|
impl Dictionary {
|
||||||
|
/// The bundled US English dictionary (decoded on first use).
|
||||||
|
pub fn en_us() -> &'static Dictionary {
|
||||||
|
static D: OnceLock<Dictionary> = OnceLock::new();
|
||||||
|
D.get_or_init(|| {
|
||||||
|
if EN_US_DIC.is_empty() {
|
||||||
|
return Dictionary::default();
|
||||||
|
}
|
||||||
|
Dictionary::from_bytes(EN_US_DIC).unwrap_or_else(|e| {
|
||||||
|
log::error!("{e}");
|
||||||
|
Dictionary::default()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Patterns {
|
||||||
|
/// The bundled US English patterns (parsed on first use).
|
||||||
|
pub fn en_us() -> &'static Patterns {
|
||||||
|
static P: OnceLock<Patterns> = OnceLock::new();
|
||||||
|
P.get_or_init(|| Patterns::parse(EN_US_PAT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hyphenation limits from the paragraph's Hyphenation settings.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct Limits {
|
||||||
|
pub min_word: usize,
|
||||||
|
pub after_first: usize,
|
||||||
|
pub before_last: usize,
|
||||||
|
pub capitalized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Limits {
|
||||||
|
fn default() -> Self {
|
||||||
|
Limits { min_word: 5, after_first: 2, before_last: 2, capitalized: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a word's break points came from.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Source {
|
||||||
|
Dictionary,
|
||||||
|
Patterns,
|
||||||
|
Heuristic,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_apostrophe(c: char) -> bool {
|
||||||
|
matches!(c, '\'' | '’' | 'ʼ')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_word_char(c: char) -> bool {
|
||||||
|
c.is_alphabetic() || is_apostrophe(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unrestricted break points of one lowercase core (letters and `'` only, no leading/trailing `'`).
|
||||||
|
fn core_breaks(core: &[char]) -> (Vec<usize>, Source) {
|
||||||
|
let n = core.len();
|
||||||
|
if n < 2 {
|
||||||
|
return (vec![], Source::None);
|
||||||
|
}
|
||||||
|
let dict = Dictionary::en_us();
|
||||||
|
let s: String = core.iter().collect();
|
||||||
|
let from_mask = |m: u64, len: usize| (1..len.min(64)).filter(|&i| m & (1 << i) != 0).collect::<Vec<_>>();
|
||||||
|
if let Some(m) = dict.get(&s) {
|
||||||
|
return (from_mask(m, n), Source::Dictionary);
|
||||||
|
}
|
||||||
|
// Inflections inherit the stem's breaks (the suffix adds no break of its own).
|
||||||
|
for suf in ["'s", "s", "es", "ed", "d"] {
|
||||||
|
if let Some(stem) = s.strip_suffix(suf)
|
||||||
|
&& stem.chars().count() >= 2
|
||||||
|
&& let Some(m) = dict.get(stem)
|
||||||
|
{
|
||||||
|
return (from_mask(m, stem.chars().count()), Source::Dictionary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pats = Patterns::en_us();
|
||||||
|
if !pats.is_empty()
|
||||||
|
&& let Some(p) = pats.points(core)
|
||||||
|
{
|
||||||
|
return (p, Source::Patterns);
|
||||||
|
}
|
||||||
|
(heuristic_points(core), Source::Heuristic)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unrestricted break points of `word` (char indices; a hyphen goes before that char), handling
|
||||||
|
/// case, apostrophes (`don't`, `’s`), surrounding punctuation and compounds (each letter run is
|
||||||
|
/// hyphenated separately). No break is ever placed next to an apostrophe or non-letter.
|
||||||
|
pub fn word_breaks(word: &str) -> (Vec<usize>, Source) {
|
||||||
|
let chars: Vec<char> = word.chars().collect();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut src = Source::None;
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
if !is_word_char(chars[i]) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut j = i;
|
||||||
|
while j < chars.len() && is_word_char(chars[j]) {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
let (mut a, mut b) = (i, j);
|
||||||
|
while a < b && is_apostrophe(chars[a]) {
|
||||||
|
a += 1;
|
||||||
|
}
|
||||||
|
while b > a && is_apostrophe(chars[b - 1]) {
|
||||||
|
b -= 1;
|
||||||
|
}
|
||||||
|
let core: Vec<char> = chars[a..b].iter().map(|&c| if is_apostrophe(c) { '\'' } else { c.to_lowercase().next().unwrap_or(c) }).collect();
|
||||||
|
let (pts, s) = core_breaks(&core);
|
||||||
|
if src == Source::None {
|
||||||
|
src = s;
|
||||||
|
}
|
||||||
|
out.extend(pts.into_iter().filter(|&p| p > 0 && p < core.len() && core[p - 1] != '\'' && core[p] != '\'').map(|p| a + p));
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
(out, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allowed hyphenation points in `word` as char indices (a hyphen goes before that char), with
|
||||||
|
/// the paragraph limits applied to each letter run.
|
||||||
|
pub fn hyphen_points(word: &str, lim: &Limits) -> Vec<usize> {
|
||||||
|
let chars: Vec<char> = word.chars().collect();
|
||||||
|
let (pts, _) = word_breaks(word);
|
||||||
|
if pts.is_empty() {
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
let min_before = lim.after_first.max(1);
|
||||||
|
let min_after = lim.before_last.max(1);
|
||||||
|
// Run bounds of each point's letter run (trimmed of apostrophes).
|
||||||
|
let run_of = |p: usize| {
|
||||||
|
let mut a = p;
|
||||||
|
while a > 0 && is_word_char(chars[a - 1]) {
|
||||||
|
a -= 1;
|
||||||
|
}
|
||||||
|
let mut b = p;
|
||||||
|
while b < chars.len() && is_word_char(chars[b]) {
|
||||||
|
b += 1;
|
||||||
|
}
|
||||||
|
while a < b && is_apostrophe(chars[a]) {
|
||||||
|
a += 1;
|
||||||
|
}
|
||||||
|
while b > a && is_apostrophe(chars[b - 1]) {
|
||||||
|
b -= 1;
|
||||||
|
}
|
||||||
|
(a, b)
|
||||||
|
};
|
||||||
|
pts.into_iter()
|
||||||
|
.filter(|&p| {
|
||||||
|
let (a, b) = run_of(p);
|
||||||
|
let run = &chars[a..b];
|
||||||
|
let letters = run.iter().filter(|c| c.is_alphabetic()).count();
|
||||||
|
if letters < lim.min_word.max(2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if letters > 1 && run.iter().filter(|c| c.is_alphabetic()).all(|c| c.is_uppercase()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !lim.capitalized && run[0].is_uppercase() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let before = chars[a..p].iter().filter(|c| c.is_alphabetic()).count();
|
||||||
|
let after = chars[p..b].iter().filter(|c| c.is_alphabetic()).count();
|
||||||
|
before >= min_before && after >= min_after
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `word` with `-` at every hyphenation point (tests and diagnostics).
|
||||||
|
pub fn hyphenate_word(word: &str, lim: &Limits) -> String {
|
||||||
|
let pts = hyphen_points(word, lim);
|
||||||
|
let mut s = String::with_capacity(word.len() + pts.len());
|
||||||
|
for (i, ch) in word.chars().enumerate() {
|
||||||
|
if pts.contains(&i) {
|
||||||
|
s.push('-');
|
||||||
|
}
|
||||||
|
s.push(ch);
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- heuristic fallback (words outside the pattern alphabet) ----------
|
||||||
|
|
||||||
|
fn is_vowel(c: char) -> bool {
|
||||||
|
matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y') || (c.is_alphabetic() && !c.is_ascii())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn onset_pair(a: char, b: char) -> bool {
|
||||||
|
let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase());
|
||||||
|
matches!(
|
||||||
|
(a, b),
|
||||||
|
('c' | 's' | 't' | 'p' | 'w' | 'g', 'h')
|
||||||
|
| ('b' | 'c' | 'd' | 'f' | 'g' | 'k' | 'p' | 't', 'r')
|
||||||
|
| ('b' | 'c' | 'f' | 'g' | 'k' | 'p' | 's', 'l')
|
||||||
|
| ('q', 'u')
|
||||||
|
| ('s', 't' | 'p' | 'c' | 'k')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Syllable heuristic: V-CV ("ty-po"), VC-CV ("hap-pen"), keeping onset clusters ("gra-phy").
|
||||||
|
fn heuristic_points(c: &[char]) -> Vec<usize> {
|
||||||
|
let n = c.len();
|
||||||
|
if n < 4 || !c.iter().all(|c| c.is_alphabetic()) {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let v: Vec<bool> = c.iter().map(|&x| is_vowel(x)).collect();
|
||||||
|
let mut out = vec![];
|
||||||
|
for i in 1..n {
|
||||||
|
let ok = if v[i - 1] && !v[i] {
|
||||||
|
(i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2])
|
||||||
|
} else if !v[i - 1] && !v[i] {
|
||||||
|
i >= 2 && v[i - 2] && i + 1 < n && v[i + 1] && !onset_pair(c[i - 1], c[i]) && c[i - 1] != c[i] || (c[i - 1] == c[i] && i >= 2 && v[i - 2])
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
// Both parts need a sounded vowel: a trailing silent `e`, `ed` or `es` doesn't count.
|
||||||
|
let right: String = c[i..].iter().collect();
|
||||||
|
let core = right.strip_suffix("ed").or_else(|| right.strip_suffix("es")).or_else(|| right.strip_suffix('e')).unwrap_or(&right);
|
||||||
|
if ok && core.chars().any(is_vowel) && core.len() > 1 && v[..i].iter().any(|x| *x) {
|
||||||
|
out.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn h(w: &str) -> String {
|
||||||
|
hyphenate_word(w, &Limits::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundled_data_loads() {
|
||||||
|
assert!(Dictionary::en_us().len() > 150_000, "{}", Dictionary::en_us().len());
|
||||||
|
assert!(Patterns::en_us().len() > 1000, "{}", Patterns::en_us().len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dictionary_words() {
|
||||||
|
assert_eq!(h("happen"), "hap-pen");
|
||||||
|
assert_eq!(h("typography"), "ty-pog-ra-phy");
|
||||||
|
assert_eq!(h("composition"), "com-po-si-tion");
|
||||||
|
assert_eq!(h("hyphenation"), "hy-phen-a-tion");
|
||||||
|
assert_eq!(h("international"), "in-ter-na-tion-al");
|
||||||
|
assert_eq!(h("cat"), "cat");
|
||||||
|
assert!(!h("changed").contains('-'));
|
||||||
|
assert_eq!(h("NASA"), "NASA");
|
||||||
|
assert_eq!(word_breaks("publication").1, Source::Dictionary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_apostrophes_and_punctuation() {
|
||||||
|
assert_eq!(h("Typography"), "Ty-pog-ra-phy");
|
||||||
|
assert_eq!(h("TYPOGRAPHY"), "TYPOGRAPHY");
|
||||||
|
assert_eq!(h("typography,"), "ty-pog-ra-phy,");
|
||||||
|
assert_eq!(h("“typography”"), "“ty-pog-ra-phy”");
|
||||||
|
assert_eq!(h("typography's"), "ty-pog-ra-phy's");
|
||||||
|
assert_eq!(h("typography’s"), "ty-pog-ra-phy’s");
|
||||||
|
assert_eq!(h("'typography'"), "'ty-pog-ra-phy'");
|
||||||
|
// Compounds: each part separately, never next to the hyphen.
|
||||||
|
assert_eq!(h("typography-composition"), "ty-pog-ra-phy-com-po-si-tion");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inflections_inherit_the_stem() {
|
||||||
|
assert_eq!(h("ripples"), "rip-ples");
|
||||||
|
assert_eq!(h("gradients"), "gra-di-ents");
|
||||||
|
assert_eq!(h("restrained"), "re-strained");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn patterns_cover_unknown_words() {
|
||||||
|
// Not in the word list: the trained patterns decide.
|
||||||
|
let (pts, src) = word_breaks("hyperlocalization");
|
||||||
|
assert_eq!(src, Source::Patterns);
|
||||||
|
assert!(pts.len() >= 4, "{}", h("hyperlocalization"));
|
||||||
|
let (_, src) = word_breaks("zzyzx");
|
||||||
|
assert_ne!(src, Source::Dictionary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limits_are_respected() {
|
||||||
|
let lim = Limits { min_word: 5, after_first: 3, before_last: 3, capitalized: false };
|
||||||
|
for w in ["typography", "hyphenation", "justification", "Paragraph", "beautiful"] {
|
||||||
|
for p in hyphen_points(w, &lim) {
|
||||||
|
assert!(p >= 3 && p <= w.chars().count() - 3, "{w} {p}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(hyphen_points("Paragraph", &lim).is_empty());
|
||||||
|
let lim = Limits { min_word: 12, ..Limits::default() };
|
||||||
|
assert!(hyphen_points("typography", &lim).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heuristic_fallback() {
|
||||||
|
let c: Vec<char> = "happen".chars().collect();
|
||||||
|
assert_eq!(heuristic_points(&c), vec![3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
//! WordCraft proofing.
|
||||||
|
//!
|
||||||
|
//! - **Spelling** against the public-domain Moby word list (~160k words, the same list that
|
||||||
|
//! drives hyphenation), with inflections (`-s`, `-es`, `-ed`, `-ing`, `-ly`, `'s`…), a user
|
||||||
|
//! dictionary and ignore lists; suggestions by edit distance.
|
||||||
|
//! - **Grammar** checks that are cheap and reliable: repeated words, `a`/`an`, capital letter
|
||||||
|
//! after a sentence end, spaces before punctuation, doubled spaces.
|
||||||
|
//! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns, heuristic.
|
||||||
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||||
|
|
||||||
|
pub mod dict;
|
||||||
|
pub mod hyphen;
|
||||||
|
pub mod patterns;
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::{OnceLock, RwLock};
|
||||||
|
|
||||||
|
/// A problem found in text: byte range, kind, message and suggestions.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Issue {
|
||||||
|
pub start: usize,
|
||||||
|
pub end: usize,
|
||||||
|
pub kind: IssueKind,
|
||||||
|
pub message: String,
|
||||||
|
pub suggestions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum IssueKind {
|
||||||
|
Spelling,
|
||||||
|
Grammar,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_words() -> &'static RwLock<HashSet<String>> {
|
||||||
|
static U: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
|
||||||
|
U.get_or_init(|| RwLock::new(HashSet::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a word to the user dictionary ("Add to Dictionary").
|
||||||
|
pub fn add_word(w: &str) {
|
||||||
|
user_words().write().unwrap_or_else(|e| e.into_inner()).insert(w.to_lowercase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Words in the user dictionary.
|
||||||
|
pub fn user_dictionary() -> Vec<String> {
|
||||||
|
let mut v: Vec<String> = user_words().read().unwrap_or_else(|e| e.into_inner()).iter().cloned().collect();
|
||||||
|
v.sort();
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common words the source list lacks (modern vocabulary, contractions).
|
||||||
|
const COMMON: &str = "a an the and or but if of to in on at by for with from as is am are was were be been being it its we us our you your he him his she her they them their i me my mine this that these those there here what which who whom whose when where why how all any both each few more most other some such no nor not only own same so than too very can will just should now do does did done has have had having may might must shall would could up down out over under again further then once off into onto upon about above below between through during before after until while because per via vs etc ok yes yeah oh ah hi hey bye one two three four five six seven eight nine ten first last next new old get got go goes went gone see saw seen say said make made take took come came give gave know knew think thought look want need use used find found tell told ask asked feel felt try left keep let put mean set run show turn move live play pay hear hold bring begin seem help talk start might";
|
||||||
|
|
||||||
|
const EXTRA: &[&str] = &[
|
||||||
|
"don't", "doesn't", "didn't", "can't", "won't", "isn't", "aren't", "wasn't", "weren't", "shouldn't", "wouldn't", "couldn't", "i'm", "i've", "i'd", "i'll",
|
||||||
|
"you're", "you've", "you'll", "you'd", "we're", "we've", "we'll", "they're", "they've", "they'll", "it's", "that's", "there's", "here's", "what's", "let's",
|
||||||
|
"email", "emails", "online", "website", "websites", "internet", "software", "app", "apps", "smartphone", "blog", "login", "logout", "username", "wifi",
|
||||||
|
"toolbar", "workflow", "workflows", "dataset", "datasets", "metadata", "startup", "startups", "covid", "selfie", "podcast", "podcasts", "hashtag",
|
||||||
|
"wordcraft", "artcraft", "discord", "github", "rust", "ok", "okay", "unlabelled", "labelled", "colour", "colours", "favourite", "centre", "organise",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn known_core(w: &str) -> bool {
|
||||||
|
let d = dict::Dictionary::en_us();
|
||||||
|
if d.get(w).is_some() || EXTRA.contains(&w) || COMMON.split(' ').any(|c| c == w) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if user_words().read().unwrap_or_else(|e| e.into_inner()).contains(w) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Inflections.
|
||||||
|
let stems: [(&str, &[&str]); 12] = [
|
||||||
|
("'s", &[""]),
|
||||||
|
("s", &[""]),
|
||||||
|
("es", &["", "e"]),
|
||||||
|
("ies", &["y"]),
|
||||||
|
("ed", &["", "e"]),
|
||||||
|
("ied", &["y"]),
|
||||||
|
("ing", &["", "e"]),
|
||||||
|
("ly", &["", "le"]),
|
||||||
|
("ily", &["y"]),
|
||||||
|
("er", &["", "e"]),
|
||||||
|
("est", &["", "e"]),
|
||||||
|
("ness", &[""]),
|
||||||
|
];
|
||||||
|
for (suf, adds) in stems {
|
||||||
|
if let Some(stem) = w.strip_suffix(suf)
|
||||||
|
&& stem.len() >= 2
|
||||||
|
{
|
||||||
|
for a in adds {
|
||||||
|
let s = format!("{stem}{a}");
|
||||||
|
if d.get(&s).is_some() || EXTRA.contains(&s.as_str()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Doubled consonant: running → run, stopped → stop.
|
||||||
|
let b = stem.as_bytes();
|
||||||
|
if (suf == "ing" || suf == "ed" || suf == "er" || suf == "est") && b.len() >= 3 && b.get(b.len() - 1) == b.get(b.len() - 2) {
|
||||||
|
if let Some(s) = stem.get(..stem.len() - 1)
|
||||||
|
&& d.get(s).is_some()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prefixes.
|
||||||
|
for pre in ["un", "re", "non", "pre", "over", "under", "co", "mis"] {
|
||||||
|
if let Some(rest) = w.strip_prefix(pre)
|
||||||
|
&& rest.len() >= 3
|
||||||
|
&& d.get(rest).is_some()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `word` spelled correctly? Numbers, single letters, ALL-CAPS acronyms, URLs and words with
|
||||||
|
/// digits are accepted.
|
||||||
|
pub fn is_correct(word: &str) -> bool {
|
||||||
|
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'');
|
||||||
|
let w = w.trim_matches('\'');
|
||||||
|
if w.chars().count() <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if w.chars().all(|c| !c.is_lowercase()) && w.chars().count() <= 6 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if !w.is_ascii() && !w.chars().any(|c| c.is_ascii_alphabetic()) {
|
||||||
|
return true; // other scripts: no dictionary
|
||||||
|
}
|
||||||
|
let lower = w.to_lowercase().replace('’', "'");
|
||||||
|
if known_core(&lower) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Hyphenated compounds: each part.
|
||||||
|
if lower.contains('-') {
|
||||||
|
return lower.split('-').all(|p| p.is_empty() || known_core(p));
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spelling suggestions for a misspelled word (best first, at most `max`).
|
||||||
|
pub fn suggest(word: &str, max: usize) -> Vec<String> {
|
||||||
|
let lower = word.to_lowercase();
|
||||||
|
let n = lower.chars().count();
|
||||||
|
if n == 0 || n > 40 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let d = dict::Dictionary::en_us();
|
||||||
|
let mut scored: Vec<(usize, i64, String)> = Vec::new();
|
||||||
|
let target: Vec<char> = lower.chars().collect();
|
||||||
|
for (w, _) in d.iter() {
|
||||||
|
let wn = w.chars().count();
|
||||||
|
if wn + 2 < n || wn > n + 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dist = damerau(&target, w);
|
||||||
|
if dist <= 2 {
|
||||||
|
let same_first = w.chars().next() == target.first().copied();
|
||||||
|
scored.push((dist, if same_first { 0 } else { 1 }, w.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scored.sort_by(|a, b| (a.0, a.1, a.2.len().abs_diff(n)).cmp(&(b.0, b.1, b.2.len().abs_diff(n))).then(a.2.cmp(&b.2)));
|
||||||
|
let cap = word.chars().next().is_some_and(char::is_uppercase);
|
||||||
|
let upper = word.chars().all(|c| !c.is_lowercase());
|
||||||
|
scored
|
||||||
|
.into_iter()
|
||||||
|
.take(max)
|
||||||
|
.map(|(_, _, w)| {
|
||||||
|
if upper && n > 1 {
|
||||||
|
w.to_uppercase()
|
||||||
|
} else if cap {
|
||||||
|
let mut c = w.chars();
|
||||||
|
c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or(w)
|
||||||
|
} else {
|
||||||
|
w
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optimal string alignment distance, early-exit at 3.
|
||||||
|
fn damerau(a: &[char], b: &str) -> usize {
|
||||||
|
let b: Vec<char> = b.chars().collect();
|
||||||
|
let (n, m) = (a.len(), b.len());
|
||||||
|
let mut prev2 = vec![0usize; m + 1];
|
||||||
|
let mut prev: Vec<usize> = (0..=m).collect();
|
||||||
|
let mut cur = vec![0usize; m + 1];
|
||||||
|
for i in 1..=n {
|
||||||
|
if let Some(c) = cur.get_mut(0) {
|
||||||
|
*c = i;
|
||||||
|
}
|
||||||
|
let mut row_min = i;
|
||||||
|
for j in 1..=m {
|
||||||
|
let (ai, bj) = (a.get(i - 1), b.get(j - 1));
|
||||||
|
let cost = usize::from(ai != bj);
|
||||||
|
let mut v = (prev.get(j).copied().unwrap_or(9) + 1).min(cur.get(j - 1).copied().unwrap_or(9) + 1).min(prev.get(j - 1).copied().unwrap_or(9) + cost);
|
||||||
|
if i > 1 && j > 1 && a.get(i - 1) == b.get(j - 2) && a.get(i - 2) == b.get(j - 1) {
|
||||||
|
v = v.min(prev2.get(j - 2).copied().unwrap_or(9) + 1);
|
||||||
|
}
|
||||||
|
if let Some(c) = cur.get_mut(j) {
|
||||||
|
*c = v;
|
||||||
|
}
|
||||||
|
row_min = row_min.min(v);
|
||||||
|
}
|
||||||
|
if row_min > 2 {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
std::mem::swap(&mut prev2, &mut prev);
|
||||||
|
std::mem::swap(&mut prev, &mut cur);
|
||||||
|
}
|
||||||
|
prev.get(m).copied().unwrap_or(9)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Words in `text`: (byte start, byte end).
|
||||||
|
pub fn words(text: &str) -> Vec<(usize, usize)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut start: Option<usize> = None;
|
||||||
|
let is_w = |c: char| c.is_alphanumeric() || c == '\'' || c == '’' || c == '-';
|
||||||
|
for (i, c) in text.char_indices() {
|
||||||
|
match (start, is_w(c)) {
|
||||||
|
(None, true) => start = Some(i),
|
||||||
|
(Some(s), false) => {
|
||||||
|
out.push((s, i));
|
||||||
|
start = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(s) = start {
|
||||||
|
out.push((s, text.len()));
|
||||||
|
}
|
||||||
|
// Trim surrounding apostrophes/hyphens.
|
||||||
|
out.into_iter()
|
||||||
|
.filter_map(|(a, b)| {
|
||||||
|
let w = text.get(a..b)?;
|
||||||
|
let lead = w.len() - w.trim_start_matches(['\'', '’', '-']).len();
|
||||||
|
let trail = w.len() - w.trim_end_matches(['\'', '’', '-']).len();
|
||||||
|
(a + lead < b - trail).then_some((a + lead, b - trail))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spelling issues in a paragraph of text (no suggestions; ask [`suggest`] on demand).
|
||||||
|
pub fn check_spelling(text: &str) -> Vec<Issue> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
for (a, b) in words(text) {
|
||||||
|
let Some(w) = text.get(a..b) else { continue };
|
||||||
|
// Skip URLs/emails.
|
||||||
|
let ts = text.get(..a).and_then(|t| t.rfind(char::is_whitespace)).map(|i| i + 1).unwrap_or(0);
|
||||||
|
let te = text.get(b..).and_then(|t| t.find(char::is_whitespace)).map(|i| b + i).unwrap_or(text.len());
|
||||||
|
let token = text.get(ts..te).unwrap_or("");
|
||||||
|
if token.contains("://") || token.contains('@') || token.starts_with("www.") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !is_correct(w) {
|
||||||
|
v.push(Issue { start: a, end: b, kind: IssueKind::Spelling, message: "Possible spelling mistake".into(), suggestions: Vec::new() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grammar issues in a paragraph of text.
|
||||||
|
pub fn check_grammar(text: &str) -> Vec<Issue> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
let ws = words(text);
|
||||||
|
for pair in ws.windows(2) {
|
||||||
|
let [(a0, b0), (a1, b1)] = pair else { continue };
|
||||||
|
let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue };
|
||||||
|
if gap.trim().is_empty() && w0.eq_ignore_ascii_case(w1) && w0.chars().all(char::is_alphabetic) && !matches!(w0.to_lowercase().as_str(), "had" | "that" | "bye" | "so") {
|
||||||
|
v.push(Issue { start: *a0, end: *b1, kind: IssueKind::Grammar, message: format!("Repeated word: \"{w1}\""), suggestions: vec![w0.to_string()] });
|
||||||
|
}
|
||||||
|
if gap == " " && (w0 == "a" || w0 == "A") && w1.chars().next().is_some_and(|c| "aeiouAEIOU".contains(c)) && !starts_consonant_sound(w1) {
|
||||||
|
v.push(Issue { start: *a0, end: *b0, kind: IssueKind::Grammar, message: "Use \"an\" before a vowel sound".into(), suggestions: vec![if w0 == "A" { "An" } else { "an" }.into()] });
|
||||||
|
}
|
||||||
|
if gap == " " && (w0 == "an" || w0 == "An") && w1.chars().next().is_some_and(|c| c.is_alphabetic() && !"aeiouAEIOUhH".contains(c)) && !w1.chars().all(|c| c.is_uppercase()) {
|
||||||
|
v.push(Issue { start: *a0, end: *b0, kind: IssueKind::Grammar, message: "Use \"a\" before a consonant sound".into(), suggestions: vec![if w0 == "An" { "A" } else { "a" }.into()] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Space before punctuation; doubled spaces.
|
||||||
|
for (i, _) in text.match_indices(" ,").chain(text.match_indices(" .")).chain(text.match_indices(" ;")) {
|
||||||
|
if text.get(i + 2..).is_some_and(|r| r.starts_with('.') || r.starts_with(',')) {
|
||||||
|
continue; // "..." or numbers like " .5"
|
||||||
|
}
|
||||||
|
v.push(Issue { start: i, end: i + 2, kind: IssueKind::Grammar, message: "Remove the space before the punctuation".into(), suggestions: vec![text.get(i + 1..i + 2).unwrap_or("").to_string()] });
|
||||||
|
}
|
||||||
|
for (i, _) in text.match_indices(" ") {
|
||||||
|
if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
v.push(Issue { start: i, end: i + 2, kind: IssueKind::Grammar, message: "Extra space".into(), suggestions: vec![" ".into()] });
|
||||||
|
}
|
||||||
|
// Sentence start capital.
|
||||||
|
let mut after_end = true;
|
||||||
|
for (a, b) in &ws {
|
||||||
|
let Some(w) = text.get(*a..*b) else { continue };
|
||||||
|
if after_end && w.chars().next().is_some_and(char::is_lowercase) && w.chars().all(char::is_alphabetic) {
|
||||||
|
let fixed: String = w.chars().next().map(|f| f.to_uppercase().chain(w.chars().skip(1)).collect()).unwrap_or_default();
|
||||||
|
v.push(Issue { start: *a, end: *b, kind: IssueKind::Grammar, message: "Capitalize the first word of a sentence".into(), suggestions: vec![fixed] });
|
||||||
|
}
|
||||||
|
let tail = text.get(*b..).unwrap_or("");
|
||||||
|
let next_non_space = tail.trim_start_matches(['"', '”', ')', '\'']).chars().next();
|
||||||
|
after_end = matches!(next_non_space, Some('.' | '!' | '?')) && tail.trim_start_matches(['.', '!', '?', '"', '”', ')']).starts_with(' ');
|
||||||
|
}
|
||||||
|
v.sort_by_key(|i| i.start);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn starts_consonant_sound(w: &str) -> bool {
|
||||||
|
let l = w.to_lowercase();
|
||||||
|
["uni", "use", "usu", "eu", "one", "once", "ubiq", "uti", "ure", "uro"].iter().any(|p| l.starts_with(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dictionary_loads() {
|
||||||
|
assert!(dict::Dictionary::en_us().len() > 100_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spelling() {
|
||||||
|
for w in ["if", "we", "hello", "world", "running", "studios", "don't", "Thursday", "NASA", "2026", "co-operate", "unhappy", "colour", "it's", "stopped"] {
|
||||||
|
assert!(is_correct(w), "{w}");
|
||||||
|
}
|
||||||
|
for w in ["helo", "wrold", "sentense", "recieve"] {
|
||||||
|
assert!(!is_correct(w), "{w}");
|
||||||
|
}
|
||||||
|
let s = suggest("recieve", 5);
|
||||||
|
assert!(s.contains(&"receive".to_string()), "{s:?}");
|
||||||
|
assert!(suggest("Wrold", 3).contains(&"World".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spelling_issues_in_text() {
|
||||||
|
let t = "This sentense has a mispelled word, see https://example.com/xyzq.";
|
||||||
|
let v = check_spelling(t);
|
||||||
|
let bad: Vec<&str> = v.iter().map(|i| &t[i.start..i.end]).collect();
|
||||||
|
assert_eq!(bad, vec!["sentense", "mispelled"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grammar_issues() {
|
||||||
|
let t = "this is is a apple , and an car. Done";
|
||||||
|
let v = check_grammar(t);
|
||||||
|
let msgs: Vec<&str> = v.iter().map(|i| i.message.as_str()).collect();
|
||||||
|
assert!(msgs.iter().any(|m| m.contains("Repeated")), "{msgs:?}");
|
||||||
|
assert!(msgs.iter().any(|m| m.contains("\"an\"")), "{msgs:?}");
|
||||||
|
assert!(msgs.iter().any(|m| m.contains("\"a\" before")), "{msgs:?}");
|
||||||
|
assert!(msgs.iter().any(|m| m.contains("space before")), "{msgs:?}");
|
||||||
|
assert!(msgs.iter().any(|m| m.contains("Capitalize")), "{msgs:?}");
|
||||||
|
assert!(msgs.iter().any(|m| m.contains("Extra space")), "{msgs:?}");
|
||||||
|
assert!(check_grammar("A university is an hour away.").is_empty(), "{:?}", check_grammar("A university is an hour away."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_dictionary_works() {
|
||||||
|
assert!(!is_correct("zxqwordcrafty"));
|
||||||
|
add_word("zxqwordcrafty");
|
||||||
|
assert!(is_correct("zxqwordcrafty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hyphenation() {
|
||||||
|
let l = hyphen::Limits::default();
|
||||||
|
assert_eq!(hyphen::hyphenate_word("hyphenation", &l).matches('-').count() >= 2, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest::proptest! {
|
||||||
|
#[test]
|
||||||
|
fn never_panics(s in "\\PC{0,80}") {
|
||||||
|
let _ = check_spelling(&s);
|
||||||
|
let _ = check_grammar(&s);
|
||||||
|
let _ = suggest(&s, 3);
|
||||||
|
let _ = hyphen::hyphen_points(&s, &hyphen::Limits::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//! Liang hyphenation patterns (TeX notation, e.g. `.ab1c`, `4t1ion`): parsing and application.
|
||||||
|
//!
|
||||||
|
//! The bundled English set is *ours*: trained from the public-domain Moby Hyphenator word list by
|
||||||
|
//! DesignCraft's patgen (see `examples/hyphgen.rs`). Patterns are stored in a hash map keyed by the
|
||||||
|
//! packed letter string, so applying them to a word costs `O(len × max_pattern_len)` lookups.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::hash::{BuildHasherDefault, Hasher};
|
||||||
|
|
||||||
|
/// Word-boundary marker in patterns.
|
||||||
|
pub const DOT: char = '.';
|
||||||
|
/// Longest pattern the packed key supports.
|
||||||
|
pub const MAX_PATTERN_LEN: usize = 9;
|
||||||
|
|
||||||
|
/// Small, fast multiplicative hasher for packed `u64` keys (FxHash-style).
|
||||||
|
#[derive(Default, Clone, Copy)]
|
||||||
|
pub struct FxHasher(u64);
|
||||||
|
|
||||||
|
impl Hasher for FxHasher {
|
||||||
|
fn finish(&self) -> u64 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
fn write(&mut self, bytes: &[u8]) {
|
||||||
|
for &b in bytes {
|
||||||
|
self.write_u64(b as u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn write_u64(&mut self, i: u64) {
|
||||||
|
self.0 = (self.0.rotate_left(5) ^ i).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type FxMap<K, V> = HashMap<K, V, BuildHasherDefault<FxHasher>>;
|
||||||
|
|
||||||
|
/// Maps characters to 6-bit codes (1 = word boundary, 2.. = letters); 0 = not in the alphabet.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Alphabet {
|
||||||
|
ascii: [u8; 128],
|
||||||
|
other: Vec<(char, u8)>,
|
||||||
|
next: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Alphabet {
|
||||||
|
fn default() -> Self {
|
||||||
|
let mut a = Alphabet { ascii: [0; 128], other: Vec::new(), next: 2 };
|
||||||
|
a.ascii[DOT as usize] = 1;
|
||||||
|
a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Alphabet {
|
||||||
|
pub fn code(&self, c: char) -> u8 {
|
||||||
|
if (c as u32) < 128 { self.ascii[c as usize] } else { self.other.iter().find(|x| x.0 == c).map_or(0, |x| x.1) }
|
||||||
|
}
|
||||||
|
/// Code for `c`, adding it if new (None when the 6-bit alphabet is full).
|
||||||
|
pub fn intern(&mut self, c: char) -> Option<u8> {
|
||||||
|
let k = self.code(c);
|
||||||
|
if k != 0 {
|
||||||
|
return Some(k);
|
||||||
|
}
|
||||||
|
if self.next >= 63 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let k = self.next;
|
||||||
|
self.next += 1;
|
||||||
|
if (c as u32) < 128 {
|
||||||
|
self.ascii[c as usize] = k;
|
||||||
|
} else {
|
||||||
|
self.other.push((c, k));
|
||||||
|
}
|
||||||
|
Some(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Packed key of a code string (≤ [`MAX_PATTERN_LEN`] codes).
|
||||||
|
#[inline]
|
||||||
|
pub fn pack(codes: &[u8]) -> u64 {
|
||||||
|
let mut k = 0u64;
|
||||||
|
for &c in codes {
|
||||||
|
k = (k << 6) | c as u64;
|
||||||
|
}
|
||||||
|
k | ((codes.len() as u64) << 58)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compiled pattern set.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct Patterns {
|
||||||
|
alpha: Alphabet,
|
||||||
|
/// Packed letters → inter-letter values (`len + 1` entries).
|
||||||
|
map: FxMap<u64, Box<[u8]>>,
|
||||||
|
max_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Patterns {
|
||||||
|
/// Parse TeX-style patterns: whitespace-separated, `%`/`#` start a comment line.
|
||||||
|
pub fn parse(text: &str) -> Patterns {
|
||||||
|
let mut p = Patterns::default();
|
||||||
|
for line in text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('%') || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for tok in line.split_whitespace() {
|
||||||
|
p.insert(tok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add one pattern like `a1b`; returns false if it can't be represented.
|
||||||
|
pub fn insert(&mut self, pat: &str) -> bool {
|
||||||
|
let mut codes = Vec::with_capacity(pat.len());
|
||||||
|
let mut vals = vec![0u8];
|
||||||
|
for c in pat.chars() {
|
||||||
|
if let Some(d) = c.to_digit(10) {
|
||||||
|
*vals.last_mut().unwrap_or(&mut 0) = d as u8;
|
||||||
|
} else {
|
||||||
|
let Some(k) = self.alpha.intern(c) else { return false };
|
||||||
|
codes.push(k);
|
||||||
|
vals.push(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if codes.is_empty() || codes.len() > MAX_PATTERN_LEN {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.max_len = self.max_len.max(codes.len());
|
||||||
|
self.map.insert(pack(&codes), vals.into_boxed_slice());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn from_parts(alpha: Alphabet, map: FxMap<u64, Box<[u8]>>, max_len: usize) -> Patterns {
|
||||||
|
Patterns { alpha, map, max_len }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `letters` part of the alphabet even if no pattern mentions them.
|
||||||
|
pub fn add_letters(&mut self, letters: impl IntoIterator<Item = char>) {
|
||||||
|
for c in letters {
|
||||||
|
let _ = self.alpha.intern(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.map.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.map.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inter-letter values for a lowercase word: `out[i]` is the value *before* char `i`
|
||||||
|
/// (`out.len() == word.len() + 1`). None if a char is outside the pattern alphabet.
|
||||||
|
pub fn values(&self, word: &[char]) -> Option<Vec<u8>> {
|
||||||
|
let mut codes = Vec::with_capacity(word.len() + 2);
|
||||||
|
codes.push(1);
|
||||||
|
for &c in word {
|
||||||
|
let k = self.alpha.code(c);
|
||||||
|
if k == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
codes.push(k);
|
||||||
|
}
|
||||||
|
codes.push(1);
|
||||||
|
let mut vals = vec![0u8; codes.len() + 1];
|
||||||
|
apply(&self.map, self.max_len, &codes, &mut vals);
|
||||||
|
// Padded position p sits before padded char p; word char i is padded char i + 1.
|
||||||
|
Some(vals[1..codes.len()].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Break points (char indices, a hyphen goes before that char) of a lowercase word.
|
||||||
|
pub fn points(&self, word: &[char]) -> Option<Vec<usize>> {
|
||||||
|
let v = self.values(word)?;
|
||||||
|
Some((1..word.len()).filter(|&i| v[i] % 2 == 1).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The patterns in TeX notation (sorted by letters), e.g. for writing a pattern file.
|
||||||
|
pub fn to_tex(&self) -> Vec<String> {
|
||||||
|
let mut rev: Vec<(u8, char)> =
|
||||||
|
(0u8..128).filter(|&c| self.alpha.ascii[c as usize] != 0).map(|c| (self.alpha.ascii[c as usize], c as char)).collect();
|
||||||
|
rev.extend(self.alpha.other.iter().map(|&(c, k)| (k, c)));
|
||||||
|
let ch = |k: u8| rev.iter().find(|x| x.0 == k).map_or('?', |x| x.1);
|
||||||
|
let mut out: Vec<(String, String)> = self
|
||||||
|
.map
|
||||||
|
.iter()
|
||||||
|
.map(|(&key, vals)| {
|
||||||
|
let n = (key >> 58) as usize;
|
||||||
|
let letters: Vec<char> = (0..n).map(|i| ch(((key >> (6 * (n - 1 - i))) & 63) as u8)).collect();
|
||||||
|
let mut s = String::new();
|
||||||
|
for (i, l) in letters.iter().enumerate() {
|
||||||
|
if vals[i] > 0 {
|
||||||
|
s.push(char::from(b'0' + vals[i]));
|
||||||
|
}
|
||||||
|
s.push(*l);
|
||||||
|
}
|
||||||
|
if vals[n] > 0 {
|
||||||
|
s.push(char::from(b'0' + vals[n]));
|
||||||
|
}
|
||||||
|
(letters.into_iter().collect(), s)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.sort();
|
||||||
|
out.into_iter().map(|x| x.1).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply `map` to padded `codes`, raising `vals` (len = codes.len() + 1).
|
||||||
|
#[inline]
|
||||||
|
pub fn apply(map: &FxMap<u64, Box<[u8]>>, max_len: usize, codes: &[u8], vals: &mut [u8]) {
|
||||||
|
let n = codes.len();
|
||||||
|
for s in 0..n {
|
||||||
|
let mut k = 0u64;
|
||||||
|
for len in 1..=max_len.min(n - s) {
|
||||||
|
k = (k << 6) | codes[s + len - 1] as u64;
|
||||||
|
if let Some(v) = map.get(&(k | ((len as u64) << 58))) {
|
||||||
|
for (j, &x) in v.iter().enumerate() {
|
||||||
|
if x > vals[s + j] {
|
||||||
|
vals[s + j] = x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn liang_example() {
|
||||||
|
// The classic example from Liang's thesis: "hy-phen-ation".
|
||||||
|
let p = Patterns::parse("hy3ph he2n hena4 hen5at 1na n2at 1tio 2io o2n");
|
||||||
|
let w: Vec<char> = "hyphenation".chars().collect();
|
||||||
|
assert_eq!(p.points(&w).unwrap(), vec![2, 6]);
|
||||||
|
let mut tex = p.to_tex();
|
||||||
|
tex.sort();
|
||||||
|
assert!(tex.contains(&"hen5at".to_string()));
|
||||||
|
assert!(p.values(&['h', 'é']).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user