2 Commits
Author SHA1 Message Date
github-actions[bot] af389c7097 chore(release): v0.1.12 2026-07-13 15:00:54 +00:00
pj f11ddd290f fix(proofing): use macOS NSSpellChecker for spelling on macOS
The bundled Hunspell dictionary (~49k SCOWL roots) lacked common words like
"cybersecurity", "scalable", and "assistantship", and checked words inside
URLs. On macOS, spelling now uses the system NSSpellChecker (same engine as
TextEdit/Safari): far better vocabulary, skips URLs, and respects words the
user has taught macOS. The custom/tech dictionaries and Harper grammar are
unchanged; non-macOS builds keep the Hunspell fallback.
2026-07-13 20:27:58 +05:30
7 changed files with 141 additions and 8 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "margin-app",
"private": true,
"version": "0.1.11",
"version": "0.1.12",
"type": "module",
"scripts": {
"dev": "vite",
+26 -1
View File
@@ -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"
+13 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "margin-app"
version = "0.1.11"
version = "0.1.12"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -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
+2
View File
@@ -2,6 +2,8 @@ mod epub;
mod fonts;
mod gdrive;
mod library;
#[cfg(target_os = "macos")]
mod macspell;
mod pdf;
mod project;
mod proofing;
+70
View File
@@ -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<String>,
}
fn utf16_to_codepoint(text: &str, utf16_len: usize) -> Vec<usize> {
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<String>) -> Vec<MacIssue> {
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<char> = 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
})
}
+28 -3
View File
@@ -27,6 +27,7 @@ struct Harper {
}
pub struct Engine {
#[cfg(not(target_os = "macos"))]
speller: Option<spellbook::Dictionary>,
harper: Option<Harper>,
custom: HashSet<String>,
@@ -87,6 +88,7 @@ fn load_custom(app: &tauri::AppHandle) -> HashSet<String> {
set
}
#[cfg(not(target_os = "macos"))]
fn build_speller(app: &tauri::AppHandle) -> Result<spellbook::Dictionary, String> {
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<String>,
@@ -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<char> = 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() {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Margin",
"version": "0.1.11",
"version": "0.1.12",
"identifier": "studio.margin.app",
"build": {
"beforeDevCommand": "pnpm dev",