diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cca3b24..c2b38d9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4713,11 +4713,14 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "margin-app" -version = "0.1.7" +version = "0.1.9" dependencies = [ "base64 0.22.1", "fontdb", "harper-core", + "objc2", + "objc2-app-kit", + "objc2-foundation", "rand 0.8.6", "reqwest 0.12.28", "serde", @@ -5138,9 +5141,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -5160,6 +5171,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.13.0", "objc2", "objc2-foundation", ] @@ -5220,6 +5232,19 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6be4283..1324f6a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,7 +31,6 @@ fontdb = "0.23" typst = "0.14.2" typst-pdf = "0.14.2" typst-as-lib = "0.15.5" -spellbook = "0.4" # Pinned exactly: the [patch] stubs below are tied to this version's burn/cubecl graph. A minor # bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in, # so bump deliberately and re-audit the stubs. (see [patch.crates-io]) @@ -41,6 +40,18 @@ sha2 = "0.10" rand = "0.8" url = "2" +# Spelling on macOS uses the system NSSpellChecker (same engine as TextEdit/Safari): far better +# vocabulary than the bundled Hunspell dictionary and it skips URLs. These objc2 crates are already +# in the graph via Tauri, so this adds nothing new to the build. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSSpellChecker"] } +objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] } + +# Other platforms fall back to the bundled Hunspell dictionary via spellbook. +[target.'cfg(not(target_os = "macos"))'.dependencies] +spellbook = "0.4" + # harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend). # Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm # prereleases) and links-collides with `zip`'s lzma. Replace it with an empty stub so the diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 24fbb12..7ddb7aa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,8 @@ mod epub; mod fonts; mod gdrive; mod library; +#[cfg(target_os = "macos")] +mod macspell; mod pdf; mod project; mod proofing; diff --git a/src-tauri/src/macspell.rs b/src-tauri/src/macspell.rs new file mode 100644 index 0000000..9c45a76 --- /dev/null +++ b/src-tauri/src/macspell.rs @@ -0,0 +1,70 @@ +use std::collections::HashSet; + +use objc2::rc::autoreleasepool; +use objc2_app_kit::NSSpellChecker; +use objc2_foundation::{NSRange, NSString, NSTextCheckingType}; + +pub struct MacIssue { + pub start: usize, + pub end: usize, + pub word: String, + pub suggestions: Vec, +} + +fn utf16_to_codepoint(text: &str, utf16_len: usize) -> Vec { + let mut map = Vec::with_capacity(utf16_len + 1); + let mut cp = 0; + for ch in text.chars() { + for _ in 0..ch.len_utf16() { + map.push(cp); + } + cp += 1; + } + map.push(cp); + map +} + +pub fn check(text: &str, custom: &HashSet) -> Vec { + autoreleasepool(|_| { + let checker = NSSpellChecker::sharedSpellChecker(); + let ns = NSString::from_str(text); + let len = ns.length(); + let results = unsafe { + checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount( + &ns, + NSRange { location: 0, length: len }, + NSTextCheckingType::Spelling.bits(), + None, + 0, + None, + std::ptr::null_mut(), + ) + }; + + let map = utf16_to_codepoint(text, len); + let chars: Vec = text.chars().collect(); + let mut issues = Vec::new(); + for result in results.iter() { + let range = result.range(); + let start = map[range.location.min(len)]; + let end = map[(range.location + range.length).min(len)]; + let word: String = chars[start..end].iter().collect(); + if custom.contains(&word.to_lowercase()) { + continue; + } + let mut suggestions = Vec::new(); + if let Some(guesses) = + checker.guessesForWordRange_inString_language_inSpellDocumentWithTag(range, &ns, None, 0) + { + for guess in guesses.iter() { + suggestions.push(guess.to_string()); + if suggestions.len() >= 5 { + break; + } + } + } + issues.push(MacIssue { start, end, word, suggestions }); + } + issues + }) +} diff --git a/src-tauri/src/proofing.rs b/src-tauri/src/proofing.rs index e251f97..e699c9f 100644 --- a/src-tauri/src/proofing.rs +++ b/src-tauri/src/proofing.rs @@ -27,6 +27,7 @@ struct Harper { } pub struct Engine { + #[cfg(not(target_os = "macos"))] speller: Option, harper: Option, custom: HashSet, @@ -87,6 +88,7 @@ fn load_custom(app: &tauri::AppHandle) -> HashSet { set } +#[cfg(not(target_os = "macos"))] fn build_speller(app: &tauri::AppHandle) -> Result { let aff = dict_file(app, "index.aff")?; let dic = dict_file(app, "index.dic")?; @@ -100,18 +102,22 @@ fn build_harper() -> Harper { Harper { linter, dict } } +#[cfg(not(target_os = "macos"))] fn is_word_char(c: char) -> bool { c.is_alphabetic() } +#[cfg(not(target_os = "macos"))] fn has_irregular_case(word: &str) -> bool { word.chars().skip(1).any(|c| c.is_uppercase()) } +#[cfg(not(target_os = "macos"))] fn is_apostrophe(c: char) -> bool { c == '\'' || c == '\u{2019}' } +#[cfg(not(target_os = "macos"))] fn collect_spelling( speller: &spellbook::Dictionary, custom: &HashSet, @@ -143,6 +149,10 @@ fn collect_spelling( } let mut suggestions = Vec::new(); speller.suggest(&word, &mut suggestions); + let lower = word.to_lowercase(); + if suggestions.iter().any(|s| s.to_lowercase() == lower) { + continue; + } suggestions.truncate(5); issues.push(Issue { start, @@ -197,6 +207,7 @@ pub async fn proof_text( let mut custom = load_custom(&app); load_tech(&app, &mut custom); *guard = Some(Engine { + #[cfg(not(target_os = "macos"))] speller: None, harper: None, custom, @@ -207,10 +218,24 @@ pub async fn proof_text( let chars: Vec = text.chars().collect(); let mut issues = Vec::new(); if spelling { - if engine.speller.is_none() { - engine.speller = Some(build_speller(&app)?); + #[cfg(target_os = "macos")] + for mi in crate::macspell::check(&text, &engine.custom) { + issues.push(Issue { + start: mi.start, + end: mi.end, + kind: "spelling".into(), + category: "Spelling".into(), + message: format!("“{}” may be misspelled", mi.word), + suggestions: mi.suggestions, + }); + } + #[cfg(not(target_os = "macos"))] + { + if engine.speller.is_none() { + engine.speller = Some(build_speller(&app)?); + } + collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues); } - collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues); } if grammar { if engine.harper.is_none() {