From 19c1231924de50279d816e2ba241cd38e1c62c1e Mon Sep 17 00:00:00 2001 From: sungkhum <1120218+sungkhum@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:58:57 -0800 Subject: [PATCH 01/11] PF1: spelling on the shared engine and the shared spelling review Spelling moves to craft-proof, so each run of text is checked in its own language with whatever dictionary it has, and Khmer gets spelling and preferred forms with its optional data. English grammar rules stay off. The review is the shared craft-proof-ui: a docked Spelling panel that keeps the document editable, and suggestions in the right-click menu on a misspelled word. Dynamic Spelling proofs a story again only when its text, styles or the user dictionary change. Co-Authored-By: Claude Opus 5.5 --- ASSETS.md | 1 + Cargo.lock | 38 +++ Cargo.toml | 6 + NOTICE | 37 +++ ROADMAP.md | 2 + crates/doc/src/attrs/language.rs | 1 + crates/doc/src/attrs/tests.rs | 4 + crates/engine/Cargo.toml | 2 + crates/engine/src/cmd/spelling.rs | 271 ++++++++++++++--- crates/engine/src/cmd/spelling/khmer.rs | 9 + crates/engine/src/cmd/spelling/khmer/tests.rs | 274 ++++++++++++++++++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/app.rs | 2 + crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/canvas/input.rs | 1 + crates/ui-egui/src/canvas/tests.rs | 30 ++ crates/ui-egui/src/canvas/text_overlay.rs | 52 +++- crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menus/context.rs | 3 + crates/ui-egui/src/menus/invoke.rs | 4 + crates/ui-egui/src/menus/quick_apply.rs | 6 +- crates/ui-egui/src/menus/run.rs | 4 + crates/ui-egui/src/menus/state.rs | 6 + crates/ui-egui/src/menus/tables.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 12 + crates/ui-egui/src/panel_docking.rs | 4 +- crates/ui-egui/src/panels.rs | 1 + crates/ui-egui/src/panels/properties/text.rs | 1 + crates/ui-egui/src/panels/spelling.rs | 170 +++++++++++ crates/ui-egui/src/panels/spelling/tests.rs | 130 +++++++++ crates/ui-egui/src/theme.rs | 31 ++ docs/gaps.md | 2 +- docs/localization-parity.md | 2 +- docs/roadmap.md | 2 +- docs/spelling.md | 54 ++++ docs/typography-parity.md | 2 +- 37 files changed, 1108 insertions(+), 66 deletions(-) create mode 100644 crates/engine/src/cmd/spelling/khmer.rs create mode 100644 crates/engine/src/cmd/spelling/khmer/tests.rs create mode 100644 crates/ui-egui/src/panels/spelling.rs create mode 100644 crates/ui-egui/src/panels/spelling/tests.rs create mode 100644 docs/spelling.md diff --git a/ASSETS.md b/ASSETS.md index 4297ab421d..73cf0eecf3 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -8,6 +8,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| Optional Khmer model, spelling/frequency dictionaries and preferred-form rules (embedded by `craft-khmer`) | SBBIC Khmer; sources include SEAlang (Doug Cooper) and SIL NRSI khmerlbdict | https://github.com/sbbic-khmer/aksara-khmer-word-processor/tree/31740e9d51d38171ae6119fc2d18a4a18fbfb2f1 | MIT (full notices in NOTICE) | Explicit `CRAFT_KHMER_DIR` input; source and canonical hashes verified by the pinned shared crate; no dictionaries stored in this repository | | German hyphenation patterns (embedded by `hypher` 0.1.8, feature `german`) | Deutschsprachige Trennmustermannschaft: Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr | https://github.com/typst/hypher/blob/d81cc506416bffef2a75ee2dc969d4b41bc36613/patterns/hyph-de-1996.tex | MIT (copyright 2013–2024; full text in NOTICE) | Version 2024-02-28; reformed German spelling (2006), despite the `de-1996` filename; no separate old-spelling or Swiss patterns | | `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts | | `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts | diff --git a/Cargo.lock b/Cargo.lock index 549312de57..03c8712d6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1075,6 +1075,19 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-khmer" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=2322f68f2e24bebb6e89e8333de3bbb7d02d1704#2322f68f2e24bebb6e89e8333de3bbb7d02d1704" +dependencies = [ + "brotli", + "brotli-decompressor", + "crc32fast", + "regex", + "serde_json", + "sha2", +] + [[package]] name = "craft-pdf" version = "0.1.0" @@ -1114,6 +1127,26 @@ dependencies = [ "winsafe", ] +[[package]] +name = "craft-proof" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=2322f68f2e24bebb6e89e8333de3bbb7d02d1704#2322f68f2e24bebb6e89e8333de3bbb7d02d1704" +dependencies = [ + "craft-khmer", + "log", + "miniz_oxide 0.9.1", + "toml", + "unicode-normalization", +] + +[[package]] +name = "craft-proof-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=5c7a410e6ce1dc9672ad75b75d5dd424986fa956#5c7a410e6ce1dc9672ad75b75d5dd424986fa956" +dependencies = [ + "egui", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1397,8 +1430,10 @@ dependencies = [ "caseless", "craft-atomic", "craft-docpath", + "craft-khmer", "craft-prepress", "craft-print", + "craft-proof", "designcraft-color", "designcraft-compose", "designcraft-doc", @@ -1615,6 +1650,7 @@ dependencies = [ "craft-dialogs", "craft-keymap", "craft-prepress", + "craft-proof-ui", "craft-ui", "craft-update", "craft-window", @@ -5304,6 +5340,8 @@ version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash 0.2.0", + "indexmap", "itoa", "memchr", "serde", diff --git a/Cargo.toml b/Cargo.toml index dc20f8acaa..a147aa85b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,12 @@ rust-version = "1.95" repository = "https://github.com/storytold/designcraft" [workspace.dependencies] +# Shared Khmer segmentation and proofreading; data is an explicit, hash-verified build input. +craft-khmer = { git = "https://github.com/storytold/craft-libs", rev = "2322f68f2e24bebb6e89e8333de3bbb7d02d1704", version = "=0.1.0" } +# Shared proofing: spelling, suggestions and Khmer preferred forms for each language's dictionary. +craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "2322f68f2e24bebb6e89e8333de3bbb7d02d1704", version = "=0.1.0" } +# The shared spelling review: the Spelling panel and its right-click section. +craft-proof-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c7a410e6ce1dc9672ad75b75d5dd424986fa956", version = "=0.1.0" } # Shared reader and writer; publish the immutable revision before this consumer. craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false } # Shared keyboard bindings: chords, overrides, conflicts, egui dispatch, the Keyboard Shortcuts dialog. diff --git a/NOTICE b/NOTICE index 47a709d39e..2b0c345b17 100644 --- a/NOTICE +++ b/NOTICE @@ -221,3 +221,40 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Khmer language engine and optional language data +----------------------------------------------- +Shared craft-khmer is adapted from SBBIC Khmer’s Aksara at +31740e9d51d38171ae6119fc2d18a4a18fbfb2f1. The unchanged data is an optional, +hash-verified build input; no publisher documents or additional fonts are bundled. +SBBIC identifies internal work, the SEAlang frequency lists +(http://sealang.net/project/list/) and SIL NRSI’s Khmer line-breaking dictionary +(https://github.com/silnrsi/khmerlbdict) as sources. These materials are MIT +licensed; their copyright and permission notices follow. Builds without +CRAFT_KHMER_DIR omit this data. See the craft-khmer README for provenance and its limits. +The attached-glyph protection in composition was informed by visarutforthaipbs’s +DesignCraft #516. Shared-core contributions are credited in craft-khmer. + +MIT License + +Copyright (c) 2025 SBBIC Khmer +Copyright (c) 2015 SIL NRSI +Copyright (c) 2010, Doug Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ROADMAP.md b/ROADMAP.md index a13912f7c2..ba5e4924b9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -388,6 +388,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-11-03 · PF1:** Spelling moves to the shared `craft-proof`, with the shared Spelling panel and right-click suggestions; Khmer is checked with its optional data. Dynamic Spelling proofs a story again only when its text, styles or the user dictionary change. See [spelling.md](docs/spelling.md). No readiness percentage change. + - **2026-11-02** · EPUB Export Options names a cover image file (PNG or JPEG, typed or chosen with Choose…) for the reflowable export; left empty, the first page stays the cover (#1934). No readiness percentage change. - **2026-11-02** · The Pen shows the segment a dragged smooth point shapes while it is dragged, and its rubber band curves out of a smooth point's direction line (#1438, first item). No readiness percentage change. - **2026-11-02** · A Custom text variable defined with a number (a typed `2026` reaches the command as one) shows its digits instead of nothing (#2093, first item). No readiness percentage change. diff --git a/crates/doc/src/attrs/language.rs b/crates/doc/src/attrs/language.rs index 35ac044a15..54d34f4d55 100644 --- a/crates/doc/src/attrs/language.rs +++ b/crates/doc/src/attrs/language.rs @@ -83,6 +83,7 @@ pub fn language_tag(language: &str) -> Option<&'static str> { "turkish" => "tr", "japanese" => "ja", "korean" => "ko", + "khmer" | "cambodian" => "km", "arabic" => "ar", "persian" | "farsi" => "fa", "urdu" => "ur", diff --git a/crates/doc/src/attrs/tests.rs b/crates/doc/src/attrs/tests.rs index 068fbaee8e..eb306171c2 100644 --- a/crates/doc/src/attrs/tests.rs +++ b/crates/doc/src/attrs/tests.rs @@ -79,6 +79,10 @@ fn language_names_map_to_bcp47_tags() { assert_eq!(language_tag("Georgian"), Some("ka")); assert_eq!(language_tag("Japanese"), Some("ja")); assert_eq!(language_tag("Korean"), Some("ko")); + assert_eq!(language_tag("Khmer"), Some("km")); + assert_eq!(language_tag("$ID/Khmer"), Some("km")); + assert_eq!(language_tag("Cambodian"), Some("km")); + assert_eq!(language_tag("km_KH"), Some("km-KH")); assert_eq!(language_tag("Chinese"), Some("zh")); assert_eq!(language_tag("Chinese: Simplified"), Some("zh-Hans")); assert_eq!(language_tag("Chinese: Traditional"), Some("zh-Hant")); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index c1c0f1d333..e5a67cba3b 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -11,6 +11,8 @@ workspace = true [dependencies] craft-print = { workspace = true } craft-docpath = { workspace = true } +craft-khmer = { workspace = true } +craft-proof = { workspace = true } designcraft-images = { workspace = true } designcraft-geom = { workspace = true } designcraft-color = { workspace = true } diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 5e7a425eaa..29d1980e5b 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -1,6 +1,12 @@ -//! Edit → Spelling: check spelling against the bundled US English word list (the public-domain -//! Moby list used for hyphenation) plus the document's user dictionary; suggestions by edit distance. +//! Edit → Spelling, over the shared proofing engine (`craft-proof`): each language's spelling and +//! Khmer preferred forms. +//! All findings address the original story bytes, including across formatting runs. +mod khmer; + +use std::ops::Range; + +use craft_proof::{IssueKind, ProofLang}; use designcraft_compose::hyphen::Dictionary; use designcraft_doc::StoryId; use serde_json::{Value, json}; @@ -11,7 +17,14 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ cmd!(query "spelling.check", "Check Spelling…", ["Edit", "Spelling"], Some("Cmd+I"), - "{story?, suggestions?: true} → [{story, start, end, word, suggestions}]", has_doc, check), + "{story?, suggestions?: true} → [{story, start, end, word, suggestions, kind, message, language}]", has_doc, check), + cmd!(query "spelling.suggest", "Spelling Suggestions", [], None, + "{story, start, end} → {story, start, end, word, suggestions, kind, message, language}; revalidates one current issue in its paragraph", has_doc, suggest_issue), + cmd!(query "spelling.at", "Spelling Issue at Caret", [], None, + "{story, at} → the issue around byte offset `at`, with its suggestions, or null", has_doc, issue_at), + cmd!(query "spelling.status", "Spelling Dictionaries", [], None, + "{} → {englishAvailable, khmerAvailable}; unavailable text is unchecked, not correct", has_doc, + |_, _| Ok(json!({"englishAvailable": craft_proof::has_dictionary(Some("en-US")), "khmerAvailable": craft_khmer::data_available()}))), cmd!( "hyphenation.addException", "Add Hyphenation Exception", @@ -70,11 +83,20 @@ pub fn specs() -> Vec { }), cmd!("spelling.change", "Change", [], None, "{story, start, end, to}", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("spelling.change", "missing story"))?); - let (a, b) = (p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize, p.get("end").and_then(Value::as_u64).unwrap_or(0) as usize); + let offset = |key| { + p.get(key) + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| bad("spelling.change", "start and end must be byte offsets")) + }; + let (a, b) = (offset("start")?, offset("end")?); let to = str_param(p, "to").unwrap_or("").to_string(); s.edit(|d, _| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; - st.replace(a.min(st.len())..b.min(st.len()), &to); + if a > b || st.text.get(a..b).is_none() { + return Err(bad("spelling.change", "range must address original UTF-8 character boundaries")); + } + st.replace(a..b, &to); Ok(Value::Null) }) }), @@ -202,6 +224,10 @@ pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool { /// Up to 6 suggestions at edit distance 1, ranked: transpositions, replacements, deletions, insertions. pub fn suggest(dict: &Dictionary, w: &str) -> Vec { + // Bound candidate construction for hostile long tokens in imported books. + if w.chars().take(65).count() > 64 { + return Vec::new(); + } let chars: Vec = w.chars().collect(); let mut out: Vec = Vec::new(); let mut push = |c: Vec| { @@ -238,52 +264,205 @@ pub fn suggest(dict: &Dictionary, w: &str) -> Vec { out } -/// Misspelled words of a story: byte ranges (acronyms skipped; the user dictionary counts). -pub fn misspellings(doc: &designcraft_doc::Document, story: &designcraft_doc::Story, user: &[String]) -> Vec> { - // Text in other languages isn't checked against the English dictionary. - let foreign: Vec> = { - story - .runs() - .filter(|(r, f)| { - let pi = story.para_at(r.start.min(story.len())); - let base = doc.styles.resolve_para(&story.paras[pi.min(story.paras.len().saturating_sub(1))]).1; - !designcraft_compose::is_english(&doc.styles.resolve_char(&base, f).language) - }) - .map(|(r, _)| r) - .collect() - }; - let dict = Dictionary::en_us(); - let text = &story.text; - let bytes: Vec<(usize, char)> = text.char_indices().collect(); - let mut out = Vec::new(); - let mut i = 0; - while i < bytes.len() { - if !bytes[i].1.is_alphabetic() { - i += 1; - continue; - } - let start = bytes[i].0; - let mut j = i; - while j < bytes.len() - && (bytes[j].1.is_alphabetic() || ((bytes[j].1 == '\'' || bytes[j].1 == '’') && j + 1 < bytes.len() && bytes[j + 1].1.is_alphabetic())) - { - j += 1; +/// Whether Khmer proofing has its data. Without it, Khmer text is unchecked, not correct. +pub fn khmer_available() -> bool { + craft_khmer::data_available() +} + +/// Whether `text` holds Khmer, by the same rule proofing uses. +pub fn has_khmer(text: &str) -> bool { + text.chars().any(khmer::character) +} + +/// A spelling or preferred-form finding; `range` always addresses original story UTF-8 bytes. +#[derive(Clone, Debug)] +pub struct ProofIssue { + pub range: Range, + /// `spelling`, or `grammar` for the shared Khmer preferred spelling/phrase rules. + pub kind: &'static str, + pub message: String, + /// The language's tag (`en`, `km`, …): suggestions come from its dictionary. + pub language: String, + lang: ProofLang, + pub suggestions: Vec, + /// Retain the cheap rule replacement even when candidate generation is disabled. + preferred: Option, +} + +/// The language proofing checks a run in. A language it doesn't know, or No Language, is +/// [`ProofLang::Other`]: no marks. +fn proof_language(language: &str) -> ProofLang { + let name = language.trim().trim_start_matches("$ID/"); + match designcraft_doc::language_tag(name) { + Some(tag) => ProofLang::from_tag(Some(tag)), + // No tag means English to craft-proof; for a name it means [No Language] or an unknown one. + None if designcraft_compose::is_english(name) => ProofLang::En, + None => ProofLang::Other, + } +} + +/// Intersect character runs with paragraphs: one character run can span paragraphs +/// with different base languages. Formatting boundaries do not become word boundaries. +fn language_spans( + doc: &designcraft_doc::Document, + story: &designcraft_doc::Story, + paragraphs: impl IntoIterator)>, +) -> Vec<(Range, ProofLang)> { + let mut runs = story.runs().peekable(); + let mut spans = Vec::new(); + for (pi, para) in paragraphs { + let Some(format) = story.paras.get(pi) else { continue }; + let base = doc.styles.resolve_para(format).1; + while runs.peek().is_some_and(|(r, _)| r.end <= para.start) { + runs.next(); } - let end = bytes.get(j).map(|b| b.0).unwrap_or(text.len()); - let word = &text[start..end]; - let acronym = word.chars().all(|c| c.is_uppercase()) && word.chars().count() <= 5; - if !acronym && !foreign.iter().any(|r| r.contains(&start)) && !known(dict, user, &word.to_lowercase()) { - out.push(start..end); + while let Some((run, format)) = runs.peek() { + if run.start >= para.end { + break; + } + let range = run.start.max(para.start)..run.end.min(para.end); + if range.start < range.end { + spans.push((range, proof_language(&doc.styles.resolve_char(&base, format).language))); + } + if run.end > para.end { + break; + } + runs.next(); } - i = j.max(i + 1); } + spans +} + +/// A paragraph's issues from the shared proofing, at story offsets: every language's spelling, +/// and of the grammar rules only Khmer's preferred forms, which are about spelling too. The +/// document's user dictionary counts. Candidates are left for [`suggest_issue`], except a +/// preferred form's, which needs no search. +fn paragraph_issues( + story: &designcraft_doc::Story, + user: &[String], + suggestions: bool, + paragraph: Range, + spans: &[(Range, ProofLang)], +) -> Vec { + let Some(text) = story.text.get(paragraph.clone()) else { return Vec::new() }; + let local: Vec<_> = spans + .iter() + .filter(|(r, _)| r.start < paragraph.end && r.end > paragraph.start) + .map(|(r, l)| (r.start.max(paragraph.start) - paragraph.start..r.end.min(paragraph.end) - paragraph.start, *l)) + .collect(); + let known = |word: &str| user.iter().any(|w| w == word || w.eq_ignore_ascii_case(word)); + craft_proof::check_text(text, &local) + .into_iter() + .filter(|issue| issue.kind == IssueKind::Spelling || issue.lang == ProofLang::Km) + .filter(|issue| text.get(issue.start..issue.end).is_some_and(|word| !known(word) && !known(word.trim_end_matches('ៗ')))) + .map(|issue| { + let preferred = (issue.kind == IssueKind::Grammar).then(|| issue.suggestions.first().cloned()).flatten(); + let word = text.get(issue.start..issue.end).unwrap_or_default(); + let mut found = ProofIssue { + range: paragraph.start + issue.start..paragraph.start + issue.end, + kind: if issue.kind == IssueKind::Spelling { "spelling" } else { "grammar" }, + message: issue.message, + language: if issue.lang == ProofLang::Km { "km".into() } else { issue.lang.code().to_owned() }, + lang: issue.lang, + suggestions: Vec::new(), + preferred, + }; + if suggestions { + found.suggestions = suggestions_for(&mut found, word); + } + found + }) + .collect() +} + +/// Proof logical paragraphs, then map unchanged byte offsets to the story. Pass false +/// for dynamic underlines or book-wide refreshes to avoid edit-distance work. +pub fn proof_issues(doc: &designcraft_doc::Document, story: &designcraft_doc::Story, user: &[String], suggestions: bool) -> Vec { + let paragraphs = story.para_ranges(); + let spans = language_spans(doc, story, paragraphs.iter().cloned().enumerate()); + let mut out: Vec<_> = paragraphs.into_iter().flat_map(|paragraph| paragraph_issues(story, user, suggestions, paragraph, &spans)).collect(); + out.sort_by_key(|issue| (issue.range.start, issue.range.end)); out } +/// Dynamic proofing ranges, including Khmer preferred forms; the user dictionary counts. +/// An empty list is not a correctness claim: consult `spelling.status` for data availability. +pub fn misspellings(doc: &designcraft_doc::Document, story: &designcraft_doc::Story, user: &[String]) -> Vec> { + proof_issues(doc, story, user, false).into_iter().map(|issue| issue.range).collect() +} + +fn issue_value(sid: StoryId, text: &str, issue: ProofIssue) -> Option { + let word = text.get(issue.range.clone())?; + Some(json!({ + "story": sid.0, "start": issue.range.start, "end": issue.range.end, + "word": word, "suggestions": issue.suggestions, "kind": issue.kind, + "message": issue.message, "language": issue.language, + })) +} + +fn suggest_issue(s: &mut Session, p: &Value) -> Result { + let command = "spelling.suggest"; + let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad(command, "missing story"))?); + let offset = |key| { + p.get(key).and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(command, "start and end must be byte offsets")) + }; + let range = offset("start")?..offset("end")?; + let doc = &s.doc()?.doc; + let story = doc.story(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; + if range.start >= range.end || story.text.get(range.clone()).is_none() { + return Err(bad(command, "range must address a nonempty current issue on UTF-8 character boundaries")); + } + // Search outward only as far as this paragraph; do not segment the book or + // generate any other word's suggestions during selection/refresh. + let before = story.text.get(..range.start).ok_or_else(|| bad(command, "invalid start"))?; + let tail = story.text.get(range.start..).ok_or_else(|| bad(command, "invalid start"))?; + let paragraph = before.rfind('\n').map_or(0, |at| at + 1)..tail.find('\n').map_or(story.len(), |at| range.start + at); + if range.end > paragraph.end { + return Err(bad(command, "range crosses a paragraph boundary")); + } + let pi = story.para_at(paragraph.start); + let spans = language_spans(doc, story, std::iter::once((pi, paragraph.clone()))); + let mut issue = paragraph_issues(story, &doc.user_words, false, paragraph, &spans) + .into_iter() + .find(|issue| issue.range == range) + .ok_or_else(|| bad(command, "range is no longer a current spelling or preferred-form issue"))?; + let word = story.text.get(range).ok_or_else(|| bad(command, "invalid issue range"))?; + issue.suggestions = suggestions_for(&mut issue, word); + issue_value(sid, &story.text, issue).ok_or_else(|| bad(command, "invalid issue range")) +} + +fn issue_at(s: &mut Session, p: &Value) -> Result { + let command = "spelling.at"; + let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad(command, "missing story"))?); + let at = p.get("at").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(command, "at must be a byte offset"))?; + let doc = &s.doc()?.doc; + let story = doc.story(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let (Some(before), Some(tail)) = (story.text.get(..at), story.text.get(at..)) else { + return Err(bad(command, "at must be on a UTF-8 character boundary inside the story")); + }; + let paragraph = before.rfind('\n').map_or(0, |i| i + 1)..tail.find('\n').map_or(story.len(), |i| at + i); + let spans = language_spans(doc, story, std::iter::once((story.para_at(paragraph.start), paragraph.clone()))); + let Some(mut issue) = + paragraph_issues(story, &doc.user_words, false, paragraph, &spans).into_iter().find(|i| i.range.contains(&at) || i.range.end == at) + else { + return Ok(Value::Null); + }; + let word = story.text.get(issue.range.clone()).ok_or_else(|| bad(command, "invalid issue range"))?; + issue.suggestions = suggestions_for(&mut issue, word); + Ok(issue_value(sid, &story.text, issue).unwrap_or(Value::Null)) +} + +/// The candidates for one issue: its preferred form, else edit-distance suggestions. +fn suggestions_for(issue: &mut ProofIssue, word: &str) -> Vec { + match issue.preferred.take() { + Some(preferred) => vec![preferred], + None => craft_proof::suggest_in(word, 6, issue.lang), + } +} + fn check(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let d = &st.doc; - let dict = Dictionary::en_us(); let want_sugg = p.get("suggestions").and_then(Value::as_bool).unwrap_or(true); let stories: Vec = match p.get("story").and_then(Value::as_u64) { Some(id) => vec![StoryId(id)], @@ -292,11 +471,7 @@ fn check(s: &mut Session, p: &Value) -> Result { let mut out = Vec::new(); for sid in stories { let Some(story) = d.story(sid) else { continue }; - for r in misspellings(d, story, &d.user_words) { - let word = &story.text[r.clone()]; - let sugg = if want_sugg { suggest(dict, &word.to_lowercase()) } else { vec![] }; - out.push(json!({"story": sid.0, "start": r.start, "end": r.end, "word": word, "suggestions": sugg})); - } + out.extend(proof_issues(d, story, &d.user_words, want_sugg).into_iter().filter_map(|issue| issue_value(sid, &story.text, issue))); } Ok(Value::Array(out)) } @@ -388,7 +563,7 @@ mod tests { let r = s.execute("spelling.check", &json!({})).unwrap(); let words: Vec<&str> = r.as_array().unwrap().iter().map(|m| m["word"].as_str().unwrap()).collect(); assert_eq!(words, vec!["Thw", "jumpd"]); - assert!(r[0]["suggestions"].as_array().unwrap().iter().any(|v| v == "the")); + assert!(r[0]["suggestions"].as_array().unwrap().iter().any(|v| v == "The"), "in the word's own capitalization"); s.execute("spelling.addWord", &json!({"word": "jumpd"})).unwrap(); assert_eq!(s.execute("spelling.check", &json!({})).unwrap().as_array().unwrap().len(), 1); } diff --git a/crates/engine/src/cmd/spelling/khmer.rs b/crates/engine/src/cmd/spelling/khmer.rs new file mode 100644 index 0000000000..5da8bc2f7a --- /dev/null +++ b/crates/engine/src/cmd/spelling/khmer.rs @@ -0,0 +1,9 @@ +//! Khmer text in proofing. The shared craft-proof owns segmentation, dictionaries, preferred forms, +//! repetition signs and honorifics; these are what the review needs on top. + +pub(super) fn character(c: char) -> bool { + ('\u{1780}'..='\u{17ff}').contains(&c) || ('\u{19e0}'..='\u{19ff}').contains(&c) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/spelling/khmer/tests.rs b/crates/engine/src/cmd/spelling/khmer/tests.rs new file mode 100644 index 0000000000..f894ab16bf --- /dev/null +++ b/crates/engine/src/cmd/spelling/khmer/tests.rs @@ -0,0 +1,274 @@ +//! Engine regressions. Linguistic fixtures for repetition, preferred forms and the +//! two reported paragraphs are adapted from craft-libs crates/proof/src/km.rs +//! (MIT, revision 6be86aa0f8f7183a01d238a225e77221ce9111ea). +use serde_json::json; + +use crate::{ + Session, + cmd::spelling::{misspellings, proof_issues}, +}; +use designcraft_doc::{Document, Story, StoryId}; + +fn available() -> bool { + let available = craft_khmer::data_available(); + if option_env!("CRAFT_KHMER_REQUIRED") == Some("1") { + assert!(available, "required Khmer data must activate engine regressions"); + } + available +} + +fn make_story(text: &str) -> Story { + let mut story = Story::new(StoryId(1)); + story.insert(0, text); + story +} + +fn session(text: &str) -> (Session, StoryId) { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let frame = session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": text})).unwrap(); + (session, StoryId(frame["story"].as_u64().unwrap())) +} + +#[test] +fn availability_is_explicit_and_missing_data_does_not_create_findings() { + let (mut session, _) = session("ឝឝឝឝ សំដី"); + let status = session.execute("spelling.status", &json!({})).unwrap(); + assert_eq!(status["khmerAvailable"], available()); + assert_eq!(status["englishAvailable"], !designcraft_compose::hyphen::Dictionary::en_us().is_empty()); + if !available() { + assert_eq!(session.execute("spelling.check", &json!({})).unwrap(), json!([])); + let doc = &session.doc().unwrap().doc; + assert!(misspellings(doc, doc.stories.values().next().unwrap(), &[]).is_empty()); + } +} + +#[test] +fn unspaced_typo_crosses_format_runs_and_retains_original_offsets() { + if !available() { + return; + } + let text = "Hello 🦀 ខ្មែរកសីករខ្មែរ"; + let mut story = make_story(text); + let start = text.find("កសីករ").unwrap(); + // A style boundary inside a word must never become a segmentation boundary. + story.format_chars(start + "ក".len()..start + "កសី".len(), |format| format.over.font_style = Some("Bold".into())); + let doc = Document::new(&Default::default()); + let issues = proof_issues(&doc, &story, &[], true); + assert_eq!(issues.len(), 1, "{issues:?}"); + assert_eq!(issues[0].range, start..start + "កសីករ".len()); + assert_eq!(issues[0].language, "km"); + assert_eq!(issues[0].kind, "spelling"); + assert_eq!(issues[0].suggestions.first().map(String::as_str), Some("កសិករ")); + assert_eq!(misspellings(&doc, &story, &[]), vec![issues[0].range.clone()]); + assert!(proof_issues(&doc, &story, &[], false).iter().all(|i| i.suggestions.is_empty())); + if !designcraft_compose::hyphen::Dictionary::en_us().is_empty() { + let mixed = make_story("typograpyខ្មែរemial"); + assert_eq!( + proof_issues(&doc, &mixed, &[], false).iter().map(|i| mixed.text.get(i.range.clone()).unwrap()).collect::>(), + ["typograpy", "emial"] + ); + } +} + +#[test] +fn preferred_phrase_and_repeated_forms_use_shared_rules_without_duplicate_marks() { + if !available() { + return; + } + let doc = Document::new(&Default::default()); + for (text, variant, replacement) in [("Hello «ពាក្យសំដីៗ»។", "ពាក្យសំដី", "ពាក្យសម្ដី"), ("ខាងវិញ្ញាណ", "ខាងវិញ្ញាណ", "ខាងឯវិញ្ញាណ"), ("ព្រិលៗ", "ព្រិលៗ", "ព្រឹលៗ")] + { + let mut story = make_story(text); + let at = text.find(variant).unwrap(); + story.format_chars(at..at + variant.chars().next().unwrap().len_utf8(), |format| format.over.font_style = Some("Bold".into())); + let issues = proof_issues(&doc, &story, &[], true); + assert_eq!(issues.len(), 1, "{text}: {issues:?}"); + let issue = &issues[0]; + assert_eq!(text.get(issue.range.clone()), Some(variant)); + assert_eq!(issue.kind, "grammar"); + assert_eq!(issue.suggestions, [replacement]); + assert!(!issue.message.is_empty()); + assert!(proof_issues(&doc, &story, &[variant.to_owned()], false).is_empty()); + } + assert!(proof_issues(&doc, &make_story("ព្រឹលៗ"), &[], true).is_empty()); +} + +#[test] +fn explicit_languages_apply_to_whole_corrections_and_each_paragraph_base() { + if !available() { + return; + } + let mut doc = Document::new(&Default::default()); + std::sync::Arc::make_mut(&mut doc.styles).character.push(designcraft_doc::styles::CharacterStyle { + name: "Proof disabled".into(), + based_on: None, + chars: designcraft_doc::CharAttrs { language: Some("[No Language]".into()), ..Default::default() }, + shortcut: String::new(), + }); + let mut disabled = make_story("សំដី"); + disabled.format_chars(0..disabled.len(), |format| format.style = "Proof disabled".into()); + assert!(proof_issues(&doc, &disabled, &[], false).is_empty()); + for language in ["Khmer", "km-KH", "$ID/Khmer", "Cambodian"] { + let mut story = make_story("សំដី"); + story.format_chars(0..story.len(), |format| format.over.language = Some(language.into())); + assert_eq!(proof_issues(&doc, &story, &[], false).len(), 1, "{language}"); + } + for text in ["ខាងវិញ្ញាណ", "ឝឝឝឝ"] { + for language in ["[No Language]", "French", "pl-PL", "Unknown"] { + let mut story = make_story(text); + let split = text.chars().next().unwrap().len_utf8(); + story.format_chars(split..story.len(), |format| format.over.language = Some(language.into())); + assert!(proof_issues(&doc, &story, &[], true).is_empty(), "{language}: {text}"); + } + } + // One character run spans both paragraphs; its language comes from each base. + let mut story = make_story("សំដី\nសំដី"); + assert_eq!(story.chars.len(), 1); + story.paras[0].chars.language = Some("[No Language]".into()); + story.paras[1].chars.language = Some("km".into()); + let issues = proof_issues(&doc, &story, &[], false); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].range, "សំដី\n".len()..story.len()); +} + +#[test] +fn repetition_and_authored_joiners_do_not_create_false_spelling_marks() { + if !available() { + return; + } + let doc = Document::new(&Default::default()); + for text in ["គ្រប់ៗគ្នា", "ខុសៗគ្នា", "អ្វីៗ", "ផ្សេងៗ", "កូនៗ", "ថ្មីៗ", "ល្អៗ", "ស្អាតៗ", "រ៉ាមៗ", "ៗ", "«គ្រប់ៗ»។", "ខុសៗ\u{200b}គ្នា", "ខុសៗ\u{2060}គ្នា"] + { + let story = make_story(text); + assert!(!proof_issues(&doc, &story, &[], true).iter().any(|i| i.kind == "spelling"), "{text}"); + assert_eq!(story.text, text); + } + for joiner in ['\u{200b}', '\u{2060}'] { + let text = format!("ខ្មែរ{joiner}សំដីៗ"); + let issues = proof_issues(&doc, &make_story(&text), &[], true); + assert_eq!(issues.len(), 1); + assert_eq!(text.get(issues[0].range.clone()), Some("សំដី")); + let mut corrected = text.clone(); + corrected.replace_range(issues[0].range.clone(), &issues[0].suggestions[0]); + assert_eq!(corrected, format!("ខ្មែរ{joiner}សម្ដីៗ")); + } +} + +#[test] +fn reported_paragraphs_allow_legitimate_preferred_forms_but_not_repetition_false_positives() { + if !available() { + return; + } + let doc = Document::new(&Default::default()); + for text in [ + "ព្រះជាម្ចាស់ចាត់ចែងនិងរៀបចំឋានៈឲ្យយើងគ្រប់ៗគ្នាតាមដែលទ្រង់ឃើញថាល្អនៅក្នុងព្រះនេត្ររបស់ទ្រង់។", + "ទ្រង់បានបង្កើតរបស់ខ្លះមកឲ្យអាចកម្រើកបាន និងខ្លះទៀតមិនអាចកម្រើកបានខ្លះស្អាត និងខ្លះទៀតមិនស្អាត ហើយរបស់ទាំងអស់នោះសុទ្ធតែមានគុណសម្បត្តិខុសៗគ្នា ប៉ុន្តែគ្មាននិចលភាព ឬក៏ស្មារតីសោះឡើយ។", + ] { + let mut story = make_story(text); + // Repetition and its preceding stem can have different formatting. + let sign = text.find('ៗ').unwrap(); + story.format_chars(sign..sign + 'ៗ'.len_utf8(), |format| format.over.font_style = Some("Bold".into())); + for language in ["English: USA", "Khmer"] { + story.paras[0].chars.language = Some(language.into()); + let issues = proof_issues(&doc, &story, &[], true); + assert!(!issues.iter().any(|i| i.kind == "spelling"), "{language}: {issues:?}"); + let expected = craft_khmer::grammar_matches(text); + assert_eq!(issues.iter().map(|i| (i.range.clone(), i.suggestions[0].clone())).collect::>(), expected); + assert!(issues.iter().all(|i| i.language == "km")); + assert_eq!(story.text, text); + } + } +} + +#[test] +fn native_check_change_dictionary_and_undo_preserve_repetition_and_ranges() { + if !available() { + return; + } + let text = "Hello «ខ្មែៗ»។"; + let (mut session, sid) = session(text); + session + .edit(|doc, _| { + let story = doc.story_mut(sid).unwrap(); + let sign = text.find('ៗ').unwrap(); + story.format_chars(sign..sign + 'ៗ'.len_utf8(), |format| format.over.font_style = Some("Bold".into())); + Ok(()) + }) + .unwrap(); + let issues = session.execute("spelling.check", &json!({"story": sid.0})).unwrap(); + assert_eq!(issues.as_array().unwrap().len(), 1, "{issues}"); + let issue = &issues[0]; + assert_eq!(issue["word"], "ខ្មែ"); + assert_eq!(issue["start"], "Hello «".len()); + assert!(issue["suggestions"].as_array().unwrap().iter().any(|s| s == "ខ្មែរ")); + let request = json!({"story": sid.0, "start": issue["start"], "end": issue["end"]}); + let st = session.doc().unwrap(); + let snapshot = (st.doc.clone(), st.revision, st.selection.clone(), st.history.undo.len(), st.history.redo.len(), session.journal.len()); + let lazy = session.execute("spelling.suggest", &request).unwrap(); + assert_eq!(lazy, *issue); + let st = session.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&snapshot.0, &st.doc)); + assert_eq!( + (st.revision, &st.selection, st.history.undo.len(), st.history.redo.len(), session.journal.len()), + (snapshot.1, &snapshot.2, snapshot.3, snapshot.4, snapshot.5) + ); + session.execute("spelling.change", &json!({"story": sid.0, "start": issue["start"], "end": issue["end"], "to": "ខ្មែរ"})).unwrap(); + assert_eq!(session.doc().unwrap().doc.story(sid).unwrap().text, "Hello «ខ្មែរៗ»។"); + assert_eq!(session.doc().unwrap().doc.story(sid).unwrap().char_format_of("Hello «ខ្មែរ".len()).over.font_style.as_deref(), Some("Bold")); + assert_eq!(session.execute("spelling.check", &json!({})).unwrap(), json!([])); + assert!(session.execute("spelling.suggest", &request).is_err(), "corrected issue is stale"); + session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(session.doc().unwrap().doc.story(sid).unwrap().text, text); + assert_eq!(session.execute("spelling.check", &json!({"suggestions": false})).unwrap()[0]["suggestions"], json!([])); + session.execute("spelling.addWord", &json!({"word": "ខ្មែ"})).unwrap(); + assert_eq!(session.execute("spelling.check", &json!({})).unwrap(), json!([])); + assert!(session.execute("spelling.suggest", &request).is_err(), "user dictionary suppresses the old issue"); + session.execute("edit.undo", &json!({})).unwrap(); + let doc = &session.doc().unwrap().doc; + assert_eq!(misspellings(doc, doc.story(sid).unwrap(), &doc.user_words), vec!["Hello «".len().."Hello «ខ្មែ".len()]); + for (start, end) in [(8, 9), (12, 10), (0, usize::MAX)] { + assert!(session.execute("spelling.change", &json!({"story": sid.0, "start": start, "end": end, "to": "x"})).is_err()); + assert!(session.execute("spelling.suggest", &json!({"story": sid.0, "start": start, "end": end})).is_err()); + assert_eq!(session.doc().unwrap().doc.story(sid).unwrap().text, text); + } + // One selected paragraph, one target suggestion; other paragraphs may contain many errors. + let text = format!("{}\nពាក្យសំដីៗ\n{}", "ឝឝឝឝ ".repeat(32), "ឝឝឝឝ ".repeat(32)); + let (mut session, sid) = self::session(&text); + let start = text.find("ពាក្យសំដី").unwrap(); + let request = json!({"story": sid.0, "start": start, "end": start + "ពាក្យសំដី".len()}); + let preferred = session.execute("spelling.suggest", &request).unwrap(); + assert_eq!(preferred["kind"], "grammar"); + assert_eq!(preferred["suggestions"], json!(["ពាក្យសម្ដី"])); + assert_eq!(preferred["word"], "ពាក្យសំដី"); + assert!(session.execute("spelling.suggest", &json!({"story": sid.0, "start": start, "end": text.len()})).is_err()); + session + .edit(|doc, _| { + let story = doc.story_mut(sid).unwrap(); + story.format_chars(start..start + "ពាក្យសំដី".len(), |format| format.over.language = Some("[No Language]".into())); + Ok(()) + }) + .unwrap(); + assert!(session.execute("spelling.suggest", &request).is_err(), "language changes invalidate the issue"); +} + +#[test] +fn has_khmer_agrees_with_proofing_at_the_script_boundaries() { + use crate::cmd::spelling::has_khmer; + assert!(has_khmer("abc ក") && has_khmer("\u{17ff}") && has_khmer("\u{19e0}") && has_khmer("\u{19ff}")); + assert!(!has_khmer("") && !has_khmer("plain text") && !has_khmer("\u{177f}\u{1800}\u{19df}\u{1a00}\u{200b}")); +} + +#[test] +fn the_issue_at_a_caret_comes_with_its_suggestions_only_inside_it() { + let (mut s, sid) = session("A jumpd fox.\nPlain line."); + let at = |s: &mut Session, at: usize| s.execute("spelling.at", &serde_json::json!({"story": sid.0, "at": at})).unwrap(); + for inside in [2, 4, 7] { + let issue = at(&mut s, inside); + assert_eq!(issue["word"], "jumpd", "offset {inside}"); + assert!(!issue["suggestions"].as_array().unwrap().is_empty()); + } + assert!(at(&mut s, 0).is_null(), "a known word has no issue"); + assert!(at(&mut s, 15).is_null(), "nor does another paragraph"); + assert!(s.execute("spelling.at", &serde_json::json!({"story": sid.0, "at": 1000})).is_err()); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 9167a157d9..e313cbc211 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -13,6 +13,7 @@ workspace = true craft-ui = { workspace = true, features = ["native-windows"] } craft-window = { workspace = true } craft-keymap = { workspace = true } +craft-proof-ui = { workspace = true } designcraft-geom = { workspace = true } designcraft-color = { workspace = true } designcraft-doc = { workspace = true } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index c1b7a62e3d..d4a3b685d6 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -69,6 +69,8 @@ impl DesignApp { control_input_frame: Default::default(), clipboard_guard_installed: false, story_editor: None, + spelling: Default::default(), + spelling_context: None, control_rx: None, pending_shots: vec![], queued_shots: vec![], diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f544f5bf53..afe2e1e182 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -53,6 +53,8 @@ pub(crate) use rulers::fmt_tick; use rulers::ruler_pieces; use rulers::{draw_rulers, ruler_units_menus}; use scroll::{mods, navigation_space, scrollbars, spring_zoom_mode}; +#[cfg(test)] +use text_overlay::dynamic_misspellings; pub use text_overlay::selected_text; use text_overlay::{draw_dynamic_spelling, draw_hidden_characters}; #[cfg(test)] diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 21f269e03f..36922e01e6 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -141,6 +141,7 @@ fn context_target(app: &mut DesignApp, p: Point, zoom: f64) -> crate::menus::Con let _ = app.run("text.release", json!({"frame": id.0, "point": [sp.x, sp.y], "moved": true})); } } + crate::panels::spelling::prepare_context(app); return ContextMenu::Text; } let Some((id, _)) = hit else { return ContextMenu::Canvas }; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 9267a6cdaf..a2d0153f15 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -3020,3 +3020,33 @@ fn leaving_parent_editing_for_the_left_page_of_a_spread_keeps_that_page_selected h.run_steps(3); assert_eq!(current_page(h.state()), Some(1)); } + +/// Typing in one story must not re-proof the others; a language or dictionary change must. +#[test] +fn dynamic_spelling_reproofs_only_after_a_relevant_change() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let ctx = egui::Context::default(); + let mut doc = (*app.session.active().unwrap().doc).clone(); + let layer = doc.default_layer(); + let rect = designcraft_geom::Rect::new(0.0, 0.0, 200.0, 100.0); + let add = |doc: &mut designcraft_doc::Document, text: &str| { + doc.add_text_frame(designcraft_doc::SpreadRef::Doc(0), rect, layer, text, Default::default()).unwrap().1 + }; + let (edited, other) = (add(&mut doc, "jumpd"), add(&mut doc, "lazzy")); + let marks = |doc: &designcraft_doc::Document, id| dynamic_misspellings(&ctx, doc, doc.stories.get(&id).unwrap()); + let (first, unrelated) = (marks(&doc, edited), marks(&doc, other)); + assert_eq!(first.len(), 1); + assert!(std::sync::Arc::ptr_eq(&first, &marks(&doc, edited)), "unchanged: cached"); + doc.story_mut(edited).unwrap().replace(0..0, "wrng "); + assert_eq!(marks(&doc, edited).len(), 2, "the edited story is checked again"); + assert!(std::sync::Arc::ptr_eq(&unrelated, &marks(&doc, other)), "the other story stays cached"); + doc.user_words.push("lazzy".into()); + assert!(marks(&doc, other).is_empty(), "the user dictionary counts at once"); + doc.user_words.clear(); + assert_eq!(marks(&doc, other).len(), 1); + for style in &mut std::sync::Arc::make_mut(&mut doc.styles).paragraph { + style.chars.language = Some("[No Language]".into()); + } + assert!(marks(&doc, other).is_empty(), "a style's language counts at once"); +} diff --git a/crates/ui-egui/src/canvas/text_overlay.rs b/crates/ui-egui/src/canvas/text_overlay.rs index 521759eb01..c77f9668ae 100644 --- a/crates/ui-egui/src/canvas/text_overlay.rs +++ b/crates/ui-egui/src/canvas/text_overlay.rs @@ -341,19 +341,49 @@ pub(super) fn hidden_marks(text: &str, l: &compose::Line) -> Vec<(f64, &'static out } -/// Edit › Spelling › Dynamic Spelling: red squiggles under misspelled words (checked once per -/// story version). +/// One story's dynamic-spelling marks and everything they were computed from. `Weak` identifies +/// the story and styles (their addresses can't be reused while it lives) without keeping a closed +/// document's text in memory. +#[derive(Clone)] +struct DynamicSpelling { + story: std::sync::Weak, + styles: std::sync::Weak, + user_words: Vec, + bad: std::sync::Arc>>, +} + +/// A story's marks, proofed again only when its text and formatting, the styles that give it a +/// language, or the user dictionary change: typing in one story leaves the rest of a book alone. +pub(super) fn dynamic_misspellings( + ctx: &egui::Context, + doc: &Document, + story: &std::sync::Arc, +) -> std::sync::Arc>> { + let key = egui::Id::unique(("dyn_spell", story.id)); + // Shared, so a cache hit on every frame copies nothing. + let cached: Option> = ctx.data(|d| d.get_temp(key)); + if let Some(c) = cached + && c.story.as_ptr() == std::sync::Arc::as_ptr(story) + && c.styles.as_ptr() == std::sync::Arc::as_ptr(&doc.styles) + && c.user_words == doc.user_words + { + return c.bad.clone(); + } + let bad = std::sync::Arc::new(designcraft_engine::cmd::spelling::misspellings(doc, story, &doc.user_words)); + let entry = std::sync::Arc::new(DynamicSpelling { + story: std::sync::Arc::downgrade(story), + styles: std::sync::Arc::downgrade(&doc.styles), + user_words: doc.user_words.clone(), + bad: bad.clone(), + }); + ctx.data_mut(|d| d.insert_temp(key, entry)); + bad +} + +/// Edit › Spelling › Dynamic Spelling: red squiggles under misspelled words. pub(super) fn draw_dynamic_spelling(app: &DesignApp, ctx: &egui::Context, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) { for story in doc.stories.values() { - let key = egui::Id::unique(("dyn_spell", std::sync::Arc::as_ptr(story) as usize, doc.user_words.len())); - let bad: std::sync::Arc>> = match ctx.data(|d| d.get_temp(key)) { - Some(v) => v, - None => { - let v = std::sync::Arc::new(designcraft_engine::cmd::spelling::misspellings(doc, story, &doc.user_words)); - ctx.data_mut(|d| d.insert_temp(key, v.clone())); - v - } - }; + let bad = dynamic_misspellings(ctx, doc, story); if bad.is_empty() { continue; } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 74dc376514..ff83e1972f 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -34,6 +34,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("preflight", "Preflight", "panel-preflight"), ("separations", "Separations Preview", "panel-swatches"), ("attributes", "Attributes", "panel-attributes"), + ("spelling", "Spelling", "panel-preflight"), ("conditions", "Conditional Text", "panel-conditions"), ("notes", "Notes", "panel-notes"), ("library", "Library", "panel-library"), @@ -103,6 +104,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "conditions" => panels::conditions::show(app, ui), "notes" => panels::notes::show(app, ui), "separations" => panels::separations::show(app, ui), + "spelling" => panels::spelling::show(app, ui), "library" => panels::library::show(app, ui), "hyperlinks" => panels::interactive::hyperlinks(app, ui), "bookmarks" => panels::interactive::bookmarks(app, ui), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ef86298960..9587600849 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -176,6 +176,10 @@ pub struct DesignApp { clipboard_guard_installed: bool, /// Story open in the Story Editor. pub story_editor: Option, + /// The Spelling panel's review of the open document. + pub(crate) spelling: craft_proof_ui::Review, + /// The spelling issue a text context menu is open on. + pub(crate) spelling_context: Option, control_rx: Option>, pending_shots: Vec, queued_shots: Vec<(u64, f64, u32)>, diff --git a/crates/ui-egui/src/menus/context.rs b/crates/ui-egui/src/menus/context.rs index a8ce474df0..71f90bf10f 100644 --- a/crates/ui-egui/src/menus/context.rs +++ b/crates/ui-egui/src/menus/context.rs @@ -143,6 +143,9 @@ pub(super) fn context_parse(entries: &[&str]) -> Vec<(String, Vec)> { /// A canvas context menu: the menu bar's rows (labels, shortcuts, check marks, greyed when they /// can't run), scrolling when taller than the window. pub fn context_menu(app: &mut DesignApp, ui: &mut egui::Ui, kind: ContextMenu) { + if kind == ContextMenu::Text { + crate::panels::spelling::context_items(app, ui); + } let runs = context_parse(&context_entries(app, kind)); let max_height = menu_max_height(ui, None); let mut scroll = egui::ScrollArea::vertical().max_height(max_height).min_scrolled_height(1.0); diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index b9d35539d6..4699705e34 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -31,6 +31,10 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { } fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { + if id == "spelling.check" { + let _ = app.run("app.checkSpelling", json!({})); + return; + } // Object › Effects › Drop Shadow… (and its shortcut): the Effects dialog on that page. if params.is_null() && let Some(page) = crate::effects_dialog::page_of_command(id) diff --git a/crates/ui-egui/src/menus/quick_apply.rs b/crates/ui-egui/src/menus/quick_apply.rs index ec91a2c8a0..6a2cec5375 100644 --- a/crates/ui-egui/src/menus/quick_apply.rs +++ b/crates/ui-egui/src/menus/quick_apply.rs @@ -48,7 +48,8 @@ pub fn palette(app: &mut DesignApp, ctx: &egui::Context) { }); app.ui.palette = if close || run.is_some() { None } else { Some(q) }; if let Some((id, params)) = run { - let _ = app.run(&id, params); + let id = if id == "spelling.check" { "app.checkSpelling" } else { &id }; + let _ = app.run(id, params); } } @@ -106,6 +107,9 @@ pub fn quick_apply_items(session: &designcraft_engine::Session, query: &str, cjk /// dialog to ask for it, or without one they reset a setting. Each stays in the menu, panel or /// dialog that gives it the value (the workspace menus, Keyboard Shortcuts, Preferences…). const NOT_SEARCHED: &[&str] = &[ + // The existing Check Spelling entry invokes this UI alias (the query stays read-only). + "app.checkSpelling", + "app.spelling", // The workspace to switch to or delete. "window.workspace", "window.deleteWorkspace", diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 717e1332e2..0f32171738 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -83,6 +83,10 @@ fn flag(b: &mut bool) -> Result { /// One slice of the UI commands: `None` if `id` isn't one of them. fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option> { Some(match id { + "app.checkSpelling" => crate::panels::spelling::open(app), + "app.spelling" => { + crate::panels::spelling::action(app, p.get("action").and_then(Value::as_str).unwrap_or(""), p.get("to").and_then(Value::as_str)) + } "window.minimize" => { app.pending_window_commands.push(egui::ViewportCommand::Minimized(true)); Ok(Value::Null) diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 00749603ec..06dd6fb74e 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -32,6 +32,9 @@ pub fn enabled(app: &DesignApp, id: &str) -> bool { if is_window_control(id) { return window_control_available(app, id); } + if matches!(id, "app.checkSpelling" | "app.spelling") { + return enabled(app, "spelling.check"); + } // Shared docking commands DesignCraft does not provide (native panel windows, Lock Workspace). if matches!(id, "window.panel.window" | "window.workspace.lock") { return false; @@ -160,6 +163,9 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { /// Enablement for menu display (UI commands need a document unless they're app/window-level). pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { + if matches!(id, "app.checkSpelling" | "app.spelling") { + return enabled(app, id); + } if waits_for_ime(app, id) { return false; } diff --git a/crates/ui-egui/src/menus/tables.rs b/crates/ui-egui/src/menus/tables.rs index e7907e7daf..5ac0880263 100644 --- a/crates/ui-egui/src/menus/tables.rs +++ b/crates/ui-egui/src/menus/tables.rs @@ -611,6 +611,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Conditional Text|{\"panel\": \"conditions\"}", "ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}", "ui:window.panel|Paragraph|{\"panel\": \"paragraph\"}", + "ui:window.panel|Spelling|{\"panel\": \"spelling\"}", "ui:window.panel|Story|{\"panel\": \"story\"}", "ui:window.panel|Table|{\"panel\": \"table\"}", "ui:app.tabsPanel", diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index f24d1d5486..11789be53b 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -12,6 +12,18 @@ pub fn shown(app: &DesignApp, id: &str) -> bool { /// UI commands: (id, label, shortcut, params). pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ + ( + "app.checkSpelling", + "Check Spelling…", + None, + "{} — opens the Spelling panel and checks the document; → {issue, remaining, skipped, to, message, khmerPresent, khmerUnchecked}", + ), + ( + "app.spelling", + "Spelling Review", + None, + "{action: change|ignoreOnce|ignoreAll|add|check, to?} — the Spelling panel's actions, after app.checkSpelling", + ), ("app.newDocumentDialog", "New Document…", Some("Cmd+N"), "{}"), ("app.openDialog", "Open…", Some("Cmd+O"), "{path?} — without a path, choose the file in a dialog"), ("app.openRecent", "Open Recent File", None, "{index} — file.openRecent, with an alert when the file can't be opened"), diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 801a96ae37..35597476fe 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -116,7 +116,7 @@ const ICON_GROUPS: &[&[&str]] = &[ &["effects"], &["characterStyles", "paragraphStyles"], &["character", "paragraph", "typography"], - &["glyphs", "story"], + &["glyphs", "story", "spelling"], &["table"], &["textWrap"], &["align", "pathfinder"], @@ -1256,7 +1256,7 @@ fn floating_size(panel: &str) -> egui::Vec2 { match panel { "properties" | "pages" | "layers" => egui::vec2(300.0, 420.0), "characterStyles" | "paragraphStyles" => egui::vec2(290.0, 340.0), - "glyphs" | "library" | "links" | "preflight" | "dataMerge" => egui::vec2(320.0, 400.0), + "glyphs" | "library" | "links" | "preflight" | "dataMerge" | "spelling" => egui::vec2(320.0, 400.0), _ => egui::vec2(280.0, 360.0), } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 2efdd9ec74..9070616ec5 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -13,6 +13,7 @@ pub mod notes; pub mod pages; pub mod properties; pub mod separations; +pub mod spelling; pub mod story; pub mod styles; pub mod swatches; diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index f27ceabcc0..e97b1bd79d 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -574,6 +574,7 @@ pub(crate) const LANGUAGES: &[&str] = &[ "Chinese: Simplified", "Chinese: Traditional", "Korean", + "Khmer", "Arabic", "Hebrew", ]; diff --git a/crates/ui-egui/src/panels/spelling.rs b/crates/ui-egui/src/panels/spelling.rs new file mode 100644 index 0000000000..f3a86155be --- /dev/null +++ b/crates/ui-egui/src/panels/spelling.rs @@ -0,0 +1,170 @@ +//! The Spelling panel and right-click suggestions: the shared review (`craft-proof-ui`) over this +//! document's stories and the read-only spelling queries. +use std::{borrow::Cow, sync::Arc}; + +use craft_proof_ui::{Action, ContextChoice, Host, Issue, IssueKind, Review, Text}; +use serde_json::{Value, json}; + +use crate::{DesignApp, i18n::tr, theme::Tokens}; + +/// The session as the review sees it. +struct AppHost<'a>(&'a mut DesignApp); + +fn issue_of(v: &Value) -> Option { + Some(Issue { + story: v["story"].as_u64()?, + start: usize::try_from(v["start"].as_u64()?).ok()?, + end: usize::try_from(v["end"].as_u64()?).ok()?, + word: v["word"].as_str()?.to_owned(), + kind: if v["kind"] == "spelling" { IssueKind::Spelling } else { IssueKind::Grammar }, + message: v["message"].as_str().unwrap_or("").to_owned(), + suggestions: v["suggestions"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(), + }) +} + +impl Host for AppHost<'_> { + fn check(&mut self) -> Result, String> { + let found = self.0.run("spelling.check", json!({"suggestions": false}))?; + Ok(found.as_array().into_iter().flatten().filter_map(issue_of).collect()) + } + + fn suggestions(&mut self, issue: &Issue) -> Result, String> { + let fresh = self.0.run("spelling.suggest", json!({"story": issue.story, "start": issue.start, "end": issue.end}))?; + let fresh = issue_of(&fresh).ok_or("invalid spelling response")?; + if fresh.word != issue.word { + return Err("the issue changed".into()); + } + Ok(fresh.suggestions) + } + + fn text(&self, story: u64) -> Option<&str> { + Some(self.0.session.active()?.doc.story(designcraft_doc::StoryId(story))?.text.as_str()) + } + + /// A story is replaced on any edit, formatting too; that can only ask for a needless re-check. + fn version(&self, story: u64) -> Option { + let story = self.0.session.active()?.doc.stories.get(&designcraft_doc::StoryId(story))?; + Some(Arc::as_ptr(story) as usize as u64) + } + + fn document(&self) -> Option { + Some(self.0.session.active()?.identity_epoch) + } + + fn reveal(&mut self, issue: &Issue) { + let _ = self.0.run("text.select", json!({"story": issue.story, "anchor": issue.start, "focus": issue.end})); + } + + fn change(&mut self, issue: &Issue, to: &str) -> Result<(), String> { + self.0.run("spelling.change", json!({"story": issue.story, "start": issue.start, "end": issue.end, "to": to})).map(|_| ()) + } + + fn add_word(&mut self, word: &str) -> Result<(), String> { + self.0.run("spelling.addWord", json!({"word": word})).map(|_| ()) + } + + fn notice(&self) -> Option { + let st = self.0.session.active()?; + let unchecked = !designcraft_engine::cmd::spelling::khmer_available() + && st.doc.stories.values().any(|s| designcraft_engine::cmd::spelling::has_khmer(&s.text)); + unchecked.then(|| tr(&self.0.ui.language, "Khmer data is not included. Khmer text has not been checked.").to_owned()) + } +} + +struct AppText(String); + +impl Text for AppText { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + Cow::Owned(tr(&self.0, source).to_owned()) + } +} + +fn colors(t: &Tokens, ui: &egui::Ui) -> craft_proof_ui::Colors { + craft_proof_ui::Colors { + text: t.text, + dim: t.text_dim, + field: t.input, + field_border: t.input_border, + hover: t.hover, + spelling: egui::Color32::from_rgb(230, 30, 30), + grammar: t.accent, + error: ui.visuals().error_fg_color, + } +} + +/// Opens the panel and checks the document from the top. +pub(crate) fn open(app: &mut DesignApp) -> Result { + if app.session.active().is_none() { + return Err("no document open".into()); + } + app.run("window.panel", json!({"panel": "spelling"}))?; + let mut review = Review::default(); + review.start(&mut AppHost(app)); + let status = status(&review); + app.spelling = review; + Ok(status) +} + +fn status(review: &Review) -> Value { + let issue = review.current().map(|i| json!({"story": i.story, "start": i.start, "end": i.end, "word": i.word, "suggestions": i.suggestions})); + json!({"issue": issue, "remaining": review.remaining(), "to": review.replacement, "message": review.message()}) +} + +/// `app.spelling`: the panel's actions for automation. +pub(crate) fn action(app: &mut DesignApp, name: &str, to: Option<&str>) -> Result { + let action = match name { + "change" => Action::Change(to.map(str::to_owned)), + "ignoreOnce" => Action::IgnoreOnce, + "ignoreAll" => Action::IgnoreAll, + "add" => Action::AddToDictionary, + "check" => Action::Check, + _ => return Err("unknown action (change, ignoreOnce, ignoreAll, add, check)".into()), + }; + if !app.spelling.started() { + return Err("run app.checkSpelling first".into()); + } + let mut review = std::mem::take(&mut app.spelling); + let result = review.act(&mut AppHost(app), action); + let status = status(&review); + app.spelling = review; + result.map(|()| status) +} + +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let colors = colors(&t, ui); + let text = AppText(app.ui.language.clone()); + let mut review = std::mem::take(&mut app.spelling); + craft_proof_ui::panel(ui, &mut review, &mut AppHost(app), &text, &colors); + app.spelling = review; +} + +/// On a right-click in text: the issue under the caret, with its suggestions, for the menu. +/// Checked once here, not on every frame the menu is open. +pub(crate) fn prepare_context(app: &mut DesignApp) { + app.spelling_context = None; + let Some(text) = app.session.active().and_then(|st| st.selection.text) else { return }; + if let Ok(found) = app.run("spelling.at", json!({"story": text.story.0, "at": text.focus})) { + app.spelling_context = issue_of(&found); + } +} + +/// The menu's first section: the suggestions, then what else can be done with the word. +pub(crate) fn context_items(app: &mut DesignApp, ui: &mut egui::Ui) { + let Some(issue) = app.spelling_context.clone() else { return }; + let text = AppText(app.ui.language.clone()); + let Some(choice) = craft_proof_ui::context_section(ui, &issue, &text) else { return }; + let result = match choice { + ContextChoice::Change(to) => AppHost(app).change(&issue, &to), + ContextChoice::AddToDictionary => AppHost(app).add_word(&issue.word), + ContextChoice::Review => open(app).map(|_| ()), + }; + if let Err(e) = result { + app.status(e); + } + app.spelling_context = None; + ui.close(); +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/panels/spelling/tests.rs b/crates/ui-egui/src/panels/spelling/tests.rs new file mode 100644 index 0000000000..ea46b57097 --- /dev/null +++ b/crates/ui-egui/src/panels/spelling/tests.rs @@ -0,0 +1,130 @@ +use egui_kittest::{Harness, kittest::Queryable}; + +use super::*; +use crate::menus; + +const TEXT: &str = "The jumpd fox. A jumpd dog."; + +fn app_with(text: &str) -> (DesignApp, designcraft_doc::StoryId) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.language = "en".into(); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [36, 36, 400, 200], "content": "text", "text": text})).unwrap(); + (app, designcraft_doc::StoryId(frame["story"].as_u64().unwrap())) +} + +fn panel(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(egui::vec2(320.0, 800.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); + h.run_steps(2); + h +} + +fn text_of(app: &DesignApp, sid: designcraft_doc::StoryId) -> String { + app.session.doc().unwrap().doc.story(sid).unwrap().text.clone() +} + +#[test] +fn check_spelling_opens_the_panel_on_the_first_issue_and_selects_it() { + let (mut app, sid) = app_with(TEXT); + app.run("spelling.check", json!({"suggestions": false})).unwrap(); + assert!(!app.spelling.started(), "the query alone opens nothing"); + menus::activate(&mut app, "spelling.check", &Value::Null); + assert_eq!(app.spelling.current().unwrap().word, "jumpd"); + assert!(app.ui.dialog.is_none(), "the review does not block the document"); + let selection = app.session.active().unwrap().selection.text.unwrap(); + assert_eq!((selection.story, selection.anchor, selection.focus), (sid, 4, 9), "the word is selected in the text"); +} + +#[test] +fn a_suggestion_clicked_in_the_panel_corrects_and_undo_restores() { + let (mut app, sid) = app_with(TEXT); + open(&mut app).unwrap(); + let suggestion = app.spelling.current().unwrap().suggestions[0].clone(); + let mut h = panel(app); + h.get_by_label(&suggestion).click(); + h.run_steps(2); + let app = h.state_mut(); + assert_eq!(text_of(app, sid), format!("The {suggestion} fox. A jumpd dog.")); + assert_eq!(app.spelling.current().unwrap().start, 17 + suggestion.len() - 5, "on to the next"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(text_of(app, sid), TEXT); +} + +#[test] +fn the_user_dictionary_and_an_edit_reach_the_review() { + let (mut app, sid) = app_with(TEXT); + open(&mut app).unwrap(); + action(&mut app, "add", None).unwrap(); + assert!(app.spelling.current().is_none(), "a word in the document's dictionary is no issue"); + assert!(app.run("spelling.words", json!({})).unwrap().as_array().unwrap().contains(&json!("jumpd"))); + let (mut app, _) = app_with(TEXT); + open(&mut app).unwrap(); + let first = app.spelling.current().unwrap().suggestions[0].clone(); + let sid2 = app.spelling.current().unwrap().story; + app.run("spelling.change", json!({"story": sid2, "start": 0, "end": 0, "to": "A "})).unwrap(); + let before = app.session.doc().unwrap().doc.story(designcraft_doc::StoryId(sid2)).unwrap().text.clone(); + assert!(action(&mut app, "change", Some(&first)).unwrap_err().contains("changed")); + assert_eq!(app.session.doc().unwrap().doc.story(designcraft_doc::StoryId(sid2)).unwrap().text, before); + let _ = sid; + action(&mut app, "check", None).unwrap(); + assert_eq!(app.spelling.current().unwrap().start, 6); + // Selecting elsewhere is not an edit: the review carries on. + app.run("text.select", json!({"story": sid2, "anchor": 0, "focus": 0})).unwrap(); + action(&mut app, "ignoreOnce", None).unwrap(); +} + +#[test] +fn another_document_ends_the_review() { + let (mut app, _) = app_with(TEXT); + open(&mut app).unwrap(); + app.run("file.new", json!({})).unwrap(); + assert!(action(&mut app, "ignoreOnce", None).unwrap_err().contains("document changed")); + assert!(action(&mut app, "nonsense", None).is_err()); +} + +fn context_harness(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(egui::vec2(320.0, 500.0)).build_ui_state(|ui, app: &mut DesignApp| context_items(app, ui), app); + h.run_steps(1); + h +} + +#[test] +fn a_right_click_on_a_misspelled_word_offers_its_suggestions() { + let (mut app, sid) = app_with(TEXT); + app.run("text.select", json!({"story": sid.0, "anchor": 6, "focus": 6})).unwrap(); + prepare_context(&mut app); + let suggestion = app.spelling_context.as_ref().expect("the caret is in a misspelled word").suggestions[0].clone(); + let mut h = context_harness(app); + h.get_by_label(&suggestion).click(); + h.run_steps(2); + assert_eq!(text_of(h.state(), sid), format!("The {suggestion} fox. A jumpd dog.")); + assert!(h.state().spelling_context.is_none(), "the section is gone with the word"); +} + +#[test] +fn a_right_click_adds_to_the_dictionary_or_opens_the_panel() { + let (mut app, sid) = app_with(TEXT); + app.run("text.select", json!({"story": sid.0, "anchor": 6, "focus": 6})).unwrap(); + prepare_context(&mut app); + let mut h = context_harness(app); + h.get_by_label("Check Spelling…").click(); + h.run_steps(2); + assert_eq!(h.state().spelling.current().unwrap().word, "jumpd"); + let app = h.state_mut(); + app.run("text.select", json!({"story": sid.0, "anchor": 6, "focus": 6})).unwrap(); + prepare_context(app); + h.run_steps(2); + h.get_by_label("Add to Dictionary").click(); + h.run_steps(2); + assert!(h.state_mut().run("spelling.words", json!({})).unwrap().as_array().unwrap().contains(&json!("jumpd"))); +} + +#[test] +fn a_right_click_elsewhere_adds_nothing_to_the_menu() { + let (mut app, sid) = app_with(TEXT); + app.run("text.select", json!({"story": sid.0, "anchor": 1, "focus": 1})).unwrap(); + prepare_context(&mut app); + assert!(app.spelling_context.is_none(), "a correct word"); + let h = context_harness(app); + assert!(h.query_by_label("Add to Dictionary").is_none()); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 8efa2e423e..39290508e4 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -789,6 +789,37 @@ mod japanese_font_tests { }); } + /// A fresh English UI must install Khmer fallback glyphs before any Khmer dialog has drawn. + #[test] + fn fresh_ui_renders_khmer_in_body_and_suggestion_fonts() { + let dir = tempfile::tempdir().unwrap(); + let text = "អក្ខរាវិរុទ្ធ"; + let chars: Vec = text.chars().collect(); + let bytes = designcraft_fonts::testing::font_with("Khmer Sangam MN", &chars).unwrap(); + std::fs::write(dir.path().join("khmer.ttf"), bytes).unwrap(); + let db = designcraft_fonts::FontDb::with_font_dirs(vec![dir.path().to_path_buf()]); + let system = super::installed_ui_fonts(&db, &[]); + assert!(system.iter().any(|f| f.script == "Khmr")); + let mut defs = super::font_definitions(&[], "en"); + super::add_system_fallbacks(&mut defs, &system, "en"); + let ctx = egui::Context::default(); + ctx.set_fonts(defs); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + for family in families() { + let font = egui::FontId::new(20.0, family); + ui.fonts_mut(|fonts| { + for ch in text.chars() { + assert!(fonts.has_glyph(&font, ch), "missing {ch}"); + } + let galley = fonts.layout_no_wrap(text.into(), font.clone(), egui::Color32::WHITE); + assert!(galley.rect.is_finite() && galley.size().x > 0.0); + }); + ui.label(egui::RichText::new(text).font(font)); + } + }); + output.textures_delta.clear(); + } + #[test] fn ui_works_without_craft_fonts() { let ctx = ctx_with(&[]); diff --git a/docs/gaps.md b/docs/gaps.md index c2ca44ad2a..cb62df98f5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -157,7 +157,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ### 13. Hyphenation and spelling languages (T, L) - **Missing:** hyphenation patterns for every language but English, Spanish, Portuguese, Norwegian, Greek, German (2006 reform), French, Italian and Dutch; spelling for - every language but English. InDesign ships dictionaries for ~40 languages. + every language but English without a dictionary installed; see [spelling.md](spelling.md). InDesign ships dictionaries for ~40 languages. - **Estimate:** 30–60 h (permissively licensed patterns exist for most; spelling word lists need licence review). **Doc:** [typography-parity.md](typography-parity.md) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 677c4291a9..3a7b0acd52 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -93,7 +93,7 @@ eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, ## Document languages InDesign 2026 hyphenates and spell-checks ~40 languages (Hunspell and Duden dictionaries). -DesignCraft hyphenates **English, Dutch, French, German (2006 reform), Greek, Italian, Norwegian, Portuguese and Spanish** and spell-checks **English** only. Text language +DesignCraft hyphenates **English, Dutch, French, German (2006 reform), Greek, Italian, Norwegian, Portuguese and Spanish** and spell-checks **English**, plus other languages with a dictionary or pack and **Khmer** with its optional data; see [spelling.md](spelling.md). Text language can be set per paragraph and character (Properties, Character panel) and drives quotes and locale shaping. See [typography-parity.md](typography-parity.md). German hyphenation accepts `German: 2006 Reform`, `German: Swiss 2006 Reform` and `de` locale diff --git a/docs/roadmap.md b/docs/roadmap.md index ae162eedca..dd1f6cdb9d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -67,7 +67,7 @@ set exists; depth gaps are in gaps.md. | M7 | Long documents (sections, TOC, index, footnotes, books) | ✅ except Books (shallow) | | M8 | Tables | ✅ core (table/cell styles, rotation); diagonal lines, anchored objects in cells (#163) | | M9 | Performance (MT composition, tiles) | in progress: parallel compose 6.7×, damage regions; incremental composition next | -| M10 | Find/Change, spelling, Preflight | ✅ (spelling English only) | +| M10 | Find/Change, spelling, Preflight | ✅ (spelling through the shared `craft-proof`) | | M11 | Layout power features (liquid/alternate layouts, data merge) | present, shallow; Flex Layout (new in InDesign 2026) ✗ | | M12 | Interactive & digital (EPUB, HTML, interactive PDF) | present; HTML basic (#314), fixed-layout EPUB as images | | M13 | Automation (scripts, batch) | ✅ own command scripts, CLI, MCP; ExtendScript/UXP compatibility ✗ | diff --git a/docs/spelling.md b/docs/spelling.md new file mode 100644 index 0000000000..731e40c0a0 --- /dev/null +++ b/docs/spelling.md @@ -0,0 +1,54 @@ +# Spelling and proofreading + +DesignCraft checks spelling with the suite's shared engine, [`craft-proof`](https://github.com/storytold/craft-libs/tree/main/crates/proof), +and reviews it with the shared interface, [`craft-proof-ui`](https://github.com/storytold/craft-libs/tree/main/crates/proof-ui). + +## Using it + +**Edit > Spelling > Check Spelling…** opens the Spelling panel beside the page, on the first issue in +the document, with the word selected in the text. Clicking a suggestion corrects the word and moves +on; corrections are ordinary undoable edits. **Ignore Once** and **Ignore All** last for this pass, and +**Add to Dictionary** adds to the document's user dictionary. **Change…** takes a typed replacement. The +document stays editable: if the text under the current issue changes, the panel asks to check again +instead of applying a correction to the wrong place. + +Right-clicking a misspelled word while typing offers its suggestions, Add to Dictionary and Check +Spelling…. **Edit > Spelling > Dynamic Spelling** underlines issues as you type, and proofs a story again +only when its text, its styles or the user dictionary change. + +Each run of text is checked in its own language; **[No Language]** text is not checked. A language with +no dictionary installed is not checked either. + +## Dictionaries + +| Language | Source | +|---|---| +| English | The Language data from `CRAFT_I18N_DIR` (SCOWL) when a build embeds it, else an installed Hunspell dictionary, else the Moby list built into `craft-proof` | +| Khmer | The optional data pack, below | +| Others | An installed Hunspell dictionary or Language pack, as `craft-proof` documents | + +Hyphenation does not use these: it keeps its own patterns. + +## Khmer + +With the Khmer data included, unspaced Khmer gets spelling suggestions and preferred-form checks. Those +checks are limited rules, not a general grammar checker. Text in the default English style is checked as +Khmer; an explicit other language or [No Language] is respected. Word boundaries, repetition signs and +honorifics are handled in the shared engine, and offsets always address the original text: nothing is +normalized or inserted. + +The default build contains no Khmer dictionaries. It never reports Khmer as checked without them: the +panel says the text was not checked, and `spelling.status` reports availability. Build with the data as +[`crates/khmer/DATA.md`](https://github.com/storytold/craft-libs/blob/main/crates/khmer/DATA.md) in +craft-libs describes, setting `CRAFT_KHMER_DIR` and `CRAFT_KHMER_REQUIRED=1`. + +SBBIC identifies its internal work, the SEAlang frequency lists and SIL NRSI's khmerlbdict as the data's +sources; their MIT notices are in `NOTICE`. The tagger was trained on internal documents that SBBIC has +permission to train with; those documents are not distributed. + +## For automation + +`spelling.check {suggestions:false}` lists every issue without generating candidates; +`spelling.suggest {story,start,end}` revalidates one issue and returns its candidates; +`spelling.at {story,at}` returns the issue around an offset, or null. `app.checkSpelling` opens the panel +and `app.spelling {action,to?}` carries out its actions. diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 3e06b2007c..c1fa9e28d2 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -37,7 +37,7 @@ Fidelity: **unmeasured** unless stated. | World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested | | Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab justifies after the tab (#284); set per paragraph in Type › Justification… and the Paragraph panel menu (#531) | | Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no), Greek (hyph-el-monoton), German 2006 (hyph-de-1996), French (hyph-fr), Italian (hyph-it) and Dutch (Tutelaers); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame; discretionary U+00AD breaks with both composers regardless of automatic hyphenation and replaces pattern points within its word (#1955) | ~ | other languages don't hyphenate (#87 reported "doesn't work") | -| Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | | +| Spelling | per-language dictionaries | shared `craft-proof`: English, Language pack and installed Hunspell languages, optional Khmer; document/user dictionary; Spelling panel and right-click suggestions | ~ | [details](spelling.md) | | Kerning | Metrics, Optical, manual, none | Metrics, Optical (outline-based: pairs pulled towards the font's stem gap, `crates/compose/src/optical.rs`), manual, none | ~ | Optical is an approximation of Adobe's algorithm (#181), not a match: imported display text can still break differently | | Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | | | Character fill and stroke | fill, stroke, tint, weight, stroke aligned to centre or outside | fill, stroke, tints, weight, stroke behind fill (screen, PNG, PDF, IDML `StrokeAlignment`) | ~ | text strokes have no join or miter limit settings (#619 for stroke behind fill) | From ae317b0c33a75ba9c8100cc655b6a42ae3d61cd2 Mon Sep 17 00:00:00 2001 From: sungkhum <1120218+sungkhum@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:59:18 -0800 Subject: [PATCH 02/11] PF1: name pack languages in spelling results, and report the panel's strings Issues in a language from a pack or an installed dictionary had an empty language: only `tag()` names those. The Spelling panel's strings now count in the interface translation checks. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/spelling.rs | 2 +- crates/engine/src/cmd/spelling/khmer/tests.rs | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/tests.rs | 2 ++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 29d1980e5b..bb6a5d3b63 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -362,7 +362,7 @@ fn paragraph_issues( range: paragraph.start + issue.start..paragraph.start + issue.end, kind: if issue.kind == IssueKind::Spelling { "spelling" } else { "grammar" }, message: issue.message, - language: if issue.lang == ProofLang::Km { "km".into() } else { issue.lang.code().to_owned() }, + language: issue.lang.tag().into_owned(), lang: issue.lang, suggestions: Vec::new(), preferred, diff --git a/crates/engine/src/cmd/spelling/khmer/tests.rs b/crates/engine/src/cmd/spelling/khmer/tests.rs index f894ab16bf..b132bbc948 100644 --- a/crates/engine/src/cmd/spelling/khmer/tests.rs +++ b/crates/engine/src/cmd/spelling/khmer/tests.rs @@ -272,3 +272,21 @@ fn the_issue_at_a_caret_comes_with_its_suggestions_only_inside_it() { assert!(at(&mut s, 15).is_null(), "nor does another paragraph"); assert!(s.execute("spelling.at", &serde_json::json!({"story": sid.0, "at": 1000})).is_err()); } + +/// Any language with a dictionary is checked in its own language, right to left included. +#[test] +fn a_language_with_a_dictionary_is_checked_and_named() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("ar.aff"), "SET UTF-8\n").unwrap(); + std::fs::write(dir.path().join("ar.dic"), "3\nهذا\nكتاب\nجميل\n").unwrap(); + craft_proof::set_dictionaries_folder(Some(dir.path().to_path_buf())); + let (mut session, sid) = session("هذا كتابب جميل"); + let text = session.doc().unwrap().doc.story(sid).unwrap().text.clone(); + let check = |session: &mut Session| session.execute("spelling.check", &json!({"suggestions": false})).unwrap(); + let doc = std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().doc); + doc.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.language = Some("Arabic".into())); + let found = check(&mut session); + craft_proof::set_dictionaries_folder(None); + let issue = found.as_array().unwrap().iter().find(|i| i["word"] == "كتابب").expect("checked with its own dictionary"); + assert_eq!(issue["language"], "ar", "named by its tag, as every pack language is"); +} diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index b676a0c5fd..96d6e8d8d2 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -563,6 +563,8 @@ fn interface_strings() -> HashSet<&'static str> { ]); // The Keyboard Shortcuts dialog's strings (craft-keymap) reach `tr` too. keys.extend(crate::shortcuts_dialog::strings()); + // The Spelling panel's strings (craft-proof-ui) too. + keys.extend(craft_proof_ui::STRINGS.iter().copied()); keys } From d6a61be4c21d195709a60b774b1ee44d4092e0ad Mon Sep 17 00:00:00 2001 From: sungkhum <1120218+sungkhum@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:42:15 -0800 Subject: [PATCH 03/11] text selection: draw every glyph of a selected cluster Selected text is a black band with the glyphs redrawn inverted, but only a cluster's first glyph has a length, so subscripts and marks stayed black and vanished: Khmer, Devanagari and other complex scripts lost them while selected. In right-to-left text a cluster can start with a zero-width mark, so the band missed the base letter too. Now the whole cluster is redrawn, and selected text is one band unless other text lies between. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 32 ++++++++- crates/compose/src/tests.rs | 81 +++++++++++++++++++++++ crates/ui-egui/src/canvas/text_overlay.rs | 6 +- 3 files changed, 112 insertions(+), 7 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index abac729856..1e61e2c1e1 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -5625,6 +5625,23 @@ fn band_quad(x0: f64, x1: f64, top: f64, bot: f64) -> [Point; 4] { /// Highlight rectangles for bytes `s..e` on `l`. `past_end` extends the line-end caret when the /// selection continues onto the next line. Stacked rows (warichu) each get their own band. +/// The glyphs of line `l` in the selected range `s..e`, by index. A cluster's other glyphs (subscripts, +/// marks, and in right-to-left text the base that follows its first mark) carry no length of their +/// own but belong to it; generated ones (a list label, a line-end hyphen) never are selected. +fn selected_cluster_glyphs(l: &Line, s: usize, e: usize) -> impl Iterator { + // The byte of the cluster being walked, once its first glyph has been found. + let mut cluster = None; + l.glyphs.iter().enumerate().filter_map(move |(i, g)| { + cluster = if g.len > 0 { Some(g.byte) } else { cluster.filter(|byte| *byte == g.byte) }; + (g.byte >= s && g.byte < e && cluster == Some(g.byte)).then_some(i) + }) +} + +/// The glyphs to draw as selected for the source range `s..e`, by index into `l.glyphs`. +pub fn selected_glyphs(l: &Line, s: usize, e: usize) -> Vec { + selected_cluster_glyphs(l, s, e).filter(|i| l.glyphs.get(*i).is_some_and(|g| g.visible)).collect() +} + pub fn highlight_quads(l: &Line, s: usize, e: usize, past_end: bool) -> Vec<[Point; 4]> { let selected: Vec<&PlacedGlyph> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte >= s && g.byte < e).collect(); let stacked = selected.iter().any(|g| on_stacked_row(&selected, g)); @@ -5664,12 +5681,23 @@ pub fn highlight_quads(l: &Line, s: usize, e: usize, past_end: bool) -> Vec<[Poi let top = l.baseline - l.ascent; let bot = l.baseline + l.descent; if l.glyphs.iter().any(|g| g.rtl) { - let mut spans: Vec<(f64, f64)> = selected.iter().map(|g| (g.x, g.x + g.adv)).collect(); + // By every glyph of a cluster: the first may be a mark with no advance of its own. Selected + // text is one band unless other text lies between: joined letters needn't touch, and a + // selection across a change of direction lands in separate places. + let chosen: std::collections::HashSet = selected_cluster_glyphs(l, s, e).collect(); + let others: Vec = l + .glyphs + .iter() + .enumerate() + .filter(|(i, g)| !chosen.contains(i) && g.visible && g.adv > 0.0 && g.byte < l.range.end) + .map(|(_, g)| g.x + g.adv / 2.0) + .collect(); + let mut spans: Vec<(f64, f64)> = chosen.iter().filter_map(|i| l.glyphs.get(*i)).map(|g| (g.x, g.x + g.adv)).collect(); spans.sort_by(|a, b| a.0.total_cmp(&b.0)); let mut merged: Vec<(f64, f64)> = Vec::new(); for (a, b) in spans { match merged.last_mut() { - Some(m) if a <= m.1 + 0.5 => m.1 = m.1.max(b), + Some(m) if !others.iter().any(|x| *x > m.1 && *x < a) => m.1 = m.1.max(b), _ => merged.push((a, b)), } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index d7d9b6535b..883ff18f80 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -8635,3 +8635,84 @@ fn cross_reference_text_breaks_at_its_spaces() { } } } + +/// Selected text is redrawn inverted glyph by glyph, so a mark left out vanishes into the highlight. +#[test] +fn a_selected_cluster_includes_its_marks_and_nothing_generated() { + use designcraft_fonts::testing::font_mapping; + const FAMILY: &str = "DC Test Selected Clusters"; + static ADDED: std::sync::Once = std::sync::Once::new(); + ADDED.call_once(|| { + let glyphs = [('ក', 'a'), ('្', 'b'), ('ត', 'c'), ('ី', 'd'), ('ខ', 'e'), (' ', ' '), ('•', 'f'), ('-', '-'), ('x', 'x')]; + designcraft_fonts::FontDb::global().add_font(font_mapping(FAMILY, &glyphs).unwrap()); + }); + let compose = |text: &str, para: ParaAttrs, width: f64| { + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width, 400.0), para); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(FAMILY.into())); + compose_story(&d, sid, &ComposeOptions::default()) + }; + // One cluster of several glyphs, then a separate one. + let text = "ក្តី ខ"; + let space = text.find(' ').unwrap(); + let cs = compose(text, ParaAttrs::default(), 400.0); + let line = all_lines(&cs)[0]; + let cluster: Vec<_> = line.glyphs.iter().enumerate().filter(|(_, g)| g.byte == 0).map(|(i, _)| i).collect(); + assert!(cluster.len() > 1, "exercise a cluster of several glyphs"); + assert!(line.glyphs[cluster[1]].len == 0, "its later glyphs carry no length of their own"); + let word: Vec<_> = line.glyphs.iter().enumerate().filter(|(_, g)| g.byte < space).map(|(i, _)| i).collect(); + assert_eq!(selected_glyphs(line, 0, space), word, "every glyph of the word"); + let first_len = line.glyphs[cluster[0]].len; + assert_eq!(selected_glyphs(line, 0, first_len), cluster, "the whole cluster, marks included"); + assert!(selected_glyphs(line, space, text.len()).iter().all(|i| line.glyphs[*i].byte >= space), "another cluster's glyphs stay out"); + assert!(selected_glyphs(line, space + 1, space + 1).is_empty(), "an empty range selects nothing"); + assert_eq!(selected_glyphs(line, 0, text.len()).len(), line.glyphs.iter().filter(|g| g.visible).count(), "the whole line"); + // A list label is generated: it isn't selected with the first letter it sits beside. + let bulleted = ParaAttrs { list_type: Some(designcraft_doc::ListType::Bullets), ..Default::default() }; + let cs = compose("xx", bulleted, 400.0); + let line = all_lines(&cs)[0]; + assert!(line.glyphs.iter().any(|g| g.len == 0), "exercise a generated label"); + let selected = selected_glyphs(line, 0, 2); + assert_eq!(selected.len(), line.glyphs.iter().filter(|g| g.len > 0).count(), "only the text"); + assert!(selected.iter().all(|i| line.glyphs[*i].len > 0)); +} + +/// In right-to-left text a cluster can start with a mark that has no advance: the highlight must +/// still cover its base, or the selected letters are drawn inverted on the bare page. +#[test] +fn the_highlight_covers_every_glyph_of_a_right_to_left_cluster() { + use designcraft_fonts::testing::font_mapping; + const FAMILY: &str = "DC Test Selected Arabic"; + static ADDED: std::sync::Once = std::sync::Once::new(); + ADDED.call_once(|| { + let glyphs = [('\u{0628}', 'a'), ('\u{064e}', 'b'), ('\u{0644}', 'c'), (' ', ' '), ('x', 'x'), ('y', 'y')]; + designcraft_fonts::FontDb::global().add_font(font_mapping(FAMILY, &glyphs).unwrap()); + }); + let text = "\u{0628}\u{064e}\u{0644}\u{064e} \u{0628}\u{064e}\u{0644}\u{064e}"; + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 600.0, 400.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(FAMILY.into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&cs)[0]; + assert!(line.glyphs.iter().any(|g| g.rtl), "exercise right-to-left text"); + assert!(line.glyphs.iter().any(|g| g.len == 0 && g.byte < text.len() && g.adv > 0.0), "a cluster's later glyph has its own width"); + let quads = highlight_quads(line, 0, text.len(), false); + assert_eq!(quads.len(), 1, "one line of one direction is one band: {quads:?}"); + for g in line.glyphs.iter().filter(|g| g.byte < text.len() && g.adv > 0.0) { + let covered = quads.iter().any(|q| { + let (x0, x1) = (q[0].x.min(q[1].x), q[0].x.max(q[1].x)); + x0 <= g.x + 1e-6 && g.x + g.adv <= x1 + 1e-6 + }); + assert!(covered, "glyph at byte {} ({:.2}+{:.2}) outside {quads:?}", g.byte, g.x, g.adv); + } + // A selection across a change of direction lands in two places, and not on what lies between. + let text = "xy \u{0628}\u{0644}\u{0628} yx"; + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 600.0, 400.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(FAMILY.into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&cs)[0]; + let first_arabic_end = "xy \u{0628}".len(); + let quads = highlight_quads(line, 1, first_arabic_end, false); + assert_eq!(quads.len(), 2, "{quads:?}"); + let last = line.glyphs.iter().find(|g| g.byte == "xy \u{0628}\u{0644}".len()).unwrap(); + let middle = last.x + last.adv / 2.0; + assert!(quads.iter().all(|q| middle < q[0].x.min(q[1].x) || middle > q[0].x.max(q[1].x)), "an unselected letter stays out"); +} diff --git a/crates/ui-egui/src/canvas/text_overlay.rs b/crates/ui-egui/src/canvas/text_overlay.rs index 521759eb01..29ba0fd545 100644 --- a/crates/ui-egui/src/canvas/text_overlay.rs +++ b/crates/ui-egui/src/canvas/text_overlay.rs @@ -90,11 +90,7 @@ pub(super) fn draw_text_selection( quads.push([Point::new(a, r.y0), Point::new(b, r.y0), Point::new(b, r.y1), Point::new(a, r.y1)]); } } - for (gi, g) in l.glyphs.iter().enumerate() { - if g.visible && g.len > 0 && g.byte >= s && g.byte < e { - glyphs.push((li, gi)); - } - } + glyphs.extend(compose::selected_glyphs(l, s, e).into_iter().map(|gi| (li, gi))); } if !quads.is_empty() { runs.push((fi, m, quads, glyphs)); From 26d0f3d005b9f4e838b904ec6f3f9f6a87b49531 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:51:21 +0000 Subject: [PATCH 04/11] Apply review fixes - crates/compose/src/lib.rs --- crates/compose/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 1e61e2c1e1..272171ce0f 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -5623,11 +5623,9 @@ fn band_quad(x0: f64, x1: f64, top: f64, bot: f64) -> [Point; 4] { [Point::new(x0, top), Point::new(x1, top), Point::new(x1, bot), Point::new(x0, bot)] } -/// Highlight rectangles for bytes `s..e` on `l`. `past_end` extends the line-end caret when the -/// selection continues onto the next line. Stacked rows (warichu) each get their own band. /// The glyphs of line `l` in the selected range `s..e`, by index. A cluster's other glyphs (subscripts, /// marks, and in right-to-left text the base that follows its first mark) carry no length of their -/// own but belong to it; generated ones (a list label, a line-end hyphen) never are selected. +/// own but belong to it; generated ones (a list label, a line-end hyphen) are never selected. fn selected_cluster_glyphs(l: &Line, s: usize, e: usize) -> impl Iterator { // The byte of the cluster being walked, once its first glyph has been found. let mut cluster = None; @@ -5642,6 +5640,8 @@ pub fn selected_glyphs(l: &Line, s: usize, e: usize) -> Vec { selected_cluster_glyphs(l, s, e).filter(|i| l.glyphs.get(*i).is_some_and(|g| g.visible)).collect() } +/// Highlight rectangles for bytes `s..e` on `l`. `past_end` extends the line-end caret when the +/// selection continues onto the next line. Stacked rows (warichu) each get their own band. pub fn highlight_quads(l: &Line, s: usize, e: usize, past_end: bool) -> Vec<[Point; 4]> { let selected: Vec<&PlacedGlyph> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte >= s && g.byte < e).collect(); let stacked = selected.iter().any(|g| on_stacked_row(&selected, g)); From a2cf7389348a85c1ae526772bd7c7d1adae2e37b Mon Sep 17 00:00:00 2001 From: sungkhum <1120218+sungkhum@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:37:45 -0800 Subject: [PATCH 05/11] PF1: a spelling panel with real buttons, and underlines on the page while it shows The shared panel now has Change, Ignore Once, Ignore All and Add to Dictionary as buttons and a Change to field, and keeps those in view itself, so the dock leaves its scrolling to it. While the panel shows, what is left of the pass is underlined on the page, over the selection, red for spelling and blue for preferred forms, whatever Dynamic Spelling is set to. Squiggles span whole clusters. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/canvas.rs | 9 +++--- crates/ui-egui/src/canvas/tests.rs | 34 +++++++++++++++++++ crates/ui-egui/src/canvas/text_overlay.rs | 28 +++++++++++----- crates/ui-egui/src/dock.rs | 5 +-- crates/ui-egui/src/panel_docking.rs | 4 ++- crates/ui-egui/src/panels/spelling.rs | 36 ++++++++++++++++++--- crates/ui-egui/src/panels/spelling/tests.rs | 35 ++++++++++++++++++-- docs/spelling.md | 15 ++++++--- 10 files changed, 142 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 03c8712d6d..441e6f54c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1142,7 +1142,7 @@ dependencies = [ [[package]] name = "craft-proof-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c7a410e6ce1dc9672ad75b75d5dd424986fa956#5c7a410e6ce1dc9672ad75b75d5dd424986fa956" +source = "git+https://github.com/storytold/craft-libs?rev=f3d689bdad0816e7d5e00891a789acd31b5e34cb#f3d689bdad0816e7d5e00891a789acd31b5e34cb" dependencies = [ "egui", ] diff --git a/Cargo.toml b/Cargo.toml index a147aa85b2..60a345f93f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ craft-khmer = { git = "https://github.com/storytold/craft-libs", rev = "2322f68f # Shared proofing: spelling, suggestions and Khmer preferred forms for each language's dictionary. craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "2322f68f2e24bebb6e89e8333de3bbb7d02d1704", version = "=0.1.0" } # The shared spelling review: the Spelling panel and its right-click section. -craft-proof-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c7a410e6ce1dc9672ad75b75d5dd424986fa956", version = "=0.1.0" } +craft-proof-ui = { git = "https://github.com/storytold/craft-libs", rev = "f3d689bdad0816e7d5e00891a789acd31b5e34cb", version = "=0.1.0" } # Shared reader and writer; publish the immutable revision before this consumer. craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false } # Shared keyboard bindings: chords, overrides, conflicts, egui dispatch, the Keyboard Shortcuts dialog. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index afe2e1e182..8dbd57e2d7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -56,7 +56,7 @@ use scroll::{mods, navigation_space, scrollbars, spring_zoom_mode}; #[cfg(test)] use text_overlay::dynamic_misspellings; pub use text_overlay::selected_text; -use text_overlay::{draw_dynamic_spelling, draw_hidden_characters}; +use text_overlay::{draw_hidden_characters, draw_spelling_marks}; #[cfg(test)] use text_overlay::{draw_text_selection, frame_hidden_marks, hidden_marks}; use tool_overlays::draw_tool_overlays; @@ -293,14 +293,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if app.ui.flattener_preview { draw_flattener_preview(&painter, &xf, &doc, &layout); } - if app.ui.dynamic_spelling && !preview { - draw_dynamic_spelling(app, ui.ctx(), &painter, &xf, &doc, &layout); - } } if presenting { return; } let sel_rect = draw_selection(app, &painter, &xf, &doc, &layout); + if !preview { + // Over the selection: a review selects the word it marks. + draw_spelling_marks(app, ui.ctx(), &painter, &xf, &doc, &layout); + } // Where the pointer moved on to another of the app's windows, this window's last pointer // position is stale: no hover feedback or tool cursor there. let elsewhere = pointer_elsewhere(app, ui.ctx()); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index a2d0153f15..24e4a485a4 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -3021,6 +3021,40 @@ fn leaving_parent_editing_for_the_left_page_of_a_spread_keeps_that_page_selected assert_eq!(current_page(h.state()), Some(1)); } +/// Red squiggles the canvas draws, drawn above the selection a review makes of its word. +#[test] +fn a_review_underlines_its_issues_on_the_canvas() { + use crate::panels::spelling::SPELLING_MARK; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 400, 200], "content": "text", "text": "The jumpd fox. A jumpd dog."})).unwrap(); + let squiggles = |app: &DesignApp| { + let st = app.session.doc().unwrap(); + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let xf = Xf { min: pos2(0.0, 0.0), origin: Point::new(0.0, 0.0), zoom: 1.0, rot: 0, pivot: pos2(0.0, 0.0) }; + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + draw_spelling_marks(app, ui.ctx(), ui.painter(), &xf, &st.doc, &layout); + }); + output.textures_delta.clear(); + output + .shapes + .into_iter() + .filter(|s| matches!(&s.shape, egui::Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(SPELLING_MARK))) + .count() + }; + assert_eq!(squiggles(&app), 0, "nothing without a review or Dynamic Spelling"); + crate::panels::spelling::open(&mut app).unwrap(); + assert!(!app.ui.dynamic_spelling); + assert_eq!(squiggles(&app), 2); + crate::panels::spelling::action(&mut app, "ignoreAll", None).unwrap(); + assert_eq!(squiggles(&app), 0, "an ignored word loses its underline"); + app.ui.dynamic_spelling = true; + assert_eq!(squiggles(&app), 0, "the review speaks for a story it has checked"); + app.run("window.panel", json!({"panel": "pages"})).unwrap(); + assert_eq!(squiggles(&app), 2, "Dynamic Spelling once the panel is away"); +} + /// Typing in one story must not re-proof the others; a language or dictionary change must. #[test] fn dynamic_spelling_reproofs_only_after_a_relevant_change() { diff --git a/crates/ui-egui/src/canvas/text_overlay.rs b/crates/ui-egui/src/canvas/text_overlay.rs index 96095ef531..54a16fbfe5 100644 --- a/crates/ui-egui/src/canvas/text_overlay.rs +++ b/crates/ui-egui/src/canvas/text_overlay.rs @@ -376,21 +376,33 @@ pub(super) fn dynamic_misspellings( bad } -/// Edit › Spelling › Dynamic Spelling: red squiggles under misspelled words. -pub(super) fn draw_dynamic_spelling(app: &DesignApp, ctx: &egui::Context, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) { +/// Squiggles under spelling issues: what is left of a review while its panel shows, else Edit › +/// Spelling › Dynamic Spelling's misspelled words if it is on. +pub(super) fn draw_spelling_marks(app: &DesignApp, ctx: &egui::Context, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) { + use crate::panels::spelling::SPELLING_MARK; + let review = crate::panels::spelling::review_marks(app); + if review.is_empty() && !app.ui.dynamic_spelling { + return; + } + let preferred = crate::theme::Tokens::get(ctx).accent; for story in doc.stories.values() { - let bad = dynamic_misspellings(ctx, doc, story); - if bad.is_empty() { + let marks: Vec<(std::ops::Range, Color32)> = if let Some(found) = review.get(&story.id.0) { + found.iter().map(|(r, kind)| (r.clone(), if *kind == craft_proof_ui::IssueKind::Spelling { SPELLING_MARK } else { preferred })).collect() + } else if app.ui.dynamic_spelling { + dynamic_misspellings(ctx, doc, story).iter().map(|r| (r.clone(), SPELLING_MARK)).collect() + } else { + continue; + }; + if marks.is_empty() { continue; } let cs = app.session.cache.get(doc, story.id, None); - let red = Stroke::new(1.0, Color32::from_rgb(230, 30, 30)); for ft in &cs.frames { let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; let m = a * doc.text_xf(it); for l in &ft.lines { - for r in bad.iter().filter(|r| r.start < l.range.end && r.end > l.range.start) { - let gs: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte >= r.start && g.byte < r.end).collect(); + for (r, color) in marks.iter().filter(|(r, _)| r.start < l.range.end && r.end > l.range.start) { + let gs: Vec<_> = compose::selected_glyphs(l, r.start, r.end).into_iter().filter_map(|i| l.glyphs.get(i)).collect(); let (Some(x0), Some(x1)) = (gs.iter().map(|g| g.x).reduce(f64::min), gs.iter().map(|g| g.x + g.adv).reduce(f64::max)) else { continue; }; @@ -398,7 +410,7 @@ pub(super) fn draw_dynamic_spelling(app: &DesignApp, ctx: &egui::Context, painte let (p0, p1) = (xf.to_screen(m * Point::new(x0, y)), xf.to_screen(m * Point::new(x1, y))); // Screen direction of the text's own "down", so the wave hangs below the baseline in rotated, flipped and vertical frames. let down = xf.to_screen(m * Point::new(x0, y + 1.0)) - p0; - painter.add(egui::Shape::line(squiggle_points(p0, p1, down), red)); + painter.add(egui::Shape::line(squiggle_points(p0, p1, down), Stroke::new(1.0, *color))); } } } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index ff83e1972f..29b0de57a3 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -71,9 +71,10 @@ fn scrolled_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, max_h: f32) { } } -/// Panels that lay out their own scrolling parts (the Pages panel: parents, pages, footer). +/// Panels that lay out their own scrolling parts (the Pages panel: parents, pages, footer; the +/// Spelling panel: the review, then its actions in view). pub fn scrolls_itself(id: &str) -> bool { - id == "pages" + matches!(id, "pages" | "spelling") } /// The contents of an icon-column panel. diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 35597476fe..b2d5a67f10 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1256,7 +1256,9 @@ fn floating_size(panel: &str) -> egui::Vec2 { match panel { "properties" | "pages" | "layers" => egui::vec2(300.0, 420.0), "characterStyles" | "paragraphStyles" => egui::vec2(290.0, 340.0), - "glyphs" | "library" | "links" | "preflight" | "dataMerge" | "spelling" => egui::vec2(320.0, 400.0), + "glyphs" | "library" | "links" | "preflight" | "dataMerge" => egui::vec2(320.0, 400.0), + // The word in its sentence, a replacement, the suggestions and the actions. + "spelling" => egui::vec2(320.0, 520.0), _ => egui::vec2(280.0, 360.0), } } diff --git a/crates/ui-egui/src/panels/spelling.rs b/crates/ui-egui/src/panels/spelling.rs index f3a86155be..d86342b4a8 100644 --- a/crates/ui-egui/src/panels/spelling.rs +++ b/crates/ui-egui/src/panels/spelling.rs @@ -1,12 +1,15 @@ //! The Spelling panel and right-click suggestions: the shared review (`craft-proof-ui`) over this //! document's stories and the read-only spelling queries. -use std::{borrow::Cow, sync::Arc}; +use std::{borrow::Cow, collections::HashMap, ops::Range, sync::Arc}; use craft_proof_ui::{Action, ContextChoice, Host, Issue, IssueKind, Review, Text}; use serde_json::{Value, json}; use crate::{DesignApp, i18n::tr, theme::Tokens}; +/// The underline of a misspelled word, in the panel and on the canvas. +pub(crate) const SPELLING_MARK: egui::Color32 = egui::Color32::from_rgb(230, 30, 30); + /// The session as the review sees it. struct AppHost<'a>(&'a mut DesignApp); @@ -43,8 +46,7 @@ impl Host for AppHost<'_> { /// A story is replaced on any edit, formatting too; that can only ask for a needless re-check. fn version(&self, story: u64) -> Option { - let story = self.0.session.active()?.doc.stories.get(&designcraft_doc::StoryId(story))?; - Some(Arc::as_ptr(story) as usize as u64) + story_version(&self.0.session.active()?.doc, story) } fn document(&self) -> Option { @@ -71,6 +73,28 @@ impl Host for AppHost<'_> { } } +fn story_version(doc: &designcraft_doc::Document, story: u64) -> Option { + Some(Arc::as_ptr(doc.stories.get(&designcraft_doc::StoryId(story))?) as usize as u64) +} + +/// While the review's panel shows, the issues left to underline in each story it still speaks for: +/// not its ignored words, and not a story edited since, until it is checked again. +pub(crate) fn review_marks(app: &DesignApp) -> HashMap, IssueKind)>> { + let mut marks: HashMap<_, Vec<_>> = HashMap::new(); + let Some(st) = app.session.active() else { return marks }; + if !app.spelling.started() || !crate::panel_docking::showing(app, "spelling") { + return marks; + } + let document = Some(st.identity_epoch); + for id in st.doc.stories.keys().filter(|id| app.spelling.covers(document, id.0, story_version(&st.doc, id.0))) { + marks.insert(id.0, Vec::new()); + } + for issue in app.spelling.marks(document, |story| story_version(&st.doc, story)) { + marks.entry(issue.story).or_default().push((issue.start..issue.end, issue.kind)); + } + marks +} + struct AppText(String); impl Text for AppText { @@ -86,7 +110,11 @@ fn colors(t: &Tokens, ui: &egui::Ui) -> craft_proof_ui::Colors { field: t.input, field_border: t.input_border, hover: t.hover, - spelling: egui::Color32::from_rgb(230, 30, 30), + selected: t.row_selected, + accent: t.accent_strong, + // White, but for High Contrast's yellow. + on_accent: if t.accent_strong.intensity() > 0.6 { egui::Color32::BLACK } else { egui::Color32::WHITE }, + spelling: SPELLING_MARK, grammar: t.accent, error: ui.visuals().error_fg_color, } diff --git a/crates/ui-egui/src/panels/spelling/tests.rs b/crates/ui-egui/src/panels/spelling/tests.rs index ea46b57097..681c782f32 100644 --- a/crates/ui-egui/src/panels/spelling/tests.rs +++ b/crates/ui-egui/src/panels/spelling/tests.rs @@ -36,13 +36,16 @@ fn check_spelling_opens_the_panel_on_the_first_issue_and_selects_it() { } #[test] -fn a_suggestion_clicked_in_the_panel_corrects_and_undo_restores() { +fn a_suggestion_chosen_in_the_panel_corrects_and_undo_restores() { let (mut app, sid) = app_with(TEXT); open(&mut app).unwrap(); - let suggestion = app.spelling.current().unwrap().suggestions[0].clone(); + let suggestion = app.spelling.current().unwrap().suggestions[1].clone(); let mut h = panel(app); h.get_by_label(&suggestion).click(); h.run_steps(2); + assert_eq!(text_of(h.state(), sid), TEXT, "a click only chooses"); + h.get_by_label("Change").click(); + h.run_steps(2); let app = h.state_mut(); assert_eq!(text_of(app, sid), format!("The {suggestion} fox. A jumpd dog.")); assert_eq!(app.spelling.current().unwrap().start, 17 + suggestion.len() - 5, "on to the next"); @@ -50,6 +53,34 @@ fn a_suggestion_clicked_in_the_panel_corrects_and_undo_restores() { assert_eq!(text_of(app, sid), TEXT); } +/// A review marks what is left of its pass on the canvas, whatever Dynamic Spelling is set to. +#[test] +fn the_review_underlines_what_is_left_while_its_panel_shows() { + let (mut app, sid) = app_with(TEXT); + assert!(review_marks(&app).is_empty(), "nothing before a review"); + open(&mut app).unwrap(); + assert!(!app.ui.dynamic_spelling); + let ranges = |app: &DesignApp| review_marks(app).get(&sid.0).map(|marks| marks.iter().map(|m| m.0.clone()).collect::>()); + assert_eq!(ranges(&app), Some(vec![4..9, 17..22])); + action(&mut app, "ignoreOnce", None).unwrap(); + assert_eq!(ranges(&app), Some(vec![17..22]), "an ignored occurrence is no longer marked"); + app.run("window.panel", json!({"panel": "pages"})).unwrap(); + assert!(review_marks(&app).is_empty(), "the panel behind another one marks nothing"); +} + +/// The dock scrolls most panels itself, which would take the actions out of view with the rest. +#[test] +fn the_docked_panel_keeps_its_actions_in_view() { + let (mut app, _) = app_with(TEXT); + open(&mut app).unwrap(); + let height = 460.0; + let h = crate::test_window::open(app, egui::vec2(1400.0, height)); + for action in ["Change", "Ignore Once", "Ignore All", "Add to Dictionary"] { + let bottom = h.get_by_label(action).rect().max.y; + assert!(bottom <= height, "{action} ends at {bottom}"); + } +} + #[test] fn the_user_dictionary_and_an_edit_reach_the_review() { let (mut app, sid) = app_with(TEXT); diff --git a/docs/spelling.md b/docs/spelling.md index 731e40c0a0..3c611f4623 100644 --- a/docs/spelling.md +++ b/docs/spelling.md @@ -6,11 +6,16 @@ and reviews it with the shared interface, [`craft-proof-ui`](https://github.com/ ## Using it **Edit > Spelling > Check Spelling…** opens the Spelling panel beside the page, on the first issue in -the document, with the word selected in the text. Clicking a suggestion corrects the word and moves -on; corrections are ordinary undoable edits. **Ignore Once** and **Ignore All** last for this pass, and -**Add to Dictionary** adds to the document's user dictionary. **Change…** takes a typed replacement. The -document stays editable: if the text under the current issue changes, the panel asks to check again -instead of applying a correction to the wrong place. +the document, with the word selected in the text. While the panel shows, every issue left in the pass +is underlined on the page: red for spelling, blue for a preferred form. Ignored words lose their +underline. + +**Change to** starts with the best suggestion. Click another suggestion or type a replacement, then +**Change** or Enter corrects the word and moves on; double-clicking a suggestion does both. Corrections +are ordinary undoable edits. **Ignore Once** and **Ignore All** last for this pass, and **Add to +Dictionary** adds to the document's user dictionary. The document stays editable: if the text under the +current issue changes, the panel asks to check again instead of applying a correction to the wrong place, +and that story's underlines go until it does. Right-clicking a misspelled word while typing offers its suggestions, Add to Dictionary and Check Spelling…. **Edit > Spelling > Dynamic Spelling** underlines issues as you type, and proofs a story again From be9bbf48a1e5f74b216d363801f07dd0364b0ef8 Mon Sep 17 00:00:00 2001 From: sungkhum <1120218+sungkhum@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:40:36 -0800 Subject: [PATCH 06/11] PF1: compare the review marks' test by where they start Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/spelling/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/spelling/tests.rs b/crates/ui-egui/src/panels/spelling/tests.rs index 681c782f32..31bf4f5d63 100644 --- a/crates/ui-egui/src/panels/spelling/tests.rs +++ b/crates/ui-egui/src/panels/spelling/tests.rs @@ -60,10 +60,10 @@ fn the_review_underlines_what_is_left_while_its_panel_shows() { assert!(review_marks(&app).is_empty(), "nothing before a review"); open(&mut app).unwrap(); assert!(!app.ui.dynamic_spelling); - let ranges = |app: &DesignApp| review_marks(app).get(&sid.0).map(|marks| marks.iter().map(|m| m.0.clone()).collect::>()); - assert_eq!(ranges(&app), Some(vec![4..9, 17..22])); + let starts = |app: &DesignApp| review_marks(app).get(&sid.0).map(|marks| marks.iter().map(|m| m.0.start).collect::>()); + assert_eq!(starts(&app), Some(vec![4, 17])); action(&mut app, "ignoreOnce", None).unwrap(); - assert_eq!(ranges(&app), Some(vec![17..22]), "an ignored occurrence is no longer marked"); + assert_eq!(starts(&app), Some(vec![17]), "an ignored occurrence is no longer marked"); app.run("window.panel", json!({"panel": "pages"})).unwrap(); assert!(review_marks(&app).is_empty(), "the panel behind another one marks nothing"); } From c1b8929dffbbcfc2b443a5262ec54854e97c85dc Mon Sep 17 00:00:00 2001 From: sungkhum <1120218+sungkhum@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:31:25 -0800 Subject: [PATCH 07/11] PF1: size the Spelling panel to its six suggestions The shared list now scrolls inside itself, so the panel only needs room for six suggestions under a one-line sentence, without a gap above the actions. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/panel_docking.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 441e6f54c2..b49f5b471d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1142,7 +1142,7 @@ dependencies = [ [[package]] name = "craft-proof-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f3d689bdad0816e7d5e00891a789acd31b5e34cb#f3d689bdad0816e7d5e00891a789acd31b5e34cb" +source = "git+https://github.com/storytold/craft-libs?rev=1dbdcdc1b2fb000b7407c031018287031cc927d3#1dbdcdc1b2fb000b7407c031018287031cc927d3" dependencies = [ "egui", ] diff --git a/Cargo.toml b/Cargo.toml index 60a345f93f..1bf7aad4d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ craft-khmer = { git = "https://github.com/storytold/craft-libs", rev = "2322f68f # Shared proofing: spelling, suggestions and Khmer preferred forms for each language's dictionary. craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "2322f68f2e24bebb6e89e8333de3bbb7d02d1704", version = "=0.1.0" } # The shared spelling review: the Spelling panel and its right-click section. -craft-proof-ui = { git = "https://github.com/storytold/craft-libs", rev = "f3d689bdad0816e7d5e00891a789acd31b5e34cb", version = "=0.1.0" } +craft-proof-ui = { git = "https://github.com/storytold/craft-libs", rev = "1dbdcdc1b2fb000b7407c031018287031cc927d3", version = "=0.1.0" } # Shared reader and writer; publish the immutable revision before this consumer. craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false } # Shared keyboard bindings: chords, overrides, conflicts, egui dispatch, the Keyboard Shortcuts dialog. diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index b2d5a67f10..a80a124507 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1257,8 +1257,8 @@ fn floating_size(panel: &str) -> egui::Vec2 { "properties" | "pages" | "layers" => egui::vec2(300.0, 420.0), "characterStyles" | "paragraphStyles" => egui::vec2(290.0, 340.0), "glyphs" | "library" | "links" | "preflight" | "dataMerge" => egui::vec2(320.0, 400.0), - // The word in its sentence, a replacement, the suggestions and the actions. - "spelling" => egui::vec2(320.0, 520.0), + // Six suggestions under a one-line sentence; a longer sentence scrolls the list. + "spelling" => egui::vec2(320.0, 468.0), _ => egui::vec2(280.0, 360.0), } } From f35a63a73ac409777d33963e9e173de6ba78c479 Mon Sep 17 00:00:00 2001 From: sungkhum <1120218+sungkhum@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:32:38 -0800 Subject: [PATCH 08/11] PF1: spelling corrections that keep the selection, and reviews that follow their word From a production review. A right-click in a table cell looked up the frame's story at the cell's offset, and could change an unrelated word: it offers nothing there now, or in a longer selection, or off the text. spelling.change keeps the selection with the text, as a stale one could end mid-character. Added words hold for non-ASCII capitals and either apostrophe, as before the move to craft-proof. Generated text is left out of the review, as corrections there don't last. The review scrolls to a word off screen, a second Check Spelling no longer closes the panel, and the underlines only do work for frames on screen. The panel says when tables and notes went unchecked. Pins craft-proof to #551's current head. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 6 +- Cargo.toml | 6 +- ROADMAP.md | 2 +- crates/engine/src/cmd/spelling.rs | 88 ++++++++++++++++++++- crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/canvas/input.rs | 5 +- crates/ui-egui/src/canvas/tests.rs | 34 ++++++++ crates/ui-egui/src/canvas/text_overlay.rs | 13 ++- crates/ui-egui/src/canvas/view.rs | 34 ++++++++ crates/ui-egui/src/i18n/tests.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 7 +- crates/ui-egui/src/panels/spelling.rs | 33 ++++++-- crates/ui-egui/src/panels/spelling/tests.rs | 45 +++++++++++ docs/spelling.md | 4 + 14 files changed, 256 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b49f5b471d..d5027cfdad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1078,7 +1078,7 @@ dependencies = [ [[package]] name = "craft-khmer" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2322f68f2e24bebb6e89e8333de3bbb7d02d1704#2322f68f2e24bebb6e89e8333de3bbb7d02d1704" +source = "git+https://github.com/storytold/craft-libs?rev=f4875b2f30697d56bfdc94494c3b33d2bb0f75ca#f4875b2f30697d56bfdc94494c3b33d2bb0f75ca" dependencies = [ "brotli", "brotli-decompressor", @@ -1130,7 +1130,7 @@ dependencies = [ [[package]] name = "craft-proof" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2322f68f2e24bebb6e89e8333de3bbb7d02d1704#2322f68f2e24bebb6e89e8333de3bbb7d02d1704" +source = "git+https://github.com/storytold/craft-libs?rev=f4875b2f30697d56bfdc94494c3b33d2bb0f75ca#f4875b2f30697d56bfdc94494c3b33d2bb0f75ca" dependencies = [ "craft-khmer", "log", @@ -1142,7 +1142,7 @@ dependencies = [ [[package]] name = "craft-proof-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1dbdcdc1b2fb000b7407c031018287031cc927d3#1dbdcdc1b2fb000b7407c031018287031cc927d3" +source = "git+https://github.com/storytold/craft-libs?rev=12c2eb7e9db0bc4215ff034f7ad53ea83cd35b39#12c2eb7e9db0bc4215ff034f7ad53ea83cd35b39" dependencies = [ "egui", ] diff --git a/Cargo.toml b/Cargo.toml index 1bf7aad4d3..7ab063376b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,11 +12,11 @@ repository = "https://github.com/storytold/designcraft" [workspace.dependencies] # Shared Khmer segmentation and proofreading; data is an explicit, hash-verified build input. -craft-khmer = { git = "https://github.com/storytold/craft-libs", rev = "2322f68f2e24bebb6e89e8333de3bbb7d02d1704", version = "=0.1.0" } +craft-khmer = { git = "https://github.com/storytold/craft-libs", rev = "f4875b2f30697d56bfdc94494c3b33d2bb0f75ca", version = "=0.1.0" } # Shared proofing: spelling, suggestions and Khmer preferred forms for each language's dictionary. -craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "2322f68f2e24bebb6e89e8333de3bbb7d02d1704", version = "=0.1.0" } +craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "f4875b2f30697d56bfdc94494c3b33d2bb0f75ca", version = "=0.1.0" } # The shared spelling review: the Spelling panel and its right-click section. -craft-proof-ui = { git = "https://github.com/storytold/craft-libs", rev = "1dbdcdc1b2fb000b7407c031018287031cc927d3", version = "=0.1.0" } +craft-proof-ui = { git = "https://github.com/storytold/craft-libs", rev = "12c2eb7e9db0bc4215ff034f7ad53ea83cd35b39", version = "=0.1.0" } # Shared reader and writer; publish the immutable revision before this consumer. craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false } # Shared keyboard bindings: chords, overrides, conflicts, egui dispatch, the Keyboard Shortcuts dialog. diff --git a/ROADMAP.md b/ROADMAP.md index 784415915b..d06457955a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -388,7 +388,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log -- **2026-11-03 · PF1:** Spelling moves to the shared `craft-proof`, with the shared Spelling panel and right-click suggestions; Khmer is checked with its optional data. Dynamic Spelling proofs a story again only when its text, styles or the user dictionary change. See [spelling.md](docs/spelling.md). No readiness percentage change. +- **2026-11-03 · PF1:** Spelling moves to the shared `craft-proof`, with the shared Spelling panel, underlines on the page while it shows and right-click suggestions; Khmer is checked with its optional data. Dynamic Spelling proofs a story again only when its text, styles or the user dictionary change. See [spelling.md](docs/spelling.md). No readiness percentage change. - **2026-11-02** · EPUB Export Options names a cover image file (PNG or JPEG, typed or chosen with Choose…) for the reflowable export; left empty, the first page stays the cover (#1934). No readiness percentage change. - **2026-11-02** · The Pen shows the segment a dragged smooth point shapes while it is dragged, and its rubber band curves out of a smooth point's direction line (#1438, first item). No readiness percentage change. diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index bb6a5d3b63..5bf7c32c72 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -91,12 +91,30 @@ pub fn specs() -> Vec { }; let (a, b) = (offset("start")?, offset("end")?); let to = str_param(p, "to").unwrap_or("").to_string(); - s.edit(|d, _| { + s.edit(|d, sel| { + if generated(d, sid) { + return Err(bad("spelling.change", "generated text is rebuilt from its source: correct it there")); + } let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; if a > b || st.text.get(a..b).is_none() { return Err(bad("spelling.change", "range must address original UTF-8 character boundaries")); } st.replace(a..b, &to); + // The selection follows the text: a selected word stays selected at its new length. + let end = a + to.len(); + let follow = |pos: usize| { + if pos <= a { + pos + } else if pos >= b { + pos - b + end + } else { + end + } + }; + if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) { + t.anchor = follow(t.anchor); + t.focus = follow(t.focus); + } Ok(Value::Null) }) }), @@ -350,7 +368,12 @@ fn paragraph_issues( .filter(|(r, _)| r.start < paragraph.end && r.end > paragraph.start) .map(|(r, l)| (r.start.max(paragraph.start) - paragraph.start..r.end.min(paragraph.end) - paragraph.start, *l)) .collect(); - let known = |word: &str| user.iter().any(|w| w == word || w.eq_ignore_ascii_case(word)); + // As `spelling.addWord` stores a word: lowercase, whatever the script. Either apostrophe. + let fold = |word: &str| word.to_lowercase().replace('’', "'"); + let known = |word: &str| { + let word = fold(word); + user.iter().any(|w| fold(w) == word) + }; craft_proof::check_text(text, &local) .into_iter() .filter(|issue| issue.kind == IssueKind::Spelling || issue.lang == ProofLang::Km) @@ -437,6 +460,9 @@ fn issue_at(s: &mut Session, p: &Value) -> Result { let at = p.get("at").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(command, "at must be a byte offset"))?; let doc = &s.doc()?.doc; let story = doc.story(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; + if generated(doc, sid) { + return Ok(Value::Null); + } let (Some(before), Some(tail)) = (story.text.get(..at), story.text.get(at..)) else { return Err(bad(command, "at must be on a UTF-8 character boundary inside the story")); }; @@ -460,13 +486,18 @@ fn suggestions_for(issue: &mut ProofIssue, word: &str) -> Vec { } } +/// Text rebuilt from its source (endnotes, a table of contents, an index): an edit here wouldn't last. +fn generated(d: &designcraft_doc::Document, sid: StoryId) -> bool { + d.endnote_story == Some(sid) || d.toc.as_ref().is_some_and(|t| t.story == sid) || d.index.as_ref().is_some_and(|i| i.story == sid) +} + fn check(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let d = &st.doc; let want_sugg = p.get("suggestions").and_then(Value::as_bool).unwrap_or(true); let stories: Vec = match p.get("story").and_then(Value::as_u64) { Some(id) => vec![StoryId(id)], - None => d.stories.keys().copied().collect(), + None => d.stories.keys().copied().filter(|sid| !generated(d, *sid)).collect(), }; let mut out = Vec::new(); for sid in stories { @@ -480,6 +511,57 @@ fn check(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; + fn session_with(text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 400, 200], "content": "text", "text": text})).unwrap(); + (s, frame["story"].as_u64().unwrap()) + } + + /// `spelling.addWord` lowers the case in any script, and a word is typed with either apostrophe. + #[test] + fn the_user_dictionary_holds_for_any_case_and_apostrophe() { + let (mut s, _) = session_with("Zqémile. Zqdon’t."); + assert_eq!(s.execute("spelling.check", &json!({"suggestions": false})).unwrap().as_array().unwrap().len(), 2); + s.execute("spelling.addWord", &json!({"word": "Zqémile"})).unwrap(); + s.execute("spelling.addWord", &json!({"word": "zqdon't"})).unwrap(); + assert_eq!(s.execute("spelling.check", &json!({"suggestions": false})).unwrap(), json!([])); + } + + #[test] + fn a_change_keeps_the_selection_with_the_text() { + let (mut s, story) = session_with("ete and more"); + let selection = |s: &Session| { + let t = s.doc().unwrap().selection.text.unwrap(); + (t.anchor, t.focus) + }; + let change = |s: &mut Session, to: &str| s.execute("spelling.change", &json!({"story": story, "start": 0, "end": 3, "to": to})).unwrap(); + s.execute("text.select", &json!({"story": story, "anchor": 1, "focus": 1})).unwrap(); + change(&mut s, "été"); + assert_eq!(selection(&s), (5, 5), "a caret inside the word lands after it, on a character boundary"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 3})).unwrap(); + change(&mut s, "an"); + assert_eq!(selection(&s), (0, 2), "a selected word stays selected, reaching no further"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": story, "anchor": 8, "focus": 12})).unwrap(); + change(&mut s, "an"); + assert_eq!(selection(&s), (7, 11), "later text keeps its selection"); + } + + /// The endnote list is rebuilt after every edit: a correction there would report success and vanish. + #[test] + fn generated_text_is_left_out_of_the_review() { + let (mut s, story) = session_with("Plain."); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 0})).unwrap(); + s.execute("endnote.insert", &json!({"text": "A zqwrong endnote."})).unwrap(); + let list = s.doc().unwrap().doc.endnote_story.expect("the endnote list").0; + assert!(s.doc().unwrap().doc.story(StoryId(list)).unwrap().text.contains("zqwrong")); + assert_eq!(s.execute("spelling.check", &json!({"suggestions": false})).unwrap(), json!([])); + assert_eq!(s.execute("spelling.at", &json!({"story": list, "at": 5})).unwrap(), Value::Null); + assert!(s.execute("spelling.change", &json!({"story": list, "start": 0, "end": 1, "to": "x"})).is_err()); + } + #[test] fn known_words_and_suggestions() { let d = Dictionary::en_us(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8dbd57e2d7..bf861e29f6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -64,7 +64,8 @@ pub(crate) use view::page_in_view; use view::present_fit; pub use view::{ PageBox, apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page, - drop_point, fit, go_through_history, go_to_page, page_box, page_box_go, page_target, set_zoom, show_spread, step_spread, view_centre, zoom_at, + drop_point, fit, go_through_history, go_to_page, page_box, page_box_go, page_target, reveal_text, set_zoom, show_spread, step_spread, + view_centre, zoom_at, }; pub const RULER: f32 = 15.0; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 36922e01e6..7ce6e152da 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -140,8 +140,11 @@ fn context_target(app: &mut DesignApp, p: Point, zoom: f64) -> crate::menus::Con if app.run("text.placeCaret", json!({"frame": id.0, "point": [sp.x, sp.y]})).is_ok_and(|r| r["dragging"] == json!(true)) { let _ = app.run("text.release", json!({"frame": id.0, "point": [sp.x, sp.y], "moved": true})); } + crate::panels::spelling::prepare_context(app); + } else { + // Not on text: the caret may be anywhere, off screen too. + app.spelling_context = None; } - crate::panels::spelling::prepare_context(app); return ContextMenu::Text; } let Some((id, _)) = hit else { return ContextMenu::Canvas }; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 24e4a485a4..dd954bbbe7 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -3021,6 +3021,40 @@ fn leaving_parent_editing_for_the_left_page_of_a_spread_keeps_that_page_selected assert_eq!(current_page(h.state()), Some(1)); } +/// A review of a book goes to words on other pages. +#[test] +fn a_review_brings_its_word_into_view() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 400, 200], "content": "text", "text": "The jumpd fox."})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + let app = h.state_mut(); + let rect = app.canvas_rect.unwrap(); + app.view_mut().unwrap().origin = Point::new(-9000.0, -9000.0); + let far = app.view().unwrap().origin; + crate::panels::spelling::open(app).unwrap(); + let view = app.view().unwrap(); + assert_ne!(view.origin, far); + let st = app.session.doc().unwrap(); + let selection = st.selection.text.unwrap(); + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let cs = app.session.cache.get(&st.doc, selection.story, None); + let (a, _) = item_canvas_xf(&st.doc, &layout, cs.frames[0].frame).unwrap(); + let frame = Xf::new(rect, view).rect(a.transform_rect_bbox(st.doc.item(cs.frames[0].frame).unwrap().bounds())); + assert!(rect.intersects(frame), "{frame:?} in {rect:?}"); + let at = view.origin; + crate::panels::spelling::action(app, "check", None).unwrap(); + assert_eq!(app.view().unwrap().origin, at, "a word in view leaves the view alone"); +} + /// Red squiggles the canvas draws, drawn above the selection a review makes of its word. #[test] fn a_review_underlines_its_issues_on_the_canvas() { diff --git a/crates/ui-egui/src/canvas/text_overlay.rs b/crates/ui-egui/src/canvas/text_overlay.rs index 54a16fbfe5..9317a481cd 100644 --- a/crates/ui-egui/src/canvas/text_overlay.rs +++ b/crates/ui-egui/src/canvas/text_overlay.rs @@ -386,7 +386,7 @@ pub(super) fn draw_spelling_marks(app: &DesignApp, ctx: &egui::Context, painter: } let preferred = crate::theme::Tokens::get(ctx).accent; for story in doc.stories.values() { - let marks: Vec<(std::ops::Range, Color32)> = if let Some(found) = review.get(&story.id.0) { + let mut marks: Vec<(std::ops::Range, Color32)> = if let Some(found) = review.get(&story.id.0) { found.iter().map(|(r, kind)| (r.clone(), if *kind == craft_proof_ui::IssueKind::Spelling { SPELLING_MARK } else { preferred })).collect() } else if app.ui.dynamic_spelling { dynamic_misspellings(ctx, doc, story).iter().map(|r| (r.clone(), SPELLING_MARK)).collect() @@ -396,12 +396,21 @@ pub(super) fn draw_spelling_marks(app: &DesignApp, ctx: &egui::Context, painter: if marks.is_empty() { continue; } + marks.sort_by_key(|(r, _)| r.start); let cs = app.session.cache.get(doc, story.id, None); for ft in &cs.frames { let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; + // This runs every frame, and a book has many: only what is on screen. + if !painter.clip_rect().intersects(xf.rect(a.transform_rect_bbox(it.bounds()))) { + continue; + } let m = a * doc.text_xf(it); + // Lines and marks both run in text order. + let mut next = 0; for l in &ft.lines { - for (r, color) in marks.iter().filter(|(r, _)| r.start < l.range.end && r.end > l.range.start) { + next += marks.get(next..).unwrap_or_default().iter().take_while(|(r, _)| r.end <= l.range.start).count(); + let on_line = marks.get(next..).unwrap_or_default().iter().take_while(|(r, _)| r.start < l.range.end); + for (r, color) in on_line.filter(|(r, _)| r.end > l.range.start) { let gs: Vec<_> = compose::selected_glyphs(l, r.start, r.end).into_iter().filter_map(|i| l.glyphs.get(i)).collect(); let (Some(x0), Some(x1)) = (gs.iter().map(|g| g.x).reduce(f64::min), gs.iter().map(|g| g.x + g.adv).reduce(f64::max)) else { continue; diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index f00a005f53..88217e8073 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -206,6 +206,40 @@ pub fn go_to_page(app: &mut DesignApp, abs: usize) { note_jump(app, true); } +/// Where text is on the canvas: its first glyph's baseline. `None` when it isn't laid out. +fn text_point(app: &DesignApp, story: designcraft_doc::StoryId, start: usize, end: usize) -> Option { + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let cs = app.session.cache.get(&st.doc, story, None); + for ft in &cs.frames { + for l in ft.lines.iter().filter(|l| l.range.start < end && l.range.end > start) { + let Some(g) = designcraft_compose::selected_glyphs(l, start, end).first().and_then(|i| l.glyphs.get(*i)) else { continue }; + let (a, _) = super::frames::item_canvas_xf(&st.doc, &layout, ft.frame)?; + return Some(a * st.doc.text_xf(st.doc.item(ft.frame)?) * Point::new(g.x, l.baseline)); + } + } + None +} + +/// Scroll text to the centre, if it isn't well inside the canvas (keeps zoom). A jump Go Back can +/// return from. +pub fn reveal_text(app: &mut DesignApp, story: designcraft_doc::StoryId, start: usize, end: usize) { + /// Room around the text for the sentence it is in. + const MARGIN: f32 = 64.0; + let Some(rect) = app.canvas_rect else { return }; + let Some(at) = text_point(app, story, start, end) else { return }; + if app.view().is_none_or(|v| rect.shrink(MARGIN).contains(Xf::new(rect, v).to_screen(at))) { + return; + } + note_jump(app, false); + if let Some(v) = app.view_mut() { + let centre = Xf::new(rect, v).unrotate(rect.center()); + v.origin = Point::new(at.x - (centre.x - rect.min.x) as f64 / v.zoom, at.y - (centre.y - rect.min.y) as f64 / v.zoom); + v.fitted = false; + } + note_jump(app, true); +} + fn centre_page(app: &mut DesignApp, abs: usize) { let Some(st) = app.session.active() else { return }; let layout = CanvasLayout::new(&st.doc, false); diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 96d6e8d8d2..d2b7c65de0 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -565,6 +565,7 @@ fn interface_strings() -> HashSet<&'static str> { keys.extend(crate::shortcuts_dialog::strings()); // The Spelling panel's strings (craft-proof-ui) too. keys.extend(craft_proof_ui::STRINGS.iter().copied()); + keys.extend(crate::panels::spelling::STRINGS.iter().copied()); keys } diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 11789be53b..2ec1fa8f9c 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -12,12 +12,7 @@ pub fn shown(app: &DesignApp, id: &str) -> bool { /// UI commands: (id, label, shortcut, params). pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ - ( - "app.checkSpelling", - "Check Spelling…", - None, - "{} — opens the Spelling panel and checks the document; → {issue, remaining, skipped, to, message, khmerPresent, khmerUnchecked}", - ), + ("app.checkSpelling", "Check Spelling…", None, "{} — opens the Spelling panel and checks the document; → {issue, remaining, to, message}"), ( "app.spelling", "Spelling Review", diff --git a/crates/ui-egui/src/panels/spelling.rs b/crates/ui-egui/src/panels/spelling.rs index d86342b4a8..d64bf2966a 100644 --- a/crates/ui-egui/src/panels/spelling.rs +++ b/crates/ui-egui/src/panels/spelling.rs @@ -10,6 +10,12 @@ use crate::{DesignApp, i18n::tr, theme::Tokens}; /// The underline of a misspelled word, in the panel and on the canvas. pub(crate) const SPELLING_MARK: egui::Color32 = egui::Color32::from_rgb(230, 30, 30); +const KHMER_UNCHECKED: &str = "Khmer data is not included. Khmer text has not been checked."; +const NOTES_UNCHECKED: &str = "Text in tables and notes has not been checked."; +/// The notices' strings, for the catalogs' report. +#[cfg(test)] +pub(crate) const STRINGS: &[&str] = &[KHMER_UNCHECKED, NOTES_UNCHECKED]; + /// The session as the review sees it. struct AppHost<'a>(&'a mut DesignApp); @@ -55,6 +61,7 @@ impl Host for AppHost<'_> { fn reveal(&mut self, issue: &Issue) { let _ = self.0.run("text.select", json!({"story": issue.story, "anchor": issue.start, "focus": issue.end})); + crate::canvas::reveal_text(self.0, designcraft_doc::StoryId(issue.story), issue.start, issue.end); } fn change(&mut self, issue: &Issue, to: &str) -> Result<(), String> { @@ -67,9 +74,17 @@ impl Host for AppHost<'_> { fn notice(&self) -> Option { let st = self.0.session.active()?; - let unchecked = !designcraft_engine::cmd::spelling::khmer_available() - && st.doc.stories.values().any(|s| designcraft_engine::cmd::spelling::has_khmer(&s.text)); - unchecked.then(|| tr(&self.0.ui.language, "Khmer data is not included. Khmer text has not been checked.").to_owned()) + let lang = &self.0.ui.language; + let mut notices = Vec::new(); + if !designcraft_engine::cmd::spelling::khmer_available() + && st.doc.stories.values().any(|s| designcraft_engine::cmd::spelling::has_khmer(&s.text)) + { + notices.push(tr(lang, KHMER_UNCHECKED)); + } + if st.doc.stories.values().any(|s| !s.tables.is_empty() || !s.notes.is_empty() || !s.endnotes.is_empty()) { + notices.push(tr(lang, NOTES_UNCHECKED)); + } + (!notices.is_empty()).then(|| notices.join(" ")) } } @@ -125,7 +140,10 @@ pub(crate) fn open(app: &mut DesignApp) -> Result { if app.session.active().is_none() { return Err("no document open".into()); } - app.run("window.panel", json!({"panel": "spelling"}))?; + // `window.panel` closes a flyout that is already open. + if !crate::panel_docking::showing(app, "spelling") { + app.run("window.panel", json!({"panel": "spelling"}))?; + } let mut review = Review::default(); review.start(&mut AppHost(app)); let status = status(&review); @@ -172,8 +190,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { pub(crate) fn prepare_context(app: &mut DesignApp) { app.spelling_context = None; let Some(text) = app.session.active().and_then(|st| st.selection.text) else { return }; + // A cell's offsets are into its own story, which isn't checked. + if text.cell.is_some() { + return; + } if let Ok(found) = app.run("spelling.at", json!({"story": text.story.0, "at": text.focus})) { - app.spelling_context = issue_of(&found); + // A longer selection isn't about the word at its end. The review selects exactly its word. + app.spelling_context = issue_of(&found).filter(|issue| text.is_caret() || text.range() == (issue.start..issue.end)); } } diff --git a/crates/ui-egui/src/panels/spelling/tests.rs b/crates/ui-egui/src/panels/spelling/tests.rs index 31bf4f5d63..057c36bd33 100644 --- a/crates/ui-egui/src/panels/spelling/tests.rs +++ b/crates/ui-egui/src/panels/spelling/tests.rs @@ -150,6 +150,51 @@ fn a_right_click_adds_to_the_dictionary_or_opens_the_panel() { assert!(h.state_mut().run("spelling.words", json!({})).unwrap().as_array().unwrap().contains(&json!("jumpd"))); } +/// A cell's offsets are into its own story: at those offsets in the frame's story is some other word. +#[test] +fn a_right_click_in_a_table_cell_offers_nothing_for_the_text_around_it() { + let (mut app, sid) = app_with(TEXT); + app.run("text.select", json!({"story": sid.0, "anchor": 6, "focus": 6})).unwrap(); + app.run("table.insert", json!({"rows": 1, "cols": 1})).unwrap(); + let table = *app.session.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap(); + let cell = designcraft_doc::CellAddr { table, row: 0, col: 0 }; + let selection = designcraft_doc::TextSel { story: sid, anchor: 6, focus: 6, frame: None, cell: Some(cell) }; + app.session.doc_mut().unwrap().selection.text = Some(selection); + prepare_context(&mut app); + assert!(app.spelling_context.is_none()); +} + +#[test] +fn a_right_click_in_a_longer_selection_isnt_about_the_word_at_its_end() { + let (mut app, sid) = app_with(TEXT); + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": 6})).unwrap(); + prepare_context(&mut app); + assert!(app.spelling_context.is_none()); + app.run("text.select", json!({"story": sid.0, "anchor": 4, "focus": 9})).unwrap(); + prepare_context(&mut app); + assert_eq!(app.spelling_context.as_ref().map(|i| i.word.as_str()), Some("jumpd"), "exactly the word, as the review selects it"); +} + +/// In the icon column `window.panel` closes an open flyout: the review mustn't go on out of sight. +#[test] +fn checking_again_keeps_the_panel_open() { + let (mut app, _) = app_with(TEXT); + open(&mut app).unwrap(); + assert!(crate::panel_docking::showing(&app, "spelling")); + open(&mut app).unwrap(); + assert!(crate::panel_docking::showing(&app, "spelling")); + assert!(!review_marks(&app).is_empty()); +} + +#[test] +fn the_panel_says_what_it_doesnt_check() { + let (mut app, sid) = app_with(TEXT); + assert!(AppHost(&mut app).notice().is_none_or(|n| !n.contains(NOTES_UNCHECKED))); + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + app.run("table.insert", json!({"rows": 1, "cols": 1})).unwrap(); + assert!(AppHost(&mut app).notice().is_some_and(|n| n.contains(NOTES_UNCHECKED))); +} + #[test] fn a_right_click_elsewhere_adds_nothing_to_the_menu() { let (mut app, sid) = app_with(TEXT); diff --git a/docs/spelling.md b/docs/spelling.md index 3c611f4623..dac7bef806 100644 --- a/docs/spelling.md +++ b/docs/spelling.md @@ -24,6 +24,10 @@ only when its text, its styles or the user dictionary change. Each run of text is checked in its own language; **[No Language]** text is not checked. A language with no dictionary installed is not checked either. +Text in tables and footnotes and endnotes is not checked yet, and the panel says so in a document that +has any. Generated text (a table of contents, an index, the endnote list) is skipped, as a correction +there wouldn't last. Corrections aren't marked by Track Changes, as with Find/Change. + ## Dictionaries | Language | Source | From 3ade74f1ce8ce12ecf7a1fda5a695c0b125c1650 Mon Sep 17 00:00:00 2001 From: sungkhum <1120218+sungkhum@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:56:01 -0800 Subject: [PATCH 09/11] PF2: pin craft-khmer and craft-proof to the current craft-libs#551 craft-libs#551's head disables embedding the Khmer data until maintainers classify it, so a build with CRAFT_KHMER_DIR now fails. The docs, NOTICE and ASSETS say so. Co-Authored-By: Claude Opus 5.5 --- ASSETS.md | 2 +- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- NOTICE | 5 ++++- docs/localization-parity.md | 2 +- docs/spelling.md | 9 +++++---- 6 files changed, 15 insertions(+), 11 deletions(-) diff --git a/ASSETS.md b/ASSETS.md index 73cf0eecf3..65adc9fe81 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -8,7 +8,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| -| Optional Khmer model, spelling/frequency dictionaries and preferred-form rules (embedded by `craft-khmer`) | SBBIC Khmer; sources include SEAlang (Doug Cooper) and SIL NRSI khmerlbdict | https://github.com/sbbic-khmer/aksara-khmer-word-processor/tree/31740e9d51d38171ae6119fc2d18a4a18fbfb2f1 | MIT (full notices in NOTICE) | Explicit `CRAFT_KHMER_DIR` input; source and canonical hashes verified by the pinned shared crate; no dictionaries stored in this repository | +| Optional Khmer model, spelling/frequency dictionaries and preferred-form rules (embedded by `craft-khmer`) | SBBIC Khmer; sources include SEAlang (Doug Cooper) and SIL NRSI khmerlbdict | https://github.com/sbbic-khmer/aksara-khmer-word-processor/tree/31740e9d51d38171ae6119fc2d18a4a18fbfb2f1 | MIT (full notices in NOTICE) | Not in any build yet: the pinned shared crate refuses `CRAFT_KHMER_DIR` until maintainers classify the data. Then an explicit input with source and canonical hashes verified by that crate; no dictionaries stored in this repository | | German hyphenation patterns (embedded by `hypher` 0.1.8, feature `german`) | Deutschsprachige Trennmustermannschaft: Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr | https://github.com/typst/hypher/blob/d81cc506416bffef2a75ee2dc969d4b41bc36613/patterns/hyph-de-1996.tex | MIT (copyright 2013–2024; full text in NOTICE) | Version 2024-02-28; reformed German spelling (2006), despite the `de-1996` filename; no separate old-spelling or Swiss patterns | | `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts | | `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts | diff --git a/Cargo.lock b/Cargo.lock index a00245761b..7e02c438b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1078,7 +1078,7 @@ dependencies = [ [[package]] name = "craft-khmer" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f4875b2f30697d56bfdc94494c3b33d2bb0f75ca#f4875b2f30697d56bfdc94494c3b33d2bb0f75ca" +source = "git+https://github.com/storytold/craft-libs?rev=0b478944aa27a5bef1f51508c57bd236f272326d#0b478944aa27a5bef1f51508c57bd236f272326d" dependencies = [ "brotli", "brotli-decompressor", @@ -1130,7 +1130,7 @@ dependencies = [ [[package]] name = "craft-proof" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f4875b2f30697d56bfdc94494c3b33d2bb0f75ca#f4875b2f30697d56bfdc94494c3b33d2bb0f75ca" +source = "git+https://github.com/storytold/craft-libs?rev=0b478944aa27a5bef1f51508c57bd236f272326d#0b478944aa27a5bef1f51508c57bd236f272326d" dependencies = [ "craft-khmer", "log", diff --git a/Cargo.toml b/Cargo.toml index 7024efe50c..45d700e049 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,9 @@ repository = "https://github.com/storytold/designcraft" [workspace.dependencies] # Shared Khmer segmentation and proofreading; data is an explicit, hash-verified build input. -craft-khmer = { git = "https://github.com/storytold/craft-libs", rev = "f4875b2f30697d56bfdc94494c3b33d2bb0f75ca", version = "=0.1.0" } +craft-khmer = { git = "https://github.com/storytold/craft-libs", rev = "0b478944aa27a5bef1f51508c57bd236f272326d", version = "=0.1.0" } # Shared proofing: spelling, suggestions and Khmer preferred forms for each language's dictionary. -craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "f4875b2f30697d56bfdc94494c3b33d2bb0f75ca", version = "=0.1.0" } +craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "0b478944aa27a5bef1f51508c57bd236f272326d", version = "=0.1.0" } # The shared spelling review: the Spelling panel and its right-click section. craft-proof-ui = { git = "https://github.com/storytold/craft-libs", rev = "12c2eb7e9db0bc4215ff034f7ad53ea83cd35b39", version = "=0.1.0" } # Shared reader and writer; publish the immutable revision before this consumer. diff --git a/NOTICE b/NOTICE index 4cf8445221..8b5b45502d 100644 --- a/NOTICE +++ b/NOTICE @@ -245,7 +245,10 @@ SBBIC identifies internal work, the SEAlang frequency lists (http://sealang.net/project/list/) and SIL NRSI’s Khmer line-breaking dictionary (https://github.com/silnrsi/khmerlbdict) as sources. These materials are MIT licensed; their copyright and permission notices follow. Builds without -CRAFT_KHMER_DIR omit this data. See the craft-khmer README for provenance and its limits. +CRAFT_KHMER_DIR omit this data, and craft-khmer currently refuses that setting, pending +maintainer classification of the data; no DesignCraft build contains it until then. +See the craft-khmer README and PROVENANCE.md for provenance and its limits, including the +historical Unicode notice for the CLDR-derived names in the frequency input. The attached-glyph protection in composition was informed by visarutforthaipbs’s DesignCraft #516. Shared-core contributions are credited in craft-khmer. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 0469ba4e63..11a5b85b0f 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -93,7 +93,7 @@ eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, ## Document languages InDesign 2026 hyphenates and spell-checks ~40 languages (Hunspell and Duden dictionaries). -DesignCraft hyphenates **English, Danish, Dutch, French, German (2006 reform), Greek, Italian, Norwegian, Portuguese and Spanish** and spell-checks **English**, plus other languages with a dictionary or pack and **Khmer** with its optional data; see [spelling.md](spelling.md). Text language +DesignCraft hyphenates **English, Danish, Dutch, French, German (2006 reform), Greek, Italian, Norwegian, Portuguese and Spanish** and spell-checks **English**, plus other languages with a dictionary or pack and **Khmer** with its optional data, once maintainers approve embedding it; see [spelling.md](spelling.md). Text language can be set per paragraph and character (Properties, Character panel) and drives quotes and locale shaping. See [typography-parity.md](typography-parity.md). German hyphenation accepts `German: 2006 Reform`, `German: Swiss 2006 Reform` and `de` locale diff --git a/docs/spelling.md b/docs/spelling.md index dac7bef806..621bdbae47 100644 --- a/docs/spelling.md +++ b/docs/spelling.md @@ -46,10 +46,11 @@ Khmer; an explicit other language or [No Language] is respected. Word boundaries honorifics are handled in the shared engine, and offsets always address the original text: nothing is normalized or inserted. -The default build contains no Khmer dictionaries. It never reports Khmer as checked without them: the -panel says the text was not checked, and `spelling.status` reports availability. Build with the data as -[`crates/khmer/DATA.md`](https://github.com/storytold/craft-libs/blob/main/crates/khmer/DATA.md) in -craft-libs describes, setting `CRAFT_KHMER_DIR` and `CRAFT_KHMER_REQUIRED=1`. +Builds contain no Khmer dictionaries for now. They never report Khmer as checked without them: the +panel says the text was not checked, and `spelling.status` reports availability. `craft-khmer` refuses +`CRAFT_KHMER_DIR`, with no bypass, until maintainers sign off on the data's licence classification and the +policy for trained models and converted configuration; see [PROVENANCE.md](https://github.com/storytold/craft-libs/blob/main/crates/khmer/PROVENANCE.md). +[`crates/khmer/DATA.md`](https://github.com/storytold/craft-libs/blob/main/crates/khmer/DATA.md) in craft-libs describes the build for after that approval. SBBIC identifies its internal work, the SEAlang frequency lists and SIL NRSI's khmerlbdict as the data's sources; their MIT notices are in `NOTICE`. The tagger was trained on internal documents that SBBIC has From e0f19bef6b705969635d6b7a2fe72d050a7c46bb Mon Sep 17 00:00:00 2001 From: sungkhum <1120218+sungkhum@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:09:15 -0800 Subject: [PATCH 10/11] PF3: keep the view on a word a review scrolled to Marking the view unfitted made the next frame refit the spread, losing the scroll and the zoom, and kept Go Back from recording the destination. The Khmer and panel doc links now name commits, since those paths aren't on craft-libs main yet. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/tests.rs | 11 +++++++++-- crates/ui-egui/src/canvas/view.rs | 1 - docs/spelling.md | 6 +++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index dd954bbbe7..9c532592bd 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -3038,11 +3038,18 @@ fn a_review_brings_its_word_into_view() { h.run_steps(4); let app = h.state_mut(); let rect = app.canvas_rect.unwrap(); - app.view_mut().unwrap().origin = Point::new(-9000.0, -9000.0); + let v = app.view_mut().unwrap(); + v.origin = Point::new(-9000.0, -9000.0); + v.zoom = 3.0; let far = app.view().unwrap().origin; crate::panels::spelling::open(app).unwrap(); + let revealed = app.view().unwrap().origin; + assert_ne!(revealed, far); + h.run_steps(2); + let app = h.state_mut(); + let rect = app.canvas_rect.unwrap(); let view = app.view().unwrap(); - assert_ne!(view.origin, far); + assert_eq!((view.origin, view.zoom), (revealed, 3.0), "later frames keep the word in view, at the same zoom"); let st = app.session.doc().unwrap(); let selection = st.selection.text.unwrap(); let layout = CanvasLayout::new(&st.doc, st.editing_parents); diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 88217e8073..b6d509eb66 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -235,7 +235,6 @@ pub fn reveal_text(app: &mut DesignApp, story: designcraft_doc::StoryId, start: if let Some(v) = app.view_mut() { let centre = Xf::new(rect, v).unrotate(rect.center()); v.origin = Point::new(at.x - (centre.x - rect.min.x) as f64 / v.zoom, at.y - (centre.y - rect.min.y) as f64 / v.zoom); - v.fitted = false; } note_jump(app, true); } diff --git a/docs/spelling.md b/docs/spelling.md index 621bdbae47..ea9e7ea614 100644 --- a/docs/spelling.md +++ b/docs/spelling.md @@ -1,7 +1,7 @@ # Spelling and proofreading DesignCraft checks spelling with the suite's shared engine, [`craft-proof`](https://github.com/storytold/craft-libs/tree/main/crates/proof), -and reviews it with the shared interface, [`craft-proof-ui`](https://github.com/storytold/craft-libs/tree/main/crates/proof-ui). +and reviews it with the shared interface, [`craft-proof-ui`](https://github.com/storytold/craft-libs/tree/12c2eb7e9db0bc4215ff034f7ad53ea83cd35b39/crates/proof-ui). ## Using it @@ -49,8 +49,8 @@ normalized or inserted. Builds contain no Khmer dictionaries for now. They never report Khmer as checked without them: the panel says the text was not checked, and `spelling.status` reports availability. `craft-khmer` refuses `CRAFT_KHMER_DIR`, with no bypass, until maintainers sign off on the data's licence classification and the -policy for trained models and converted configuration; see [PROVENANCE.md](https://github.com/storytold/craft-libs/blob/main/crates/khmer/PROVENANCE.md). -[`crates/khmer/DATA.md`](https://github.com/storytold/craft-libs/blob/main/crates/khmer/DATA.md) in craft-libs describes the build for after that approval. +policy for trained models and converted configuration; see [PROVENANCE.md](https://github.com/storytold/craft-libs/blob/0b478944aa27a5bef1f51508c57bd236f272326d/crates/khmer/PROVENANCE.md). +[`crates/khmer/DATA.md`](https://github.com/storytold/craft-libs/blob/0b478944aa27a5bef1f51508c57bd236f272326d/crates/khmer/DATA.md) in craft-libs describes the build for after that approval. SBBIC identifies its internal work, the SEAlang frequency lists and SIL NRSI's khmerlbdict as the data's sources; their MIT notices are in `NOTICE`. The tagger was trained on internal documents that SBBIC has From 122d90fab8c4393bad6fc953f27e57aedcb78151 Mon Sep 17 00:00:00 2001 From: sungkhum <1120218+sungkhum@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:25:40 -0800 Subject: [PATCH 11/11] PF4: a style edit asks a spelling review to check again Styles give a story its language, so a review's marks and corrections could outlive the language they were checked in. The review's version now follows the styles too. Also removes an unused variable from the scroll test. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/tests.rs | 1 - crates/ui-egui/src/panels/spelling.rs | 15 ++++++++++++--- crates/ui-egui/src/panels/spelling/tests.rs | 12 ++++++++++++ docs/spelling.md | 4 ++-- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 9c532592bd..211bb4f56d 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -3037,7 +3037,6 @@ fn a_review_brings_its_word_into_view() { ); h.run_steps(4); let app = h.state_mut(); - let rect = app.canvas_rect.unwrap(); let v = app.view_mut().unwrap(); v.origin = Point::new(-9000.0, -9000.0); v.zoom = 3.0; diff --git a/crates/ui-egui/src/panels/spelling.rs b/crates/ui-egui/src/panels/spelling.rs index d64bf2966a..8bde9e085a 100644 --- a/crates/ui-egui/src/panels/spelling.rs +++ b/crates/ui-egui/src/panels/spelling.rs @@ -1,6 +1,12 @@ //! The Spelling panel and right-click suggestions: the shared review (`craft-proof-ui`) over this //! document's stories and the read-only spelling queries. -use std::{borrow::Cow, collections::HashMap, ops::Range, sync::Arc}; +use std::{ + borrow::Cow, + collections::HashMap, + hash::{Hash, Hasher}, + ops::Range, + sync::Arc, +}; use craft_proof_ui::{Action, ContextChoice, Host, Issue, IssueKind, Review, Text}; use serde_json::{Value, json}; @@ -50,7 +56,8 @@ impl Host for AppHost<'_> { Some(self.0.session.active()?.doc.story(designcraft_doc::StoryId(story))?.text.as_str()) } - /// A story is replaced on any edit, formatting too; that can only ask for a needless re-check. + /// Changes with a story's text and formatting and with the styles that give it a language. Either + /// can only ask for a needless re-check. fn version(&self, story: u64) -> Option { story_version(&self.0.session.active()?.doc, story) } @@ -89,7 +96,9 @@ impl Host for AppHost<'_> { } fn story_version(doc: &designcraft_doc::Document, story: u64) -> Option { - Some(Arc::as_ptr(doc.stories.get(&designcraft_doc::StoryId(story))?) as usize as u64) + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + (Arc::as_ptr(doc.stories.get(&designcraft_doc::StoryId(story))?), Arc::as_ptr(&doc.styles)).hash(&mut hasher); + Some(hasher.finish()) } /// While the review's panel shows, the issues left to underline in each story it still speaks for: diff --git a/crates/ui-egui/src/panels/spelling/tests.rs b/crates/ui-egui/src/panels/spelling/tests.rs index 057c36bd33..fffb6b352a 100644 --- a/crates/ui-egui/src/panels/spelling/tests.rs +++ b/crates/ui-egui/src/panels/spelling/tests.rs @@ -104,6 +104,18 @@ fn the_user_dictionary_and_an_edit_reach_the_review() { action(&mut app, "ignoreOnce", None).unwrap(); } +#[test] +fn a_style_edit_asks_for_a_new_check() { + let (mut app, sid) = app_with(TEXT); + open(&mut app).unwrap(); + assert!(review_marks(&app).contains_key(&sid.0)); + let name = "[Basic Paragraph]"; + app.run("style.paragraph.edit", json!({"name": name, "chars": {"language": "[No Language]"}})).unwrap(); + assert!(review_marks(&app).is_empty(), "the language these words were checked in has gone"); + action(&mut app, "check", None).unwrap(); + assert!(app.spelling.current().is_none()); +} + #[test] fn another_document_ends_the_review() { let (mut app, _) = app_with(TEXT); diff --git a/docs/spelling.md b/docs/spelling.md index ea9e7ea614..d849c0043b 100644 --- a/docs/spelling.md +++ b/docs/spelling.md @@ -14,8 +14,8 @@ underline. **Change** or Enter corrects the word and moves on; double-clicking a suggestion does both. Corrections are ordinary undoable edits. **Ignore Once** and **Ignore All** last for this pass, and **Add to Dictionary** adds to the document's user dictionary. The document stays editable: if the text under the -current issue changes, the panel asks to check again instead of applying a correction to the wrong place, -and that story's underlines go until it does. +current issue or the styles that give it a language change, the panel asks to check again instead of applying +a correction to the wrong place, and the affected underlines go until it does. Right-clicking a misspelled word while typing offers its suggestions, Add to Dictionary and Check Spelling…. **Edit > Spelling > Dynamic Spelling** underlines issues as you type, and proofs a story again