6 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
github-actions[bot] 4975e76358 chore(release): v0.1.11 2026-07-13 14:51:25 +00:00
github-actions[bot] efeb64d289 chore(release): v0.1.10 2026-06-27 13:49:24 +00:00
pj cd41a02737 changes 2026-06-27 09:48:42 -04:00
pj 5a2ada9e9e ci(release): pin macOS runner to macos-26 2026-06-27 09:48:42 -04:00
9 changed files with 143 additions and 9 deletions

No files matched your search

+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- os: macos-latest - os: macos-26
args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json" args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json"
rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin" rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin"
- os: ubuntu-latest - os: ubuntu-latest
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "margin-app", "name": "margin-app",
"private": true, "private": true,
"version": "0.1.9", "version": "0.1.12",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+26 -1
View File
@@ -4713,11 +4713,14 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]] [[package]]
name = "margin-app" name = "margin-app"
version = "0.1.7" version = "0.1.9"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"fontdb", "fontdb",
"harper-core", "harper-core",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"rand 0.8.6", "rand 0.8.6",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
@@ -5138,9 +5141,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation", "objc2-foundation",
"objc2-quartz-core",
] ]
[[package]] [[package]]
@@ -5160,6 +5171,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [ dependencies = [
"bitflags 2.13.0",
"objc2", "objc2",
"objc2-foundation", "objc2-foundation",
] ]
@@ -5220,6 +5232,19 @@ dependencies = [
"objc2-core-graphics", "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]] [[package]]
name = "objc2-encode" name = "objc2-encode"
version = "4.1.0" version = "4.1.0"
+13 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "margin-app" name = "margin-app"
version = "0.1.9" version = "0.1.12"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -31,7 +31,6 @@ fontdb = "0.23"
typst = "0.14.2" typst = "0.14.2"
typst-pdf = "0.14.2" typst-pdf = "0.14.2"
typst-as-lib = "0.15.5" 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 # 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, # 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]) # so bump deliberately and re-audit the stubs. (see [patch.crates-io])
@@ -41,6 +40,18 @@ sha2 = "0.10"
rand = "0.8" rand = "0.8"
url = "2" 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). # harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend).
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm # 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 # prereleases) and links-collides with `zip`'s lzma. Replace it with an empty stub so the
+1
View File
@@ -6,6 +6,7 @@
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-destroy", "core:window:allow-destroy",
"core:window:allow-start-dragging",
"opener:default", "opener:default",
"dialog:default" "dialog:default"
] ]
+2
View File
@@ -2,6 +2,8 @@ mod epub;
mod fonts; mod fonts;
mod gdrive; mod gdrive;
mod library; mod library;
#[cfg(target_os = "macos")]
mod macspell;
mod pdf; mod pdf;
mod project; mod project;
mod proofing; 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 { pub struct Engine {
#[cfg(not(target_os = "macos"))]
speller: Option<spellbook::Dictionary>, speller: Option<spellbook::Dictionary>,
harper: Option<Harper>, harper: Option<Harper>,
custom: HashSet<String>, custom: HashSet<String>,
@@ -87,6 +88,7 @@ fn load_custom(app: &tauri::AppHandle) -> HashSet<String> {
set set
} }
#[cfg(not(target_os = "macos"))]
fn build_speller(app: &tauri::AppHandle) -> Result<spellbook::Dictionary, String> { fn build_speller(app: &tauri::AppHandle) -> Result<spellbook::Dictionary, String> {
let aff = dict_file(app, "index.aff")?; let aff = dict_file(app, "index.aff")?;
let dic = dict_file(app, "index.dic")?; let dic = dict_file(app, "index.dic")?;
@@ -100,18 +102,22 @@ fn build_harper() -> Harper {
Harper { linter, dict } Harper { linter, dict }
} }
#[cfg(not(target_os = "macos"))]
fn is_word_char(c: char) -> bool { fn is_word_char(c: char) -> bool {
c.is_alphabetic() c.is_alphabetic()
} }
#[cfg(not(target_os = "macos"))]
fn has_irregular_case(word: &str) -> bool { fn has_irregular_case(word: &str) -> bool {
word.chars().skip(1).any(|c| c.is_uppercase()) word.chars().skip(1).any(|c| c.is_uppercase())
} }
#[cfg(not(target_os = "macos"))]
fn is_apostrophe(c: char) -> bool { fn is_apostrophe(c: char) -> bool {
c == '\'' || c == '\u{2019}' c == '\'' || c == '\u{2019}'
} }
#[cfg(not(target_os = "macos"))]
fn collect_spelling( fn collect_spelling(
speller: &spellbook::Dictionary, speller: &spellbook::Dictionary,
custom: &HashSet<String>, custom: &HashSet<String>,
@@ -143,6 +149,10 @@ fn collect_spelling(
} }
let mut suggestions = Vec::new(); let mut suggestions = Vec::new();
speller.suggest(&word, &mut suggestions); speller.suggest(&word, &mut suggestions);
let lower = word.to_lowercase();
if suggestions.iter().any(|s| s.to_lowercase() == lower) {
continue;
}
suggestions.truncate(5); suggestions.truncate(5);
issues.push(Issue { issues.push(Issue {
start, start,
@@ -197,6 +207,7 @@ pub async fn proof_text(
let mut custom = load_custom(&app); let mut custom = load_custom(&app);
load_tech(&app, &mut custom); load_tech(&app, &mut custom);
*guard = Some(Engine { *guard = Some(Engine {
#[cfg(not(target_os = "macos"))]
speller: None, speller: None,
harper: None, harper: None,
custom, custom,
@@ -207,10 +218,24 @@ pub async fn proof_text(
let chars: Vec<char> = text.chars().collect(); let chars: Vec<char> = text.chars().collect();
let mut issues = Vec::new(); let mut issues = Vec::new();
if spelling { if spelling {
if engine.speller.is_none() { #[cfg(target_os = "macos")]
engine.speller = Some(build_speller(&app)?); 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 grammar {
if engine.harper.is_none() { if engine.harper.is_none() {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Margin", "productName": "Margin",
"version": "0.1.9", "version": "0.1.12",
"identifier": "studio.margin.app", "identifier": "studio.margin.app",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",