187 lines
9.1 KiB
Rust
187 lines
9.1 KiB
Rust
//! Hunspell dictionaries installed on the system, read at runtime.
|
|
//!
|
|
//! WordCraft doesn't bundle large spelling dictionaries. For languages with an affix dictionary
|
|
//! (Polish), it reads the standard Hunspell `.aff`/`.dic` pair installed by the operating system,
|
|
//! a package manager or LibreOffice, with [`crate::affix::AffixDict::from_hunspell`]. When none is
|
|
//! installed the language has no spelling check (no marks, no errors), like Word without the
|
|
//! language's proofing tools.
|
|
//!
|
|
//! Search order: the directory in `WORDCRAFT_DICTIONARIES` (or one set with
|
|
//! [`set_dictionary_dir`]), then the usual system locations. On the web there are none.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::RwLock;
|
|
|
|
use crate::affix::AffixDict;
|
|
|
|
/// Largest `.aff` or `.dic` file read (real ones are a few MB).
|
|
const MAX_FILE: u64 = 64 << 20;
|
|
|
|
/// An extra directory searched first (a setting), set by the front end.
|
|
static EXTRA_DIR: RwLock<Option<PathBuf>> = RwLock::new(None);
|
|
|
|
/// Search `dir` before the system locations (a "dictionaries folder" setting); `None` clears it.
|
|
/// Takes effect for dictionaries not loaded yet.
|
|
pub fn set_dictionary_dir(dir: Option<PathBuf>) {
|
|
*EXTRA_DIR.write().unwrap_or_else(|e| e.into_inner()) = dir;
|
|
}
|
|
|
|
/// The LibreOffice extension directories a Hunspell dictionary name may live in: LibreOffice's
|
|
/// IDs are per dictionary — Polish is `dict-pl`, Brazilian Portuguese `dict-pt-BR` — so both
|
|
/// the primary-language folder and the dashed full name are tried.
|
|
fn lo_ext_dirs(name: &str) -> Vec<String> {
|
|
let primary = name.split(['-', '_']).next().unwrap_or(name);
|
|
let dashed = name.replace('_', "-");
|
|
let first = format!("dict-{primary}");
|
|
let full = format!("dict-{dashed}");
|
|
if full == first { vec![first] } else { vec![first, full] }
|
|
}
|
|
|
|
/// Directories that may hold Hunspell dictionaries, most specific first. `lo_exts`, when a
|
|
/// language is being searched for, adds LibreOffice's extension folders for it (`dict-pt-BR`).
|
|
pub fn search_dirs(lo_exts: Option<&[String]>) -> Vec<PathBuf> {
|
|
let mut dirs: Vec<PathBuf> = Vec::new();
|
|
if let Some(d) = EXTRA_DIR.read().unwrap_or_else(|e| e.into_inner()).clone() {
|
|
dirs.push(d);
|
|
}
|
|
if cfg!(target_arch = "wasm32") {
|
|
return dirs;
|
|
}
|
|
if let Some(d) = std::env::var_os("WORDCRAFT_DICTIONARIES").filter(|v| !v.is_empty()) {
|
|
dirs.push(PathBuf::from(d));
|
|
}
|
|
// LibreOffice keeps each language under `…/share/extensions/<dict-xx>`.
|
|
let lo_dirs =
|
|
|prefix: &str| -> Vec<PathBuf> { lo_exts.map_or_else(Vec::new, |exts| exts.iter().map(|e| PathBuf::from(prefix).join(e)).collect()) };
|
|
let home = std::env::var_os("HOME").filter(|v| !v.is_empty()).map(PathBuf::from);
|
|
if cfg!(windows) {
|
|
for var in ["LOCALAPPDATA", "APPDATA"] {
|
|
if let Some(base) = std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from) {
|
|
dirs.push(base.join("WordCraft").join("dictionaries"));
|
|
dirs.push(base.join("hunspell"));
|
|
}
|
|
}
|
|
for var in ["ProgramFiles", "ProgramFiles(x86)"] {
|
|
if let Some(base) = std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from) {
|
|
dirs.extend(lo_dirs(&base.join("LibreOffice").join("share").join("extensions").to_string_lossy()));
|
|
}
|
|
}
|
|
} else if cfg!(target_os = "macos") {
|
|
if let Some(h) = &home {
|
|
dirs.push(h.join("Library/Spelling"));
|
|
}
|
|
dirs.push(PathBuf::from("/Library/Spelling"));
|
|
dirs.push(PathBuf::from("/opt/homebrew/share/hunspell"));
|
|
dirs.push(PathBuf::from("/usr/local/share/hunspell"));
|
|
dirs.extend(lo_dirs("/Applications/LibreOffice.app/Contents/Resources/extensions"));
|
|
} else {
|
|
if let Some(h) = &home {
|
|
dirs.push(h.join(".local/share/hunspell"));
|
|
}
|
|
for d in ["/usr/share/hunspell", "/usr/share/myspell", "/usr/share/myspell/dicts", "/usr/local/share/hunspell", "/usr/local/share/myspell"] {
|
|
dirs.push(PathBuf::from(d));
|
|
}
|
|
for d in ["/usr/lib/libreoffice/share/extensions", "/opt/libreoffice/share/extensions"] {
|
|
dirs.extend(lo_dirs(d));
|
|
}
|
|
}
|
|
dirs
|
|
}
|
|
|
|
/// The first `<name>.aff`/`<name>.dic` pair found for any of `names` (`pl_PL`, `pl`), searching
|
|
/// [`search_dirs`] (including LibreOffice's extension folder for the first name).
|
|
pub fn find(names: &[&str]) -> Option<(PathBuf, PathBuf)> {
|
|
let lo = names.first().copied().map(lo_ext_dirs);
|
|
search_dirs(lo.as_deref()).into_iter().find_map(|dir| find_in(&dir, names))
|
|
}
|
|
|
|
/// The first `<name>.aff`/`<name>.dic` pair for any of `names` in `dir`.
|
|
pub fn find_in(dir: &Path, names: &[&str]) -> Option<(PathBuf, PathBuf)> {
|
|
names.iter().find_map(|n| {
|
|
let aff = dir.join(format!("{n}.aff"));
|
|
let dic = dir.join(format!("{n}.dic"));
|
|
(aff.is_file() && dic.is_file()).then_some((aff, dic))
|
|
})
|
|
}
|
|
|
|
/// Read a Hunspell pair from disk (size-capped), decoded per the `.aff`'s `SET` line.
|
|
pub fn load(aff: &Path, dic: &Path) -> Result<AffixDict, String> {
|
|
let read = |p: &Path| -> Result<Vec<u8>, String> {
|
|
let len = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?.len();
|
|
if len > MAX_FILE {
|
|
return Err(format!("{}: too large ({len} bytes)", p.display()));
|
|
}
|
|
std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))
|
|
};
|
|
let (aff_text, dic_text) = decode(&read(aff)?, &read(dic)?)?;
|
|
let source = format!("Hunspell dictionary {}", dic.display());
|
|
AffixDict::from_hunspell(&aff_text, &dic_text, &[source.as_str()])
|
|
}
|
|
|
|
/// ISO 8859-2 code points 0xA0..=0xFF (0x00..=0x9F map to themselves).
|
|
const ISO_8859_2_HIGH: &str = "\u{a0}Ą˘Ł¤ĽŚ§¨ŠŞŤŹ\u{ad}ŽŻ°ą˛ł´ľśˇ¸šşťź˝žżŔÁÂĂÄĹĆÇČÉĘËĚÍÎĎĐŃŇÓÔŐÖ×ŘŮÚŰÜÝŢßŕáâăäĺćçčéęëěíîďđńňóôőö÷řůúűüýţ˙";
|
|
|
|
fn decode_latin2(bytes: &[u8]) -> String {
|
|
let high: Vec<char> = ISO_8859_2_HIGH.chars().collect();
|
|
bytes.iter().map(|&b| if b < 0xa0 { char::from(b) } else { high.get(usize::from(b - 0xa0)).copied().unwrap_or('\u{fffd}') }).collect()
|
|
}
|
|
|
|
/// Decode an `.aff`/`.dic` pair to UTF-8 from the encoding the `.aff`'s `SET` line names
|
|
/// (UTF-8 when there is none; ISO 8859-2 is the other one Polish dictionaries use).
|
|
pub fn decode(aff: &[u8], dic: &[u8]) -> Result<(String, String), String> {
|
|
let head = String::from_utf8_lossy(aff.get(..aff.len().min(8192)).unwrap_or_default()).to_string();
|
|
let set = head.lines().find_map(|l| l.trim_start_matches('\u{feff}').strip_prefix("SET ")).map(str::trim).unwrap_or("UTF-8").to_ascii_uppercase();
|
|
match set.as_str() {
|
|
"UTF-8" | "UTF8" => {
|
|
let text = |b: &[u8], what: &str| String::from_utf8(b.to_vec()).map_err(|e| format!("{what}: not UTF-8: {e}"));
|
|
Ok((text(aff, ".aff")?, text(dic, ".dic")?))
|
|
}
|
|
"ISO8859-2" | "ISO-8859-2" => Ok((decode_latin2(aff), decode_latin2(dic))),
|
|
other => Err(format!("unsupported encoding {other}")),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn decodes_latin2_and_utf8() {
|
|
let aff = b"SET ISO8859-2\nTRY a\n";
|
|
let (a, d) = decode(aff, b"2\n\xbf\xf3\xb3w\n").unwrap();
|
|
assert!(a.contains("TRY a"));
|
|
assert_eq!(d, "2\nżółw\n");
|
|
let (_, d) = decode("SET UTF-8\n".as_bytes(), "żółw".as_bytes()).unwrap();
|
|
assert_eq!(d, "żółw");
|
|
assert!(decode(b"SET KOI8-R\n", b"").is_err());
|
|
assert!(decode(b"SET UTF-8\n", b"\xff\xfe").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn libreoffice_extension_dirs_follow_the_language() {
|
|
// LibreOffice's IDs are per dictionary: Polish is `dict-pl`, Brazilian Portuguese
|
|
// `dict-pt-BR`, so both the primary-language folder and the dashed full name are tried.
|
|
assert_eq!(super::lo_ext_dirs("pl_PL"), ["dict-pl", "dict-pl-PL"]);
|
|
assert_eq!(super::lo_ext_dirs("pt_BR"), ["dict-pt", "dict-pt-BR"]);
|
|
assert_eq!(super::lo_ext_dirs("de-DE"), ["dict-de", "dict-de-DE"]);
|
|
let dirs = search_dirs(Some(&super::lo_ext_dirs("pt_BR")));
|
|
assert!(dirs.iter().any(|d| d.ends_with("dict-pt-BR")), "{dirs:?}");
|
|
assert!(search_dirs(None).iter().all(|d| !d.to_string_lossy().contains("dict-")), "no language: no LibreOffice guess");
|
|
}
|
|
|
|
#[test]
|
|
fn finds_and_loads_a_pair_in_a_directory() {
|
|
let dir = std::env::temp_dir().join(format!("wordcraft-hunspell-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
assert!(find_in(&dir, &["xx_XX"]).is_none(), "no files: nothing found");
|
|
std::fs::write(dir.join("xx_XX.aff"), "SET UTF-8\nSFX A Y 1\nSFX A 0 y .\n").unwrap();
|
|
assert!(find_in(&dir, &["xx_XX"]).is_none(), "an .aff alone isn't a dictionary");
|
|
std::fs::write(dir.join("xx_XX.dic"), "1\nkot/A\n").unwrap();
|
|
let (aff, dic) = find_in(&dir, &["yy", "xx_XX"]).unwrap();
|
|
let d = load(&aff, &dic).unwrap();
|
|
assert!(d.check("kot") && d.check("koty") && !d.check("kotx"));
|
|
assert!(load(&dir.join("missing.aff"), &dic).is_err());
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|