From 87a10fc8e2af63042e560e530336f1cc6ff8c535 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:19:15 +0100 Subject: [PATCH 1/2] Keep combining marks inside spell-check words Found while testing crates/engine/src/spell.rs:693 (paragraph_words, the `inside` closure) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/spell.rs | 12 ++++++++++-- crates/model/src/words.rs | 7 ++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs index 93852e3f..8927ade5 100644 --- a/crates/engine/src/spell.rs +++ b/crates/engine/src/spell.rs @@ -709,7 +709,8 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec Vec a && !chars[b - 1].0.is_alphanumeric() { + while b > a && !chars[b - 1].0.is_alphanumeric() && !deckcraft_model::words::is_combining_mark(chars[b - 1].0) { b -= 1; } let text: String = chars[a..b].iter().map(|c| c.0).collect(); @@ -819,6 +820,13 @@ SFX D Y 1\nSFX D 0 ed/S .\n"; Dictionary::parse(AFF.as_bytes(), DIC.as_bytes()) } + #[test] + fn nfd_word_not_split() { + let p = deckcraft_model::Paragraph::new("Das na\u{ef}ve Caf\u{e9} und cafe\u{301} na\u{308}ive"); + let w: Vec = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect(); + assert_eq!(w, ["Das", "na\u{ef}ve", "Caf\u{e9}", "und", "cafe\u{301}", "na\u{308}ive"]); + } + #[test] fn curly_apostrophe_matches_ascii_entry() { let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes()); diff --git a/crates/model/src/words.rs b/crates/model/src/words.rs index 54b23659..bcf662e2 100644 --- a/crates/model/src/words.rs +++ b/crates/model/src/words.rs @@ -90,11 +90,16 @@ fn base_class(c: char) -> Class { c if c.is_alphanumeric() => Class::Word, c if is_symbol(c) => Class::Symbol, // A combining mark with no base (or any other mark) stays with letters. - '\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}' => Class::Word, + c if is_combining_mark(c) => Class::Word, _ => Class::Punct, } } +/// Whether `c` is a combining mark (a decomposed accent), which belongs to the letter before it. +pub fn is_combining_mark(c: char) -> bool { + matches!(c, '\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}') +} + fn clusters(text: &str) -> Vec { let mut out: Vec = Vec::new(); let mut firsts: Vec = Vec::new(); From ffd77c7db00fb080f02b92663b6ef006e9f706ae Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 04:53:47 -0500 Subject: [PATCH 2/2] Spell check: a mark on trailing punctuation is trimmed with it Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/spell.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs index 8927ade5..2dda5c6f 100644 --- a/crates/engine/src/spell.rs +++ b/crates/engine/src/spell.rs @@ -726,8 +726,14 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec a && !chars[b - 1].0.is_alphanumeric() && !deckcraft_model::words::is_combining_mark(chars[b - 1].0) { - b -= 1; + // Trim back to the last letter or digit, keeping only the marks that sit on it + // (a mark on a trailing apostrophe goes with the apostrophe). + while b > a { + let k = (a..b).rev().find(|&k| !deckcraft_model::words::is_combining_mark(chars[k].0)).unwrap_or(a); + if chars[k].0.is_alphanumeric() { + break; + } + b = k; } let text: String = chars[a..b].iter().map(|c| c.0).collect(); if text.is_empty() || text.chars().any(|c| c.is_ascii_digit()) || !text.chars().any(char::is_alphabetic) { @@ -827,6 +833,13 @@ SFX D Y 1\nSFX D 0 ed/S .\n"; assert_eq!(w, ["Das", "na\u{ef}ve", "Caf\u{e9}", "und", "cafe\u{301}", "na\u{308}ive"]); } + #[test] + fn a_mark_on_trailing_punctuation_is_trimmed_with_it() { + let p = deckcraft_model::Paragraph::new("word'\u{301} cafe\u{301}' x"); + let w: Vec = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect(); + assert_eq!(w, ["word", "cafe\u{301}", "x"]); + } + #[test] fn curly_apostrophe_matches_ascii_entry() { let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes());