diff --git a/ROADMAP.md b/ROADMAP.md index 880d4bbaa..977848412 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-16 | Read Aloud speaks Russian, Ukrainian, Greek, Arabic, Hebrew, CJK and Thai text with a voice for its language instead of spelling out letters (#1843): the sentence's language comes from its run's tag (`w:bidi` for right-to-left text) or, when that disagrees with the script, from its letters, even when English letters outnumber them; Windows reads the text as UTF-8 and picks an installed voice of that language, macOS `say -v` and Linux `espeak -v` get the same language. The user's own voice stays whenever it already speaks the language (English documents read exactly as before), and a voice espeak lacks falls back to the default voice instead of silence. Not run on Windows or macOS here. | | 2026-10-16 | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. | | 2026-10-16 | Ctrl/⌘+wheel only zooms: a coast or wheel notch still gliding no longer scrolls the page on top of the zoom, which keeps the page point under the pointer (#1743). | | 2026-10-15 | AutoCorrect follows Turkish and Azerbaijani case mapping: `istanbul` → `İstanbul`, `ılık` → `Ilık`, `İNgilizce` → `İngilizce` with no combining dot (#1923) | @@ -453,6 +454,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-17 | trivial | Progress log: DOCX glossary documents (building blocks) | | 2026-10-15 | trivial | Progress log: move options in Track Changes Options (gap #5) | | 2026-10-15 | trivial | Progress log: image size limits checked before allocation (#1321) | +| 2026-10-17 | trivial | Progress log: Read Aloud speaks each sentence in its own language, #1843 | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | | 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. | | 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination | diff --git a/crates/engine/src/speech.rs b/crates/engine/src/speech.rs index 863c4e45e..30d447f78 100644 --- a/crates/engine/src/speech.rs +++ b/crates/engine/src/speech.rs @@ -6,6 +6,12 @@ //! `BIBLIOGRAPHY` fields. The citation's text simply isn't in the queue, so every citation //! style is skipped the same way: "(Smith et al., 2020)", "[3]" or a superscript number. //! +//! Each sentence is spoken in its own language: the run's language tag, or else the script of its +//! letters (Cyrillic, Greek, Arabic, Hebrew, CJK, Thai…), so a Russian sentence gets a Russian voice +//! where one is installed instead of being spelled out letter by letter. The system's own voice +//! stays whenever it already speaks the language (English text on an English system keeps the +//! voice the user chose), and without a voice for the language the default voice speaks. +//! //! The speech itself runs on a worker thread (desktop only): it starts `say` (macOS), `espeak` //! / `spd-say` (Linux, BSD) or the Windows speech synthesizer (PowerShell) for the current //! sentence, and moves on when it finishes. Commands only change the shared state and wake the @@ -55,6 +61,8 @@ pub struct Utterance { pub text: String, pub start: Pos, pub end: Pos, + /// The language to speak it in (a BCP 47 tag), or `None` for the system's default voice. + pub lang: Option, } impl Utterance { @@ -130,12 +138,249 @@ pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: b continue; } let (Some(first), Some(last)) = (map.get(a), map.get(b.saturating_sub(1))) else { continue }; - out.push(Utterance { text: spoken, start: Pos::new(story, path.clone(), first.0), end: Pos::new(story, path.clone(), last.1) }); + // The language of the sentence's first character, through styles and the defaults. + let run = para.props_of_char(first.0); + let explicit = doc.styles.resolve_char(para.props.style.as_deref(), run).lang; + let lang = speech_language(&spoken, explicit.as_deref(), run.lang_bidi.as_deref()); + out.push(Utterance { text: spoken, start: Pos::new(story, path.clone(), first.0), end: Pos::new(story, path.clone(), last.1), lang }); } } out } +/// The language of `text` from its script, as a BCP 47 primary tag: Cyrillic is Russian (Ukrainian +/// with one of its letters `іїєґ`), Greek, Arabic, Hebrew, Thai and Hangul map to `el`, `ar`, `he`, `th` +/// and `ko`, kana to Japanese, Han letters alone to Chinese. `None` for Latin text (the script +/// doesn't say which language) and text without letters. Mixed text goes to its largest non-Latin +/// script once that script has a real share of the letters (a fifth), even when Latin letters +/// outnumber it ("Я открыл Microsoft Word and PowerPoint"): the default voice would spell those +/// letters out, while a Russian voice reads the English words well enough. Ties go to the script +/// listed first. +pub fn detect_language(text: &str) -> Option<&'static str> { + // Letters per script: Cyrillic, Greek, Arabic, Hebrew, Thai, Hangul, kana, Han, Latin and others. + let mut n = [0usize; 10]; + let mut ukrainian = false; + for c in text.chars().filter(|c| c.is_alphabetic()) { + let i = match c { + '\u{0400}'..='\u{052F}' | '\u{1C80}'..='\u{1C8F}' | '\u{2DE0}'..='\u{2DFF}' | '\u{A640}'..='\u{A69F}' => { + ukrainian |= matches!(c, 'і' | 'ї' | 'є' | 'ґ' | 'І' | 'Ї' | 'Є' | 'Ґ'); + 0 + } + '\u{0370}'..='\u{03FF}' | '\u{1F00}'..='\u{1FFF}' => 1, + '\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{08A0}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}' => 2, + '\u{0590}'..='\u{05FF}' | '\u{FB1D}'..='\u{FB4F}' => 3, + '\u{0E00}'..='\u{0E7F}' => 4, + '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{AC00}'..='\u{D7AF}' => 5, + '\u{3040}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' => 6, + '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{20000}'..='\u{2FFFF}' => 7, + _ => 8, + }; + if let Some(k) = n.get_mut(i) { + *k += 1; + } + } + // Japanese text mixes kana and Han letters; any kana makes it Japanese. + if n[6] > 0 { + n[6] += n[7]; + n[7] = 0; + } + // The first script with the most letters (`max_by_key` would keep the last of a tie). + let (best, count) = n.iter().take(8).copied().enumerate().fold((0, 0), |b, (i, k)| if k > b.1 { (i, k) } else { b }); + let total: usize = n.iter().sum(); + if count == 0 || count.saturating_mul(5) < total { + return None; + } + Some(match best { + 0 if ukrainian => "uk", + 0 => "ru", + 1 => "el", + 2 => "ar", + 3 => "he", + 4 => "th", + 5 => "ko", + 6 => "ja", + _ => "zh", + }) +} + +/// A language tag that is safe to hand to a speech program or put in a script: letters, digits +/// and hyphens only, starting with a letter, at most 12 characters (a tag from a document is +/// hostile input). +fn plain_tag(tag: &str) -> Option<&str> { + let t = tag.trim(); + (!t.is_empty() && t.len() <= 12 && t.starts_with(|c: char| c.is_ascii_alphabetic()) && t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) + .then_some(t) +} + +/// The script group a language is written in, for the languages [`detect_language`] can tell +/// apart by script (the primary subtag in, the detected tag out). +fn script_language(primary: &str) -> Option<&'static str> { + Some(match primary { + "ru" | "uk" | "be" | "bg" | "sr" | "mk" | "kk" | "ky" | "mn" | "tg" => "ru", + "el" => "el", + "ar" | "fa" | "ur" | "ps" | "ku" | "sd" | "ug" => "ar", + "he" | "yi" => "he", + "th" => "th", + "ko" => "ko", + "ja" => "ja", + "zh" => "zh", + _ => return None, + }) +} + +/// The language to speak `text` in: the document's `explicit` tag when it fits the text, else the +/// language of its script, else `None` (the default voice). When the text is in a script +/// [`detect_language`] recognises, a tag written in another script (the `en-US` Word writes on +/// every run, on Cyrillic text) gives way to the script, or the voice would spell the letters out; +/// right-to-left text tries the run's complex-script tag (`w:bidi`, e.g. `fa-IR`) first, and Han +/// letters alone keep a Japanese or Korean tag. Latin text keeps any tag, as in Word, which reads +/// text in its proofing language: English words in a run tagged `ru-RU` get the Russian voice. +pub fn speech_language(text: &str, explicit: Option<&str>, bidi: Option<&str>) -> Option { + let explicit = explicit.and_then(plain_tag); + let Some(d) = detect_language(text) else { return explicit.map(str::to_string) }; + let fits = |tag: &&str| { + let primary = tag.split('-').next().unwrap_or("").to_ascii_lowercase(); + let script = script_language(&primary); + script == script_language(d) || (d == "zh" && matches!(script, Some("ja" | "ko"))) + }; + let bidi = bidi.and_then(plain_tag).filter(|_| matches!(d, "ar" | "he")); + Some(bidi.into_iter().chain(explicit).find(fits).unwrap_or(d).to_string()) +} + +/// The primary language subtag of a plain tag, lowercased (`ru-RU` gives `ru`). `None` for tags +/// without a two- or three-letter language, such as Word's `x-none`. +fn primary_tag(tag: &str) -> Option { + let t = plain_tag(tag)?; + t.split('-').next().filter(|p| (2..=3).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphabetic())).map(str::to_ascii_lowercase) +} + +/// The language to ask the speech program for, or `None` to keep its default voice: only when +/// `lang` isn't the language the default voice speaks (`default`, a primary subtag) already. +#[cfg(any(not(target_os = "windows"), test))] +fn voice_language(lang: Option<&str>, default: &str) -> Option { + lang.and_then(primary_tag).filter(|p| !p.eq_ignore_ascii_case(default)) +} + +/// The arguments of `espeak-ng`/`espeak`: the speed, the voice for `lang` unless it's English +/// (espeak's default voice), and `--stdin`. +#[cfg(any(all(unix, not(target_os = "macos")), test))] +fn espeak_args(wpm: &str, lang: Option<&str>) -> Vec { + let mut a = vec!["-s".to_string(), wpm.to_string()]; + if let Some(p) = voice_language(lang, "en") { + a.push("-v".into()); + a.push(p); + } + a.push("--stdin".into()); + a +} + +/// The voices in `say -v '?'`'s `listing`: each line is `Name ll_RR # sample sentence`, and +/// names may contain spaces. Gives (name, lowercased `ll_rr` code) pairs. +#[cfg(any(target_os = "macos", test))] +fn mac_voices(listing: &str) -> impl Iterator + '_ { + listing.lines().filter_map(|line| { + let line = line.split('#').next().unwrap_or(""); + let words: Vec<&str> = line.split_whitespace().collect(); + let at = words.iter().skip(1).position(|w| { + let mut p = w.split('_'); + p.next().is_some_and(|l| (2..=3).contains(&l.len()) && l.chars().all(|c| c.is_ascii_lowercase())) + && p.next().is_some_and(|r| !r.is_empty()) + })? + 1; + Some((words.get(..at)?.join(" "), words.get(at)?.to_ascii_lowercase())) + }) +} + +/// macOS's novelty voices (sound effects and singing), never chosen to read a document. +#[cfg(any(target_os = "macos", test))] +const MAC_NOVELTY_VOICES: &[&str] = &[ + "Albert", + "Bad News", + "Bahh", + "Bells", + "Boing", + "Bubbles", + "Cellos", + "Deranged", + "Good News", + "Hysterical", + "Jester", + "Organ", + "Pipe Organ", + "Superstar", + "Trinoids", + "Whisper", + "Wobble", + "Zarvox", +]; + +/// The voice `say` should use for `lang`, or `None` to keep the system voice: `None` when the +/// system voice already speaks the language (`default`, a primary subtag) or no voice does, else +/// the first voice in `listing` for the language, one of the same region first, never a novelty voice. +#[cfg(any(target_os = "macos", test))] +fn mac_voice(lang: &str, listing: &str, default: &str) -> Option { + let primary = voice_language(Some(lang), default)?; + let tag = plain_tag(lang)?.replace('-', "_").to_ascii_lowercase(); + let mut first = None; + for (name, code) in mac_voices(listing) { + if code.split('_').next() != Some(primary.as_str()) || MAC_NOVELTY_VOICES.contains(&name.as_str()) { + continue; + } + if code == tag { + return Some(name); + } + first.get_or_insert(name); + } + first +} + +/// The language (primary subtag) of the system voice: the `selected` voice's in `listing`, else +/// the `locale`'s (`ru_RU`, `ru_RU.UTF-8`), else English. +#[cfg(any(target_os = "macos", test))] +fn mac_default_language(selected: Option<&str>, listing: &str, locale: Option<&str>) -> String { + let voice = selected + .and_then(|v| mac_voices(listing).find(|(name, _)| name == v.trim())) + .and_then(|(_, code)| code.split('_').next().map(str::to_string)); + let locale = || locale.and_then(|l| l.split(['_', '-', '.', '@']).next()).and_then(primary_tag); + voice.or_else(locale).unwrap_or_else(|| "en".into()) +} + +/// The language of the voice `say` uses by default (asked once). +#[cfg(target_os = "macos")] +fn mac_default_voice_language() -> &'static str { + static LANG: std::sync::OnceLock = std::sync::OnceLock::new(); + LANG.get_or_init(|| { + let read = |args: &[&str]| { + std::process::Command::new("defaults") + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + }; + let voice = read(&["read", "com.apple.speech.voice.prefs", "SelectedVoiceName"]); + let locale = read(&["read", "-g", "AppleLocale"]).or_else(|| std::env::var("LANG").ok()); + mac_default_language(voice.as_deref(), mac_voice_listing(), locale.as_deref()) + }) +} + +/// Should `speak` say the sentence again with the default voice? When it gave the program a voice +/// and the program then exited with a failure: espeak-ng does that, silently, for a voice it +/// doesn't know, and a missing voice must never mean silence. `exited_ok` is `None` when the +/// program didn't exit on its own (stopped, or never started). +#[cfg(any(not(target_arch = "wasm32"), test))] +fn retry_without_voice(gave_voice: bool, exited_ok: Option) -> bool { + gave_voice && exited_ok == Some(false) +} + +/// The names `say` can voice, as `say -v '?'` lists them (asked once). +#[cfg(target_os = "macos")] +fn mac_voice_listing() -> &'static str { + static LISTING: std::sync::OnceLock = std::sync::OnceLock::new(); + LISTING.get_or_init(|| { + std::process::Command::new("say").args(["-v", "?"]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default() + }) +} + /// Sentence spans in `text` as char index ranges, without surrounding whitespace. fn split_sentences(text: &str) -> Vec<(usize, usize)> { let chars: Vec = text.chars().collect(); @@ -443,7 +688,7 @@ enum Spoken { fn worker(shared: &(Mutex, Condvar), backend: Backend) { let (m, cv) = shared; loop { - let (text, generation, rate) = { + let (text, lang, generation, rate) = { let mut s = lock(m); while s.state == State::Paused { s = cv.wait(s).unwrap_or_else(PoisonError::into_inner); @@ -453,7 +698,7 @@ fn worker(shared: &(Mutex, Condvar), backend: Backend) { return; } match s.queue.get(s.index) { - Some(u) => (u.text.clone(), s.generation, s.rate), + Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate), None => { s.state = State::Stopped; s.running = false; @@ -463,7 +708,7 @@ fn worker(shared: &(Mutex, Condvar), backend: Backend) { }; let changed = || lock(m).generation != generation; let outcome = match backend { - Backend::System => speak(&text, rate, &changed), + Backend::System => speak(&text, lang.as_deref(), rate, &changed), Backend::Instant => Spoken::Done, Backend::Hold => { let mut s = lock(m); @@ -521,11 +766,23 @@ struct WindowsSpeech { } #[cfg(any(target_os = "windows", test))] -fn windows_speech(system_root: Option, rate: f32) -> WindowsSpeech { +fn windows_speech(system_root: Option, rate: f32, lang: Option<&str>) -> WindowsSpeech { // SAPI rate: -10 (a third of normal speed) … 10 (three times). let r = ((rate.ln() / 3f32.ln()) * 10.0).round().clamp(-10.0, 10.0) as i64; + // The voice of the sentence's language, when one is installed (the default voice would spell + // out Cyrillic, Greek… letters one by one), but only when the user's default voice doesn't + // speak that language already, in any region. The tag is `primary_tag`-clean, so it can't end + // the quote. + let voice = lang.and_then(primary_tag).map_or_else(String::new, |p| { + format!( + "if ($s.Voice.Culture.TwoLetterISOLanguageName -ne '{p}') {{ $v = $s.GetInstalledVoices() | Where-Object {{ $_.Enabled -and $_.VoiceInfo.Culture.TwoLetterISOLanguageName -eq '{p}' }} | Select-Object -First 1; if ($v) {{ $s.SelectVoice($v.VoiceInfo.Name) }} }}; " + ) + }); + // The sentence arrives on stdin as UTF-8 bytes; `[Console]::In` would decode them with the + // console's code page (Cyrillic became `?` and the voice read single letters), so read the raw + // stream and decode it as UTF-8. The text itself is never part of the script. let script = format!( - "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = {r}; $s.Speak([Console]::In.ReadToEnd())" + "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = {r}; {voice}$m = New-Object System.IO.MemoryStream; [Console]::OpenStandardInput().CopyTo($m); $s.Speak([System.Text.Encoding]::UTF8.GetString($m.ToArray()))" ); WindowsSpeech { program: windows_powershell(system_root), @@ -536,50 +793,69 @@ fn windows_speech(system_root: Option, rate: f32) -> Windows } } -/// Speak one sentence with the system's speech program, stopping early when `changed()`. +/// Speak one sentence with the system's speech program, stopping early when `changed()`. When +/// the program fails with the sentence's voice, the default voice says it instead. #[cfg(not(target_arch = "wasm32"))] -fn speak(text: &str, rate: f32, changed: &dyn Fn() -> bool) -> Spoken { +fn speak(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Spoken { + let (spoken, gave_voice, exited_ok) = speak_with(text, lang, rate, changed); + if retry_without_voice(gave_voice, exited_ok) { + return speak_with(text, None, rate, changed).0; + } + spoken +} + +/// [`speak`] in `lang`'s voice once: how it ended, whether the program was given a voice, and +/// whether it exited successfully (`None` when it didn't exit on its own). +#[cfg(not(target_arch = "wasm32"))] +fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> (Spoken, bool, Option) { use std::io::Write; use std::process::{Command, Stdio}; let wpm = ((BASE_WPM * rate).round() as i64).to_string(); - // The text goes in on stdin so it can never be taken for an option. - let mut tries: Vec = Vec::new(); + // The text goes in on stdin so it can never be taken for an option. Each try is a program + // and whether it was given a voice. + let mut tries: Vec<(Command, bool)> = Vec::new(); #[cfg(target_os = "macos")] { let mut c = Command::new("say"); c.args(["-r", &wpm]); - tries.push(c); + let voice = lang.and_then(|l| mac_voice(l, mac_voice_listing(), mac_default_voice_language())); + if let Some(voice) = &voice { + c.args(["-v", voice.as_str()]); + } + tries.push((c, voice.is_some())); } #[cfg(target_os = "windows")] { - let w = windows_speech(std::env::var_os("SystemRoot"), rate); + let w = windows_speech(std::env::var_os("SystemRoot"), rate, lang); let mut c = Command::new(&w.program); c.args(&w.args); std::os::windows::process::CommandExt::creation_flags(&mut c, w.creation_flags); - tries.push(c); + tries.push((c, lang.and_then(primary_tag).is_some())); } #[cfg(all(unix, not(target_os = "macos")))] { for prog in ["espeak-ng", "espeak"] { + let args = espeak_args(&wpm, lang); + let voiced = args.iter().any(|a| a == "-v"); let mut c = Command::new(prog); - c.args(["-s", &wpm, "--stdin"]); - tries.push(c); + c.args(args); + tries.push((c, voiced)); } } - let _ = &wpm; + let _ = (&wpm, lang); let mut child = None; let mut last_err = String::from("no speech program on this system"); - for mut c in tries { + for (mut c, voiced) in tries { match c.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { Ok(ch) => { - child = Some(ch); + child = Some((ch, voiced)); break; } Err(e) => last_err = e.to_string(), } } - let Some(mut child) = child else { - return Spoken::Failed(format!("Read Aloud couldn't start the speech program: {last_err}")); + let Some((mut child, voiced)) = child else { + return (Spoken::Failed(format!("Read Aloud couldn't start the speech program: {last_err}")), false, None); }; if let Some(mut stdin) = child.stdin.take() { let _ = stdin.write_all(text.as_bytes()); @@ -587,14 +863,14 @@ fn speak(text: &str, rate: f32, changed: &dyn Fn() -> bool) -> Spoken { } loop { match child.try_wait() { - Ok(Some(_)) => return Spoken::Done, + Ok(Some(status)) => return (Spoken::Done, voiced, Some(status.success())), Ok(None) => {} - Err(e) => return Spoken::Failed(e.to_string()), + Err(e) => return (Spoken::Failed(e.to_string()), voiced, None), } if changed() { let _ = child.kill(); let _ = child.wait(); - return Spoken::Interrupted; + return (Spoken::Interrupted, voiced, None); } std::thread::sleep(std::time::Duration::from_millis(30)); } @@ -625,12 +901,12 @@ mod tests { // The flags `speak` passes to `creation_flags` (std can't read them back from the // `Command`): Win32's CREATE_NO_WINDOW (0x0800_0000), or every sentence flashes a // console (#1122). - let w = windows_speech(Some("D:\\Win".into()), 1.0); + let w = windows_speech(Some("D:\\Win".into()), 1.0, None); assert_eq!(w.creation_flags, 0x0800_0000); assert_eq!(w.program, windows_powershell(Some("D:\\Win".into()))); assert_eq!(w.args[..3], ["-NoProfile", "-NonInteractive", "-Command"]); assert!(w.args[3].contains("$s.Rate = 0;"), "{}", w.args[3]); - assert!(windows_speech(None, 3.0).args[3].contains("$s.Rate = 10;")); + assert!(windows_speech(None, 3.0, None).args[3].contains("$s.Rate = 10;")); } #[test] @@ -740,4 +1016,172 @@ mod tests { r.play_pause(); assert_eq!(r.state(), State::Stopped); } + #[test] + fn languages_are_detected_from_the_script() { + for (text, tag) in [ + ("Привет как твои дела?", "ru"), + ("Привіт, як справи? Їжак і ґанок", "uk"), + ("Γειά σου κόσμε", "el"), + ("مرحبا بالعالم", "ar"), + ("שלום עולם", "he"), + ("こんにちは、世界", "ja"), + ("你好,世界", "zh"), + ("안녕하세요 세계", "ko"), + ("สวัสดีชาวโลก", "th"), + ("Hello Привет мир как дела", "ru"), + ("Hello world and Привет", "ru"), + ] { + assert_eq!(detect_language(text), Some(tag), "{text}"); + } + // Latin text, nothing to read, or only punctuation: no guess. + for text in ["Hello, world", "", " ", "?!…", "123"] { + assert_eq!(detect_language(text), None, "{text:?}"); + } + } + + #[test] + fn an_explicit_language_wins_over_detection() { + // The document says so (a Russian run, even of Latin letters). + assert_eq!(speech_language("Privet", Some("ru-RU"), None).as_deref(), Some("ru-RU")); + assert_eq!(speech_language("Привет", Some("uk-UA"), None).as_deref(), Some("uk-UA")); + assert_eq!(speech_language("Hallo Welt", Some("de-DE"), None).as_deref(), Some("de-DE")); + // The default English tag on Cyrillic text (Word writes it on every run): the script wins. + assert_eq!(speech_language("Привет", Some("en-US"), None).as_deref(), Some("ru")); + // Nothing to go on: the default voice. + assert_eq!(speech_language("Hello", None, None), None); + assert_eq!(speech_language("Привет", None, None).as_deref(), Some("ru")); + // A hostile tag is never passed on. + assert_eq!(speech_language("Hello", Some("en'; calc; '"), None), None); + } + + #[test] + fn sentences_carry_the_run_language() { + let mut doc = Document::from_text("Привет как твои дела?\nPrivet."); + let s = sentences(&doc, &Pos::body(0, 0), None, true); + assert_eq!(s.iter().map(|u| u.lang.as_deref()).collect::>(), [Some("ru"), Some("en-US")]); + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + for r in &mut p.runs { + r.props.lang = Some("ru-RU".into()); + } + } + let s = sentences(&doc, &Pos::body(0, 0), None, true); + assert_eq!(s.iter().map(|u| u.lang.as_deref()).collect::>(), [Some("ru"), Some("ru-RU")]); + } + + #[test] + fn windows_script_reads_utf8_and_picks_the_voice() { + let w = windows_speech(None, 1.0, Some("ru")); + let script = &w.args[3]; + assert!(script.contains("UTF8"), "{script}"); + assert!(script.contains("GetInstalledVoices") && script.contains("SelectVoice"), "{script}"); + assert!(script.contains("'ru'"), "{script}"); + // The default voice when the language is unknown, and no voice code at all. + let d = windows_speech(None, 1.0, None); + assert!(d.args[3].contains("UTF8") && !d.args[3].contains("SelectVoice"), "{}", d.args[3]); + // The sentence is never in the script: it arrives on stdin. + assert!(!windows_speech(None, 1.0, Some("ru-RU")).args[3].contains("Привет")); + // A tag that isn't a plain language tag is dropped, not put in the script. + let h = windows_speech(None, 1.0, Some("ru'; Remove-Item x; '")); + assert!(!h.args[3].contains("Remove-Item"), "{}", h.args[3]); + } + + #[test] + fn espeak_gets_the_language() { + assert_eq!(espeak_args("180", Some("ru-RU")), ["-s", "180", "-v", "ru", "--stdin"]); + assert_eq!(espeak_args("180", Some("uk")), ["-s", "180", "-v", "uk", "--stdin"]); + assert_eq!(espeak_args("180", None), ["-s", "180", "--stdin"]); + assert_eq!(espeak_args("180", Some("--evil")), ["-s", "180", "--stdin"]); + } + + #[test] + fn english_text_keeps_the_default_voice() { + // Word writes `en-US` on almost every run: espeak's default voice is English already, so + // no `-v` (as before #1843), and Word's language-less `x-none` is no voice either. + assert_eq!(espeak_args("180", Some("en-US")), ["-s", "180", "--stdin"]); + assert_eq!(espeak_args("180", Some("en-GB")), ["-s", "180", "--stdin"]); + assert_eq!(espeak_args("180", Some("x-none")), ["-s", "180", "--stdin"]); + // Windows: the user's default voice stays when it already speaks the language, any region. + let script = &windows_speech(None, 1.0, Some("en-US")).args[3]; + let guard = script.find("if ($s.Voice.Culture.TwoLetterISOLanguageName -ne 'en')"); + let pick = script.find("GetInstalledVoices"); + assert!(guard.is_some() && pick.is_some() && guard < pick, "{script}"); + } + + #[test] + fn macos_keeps_the_system_voice_for_its_language() { + // Current macOS lists its novelty voices first, in alphabetical order. + let list = "Albert en_US # Hello! My name is Albert.\n\ + Bad News en_US # The light you see at the end of the tunnel.\n\ + Bahh en_US # Do not pull the wool over my eyes.\n\ + Samantha en_US # Hello! My name is Samantha.\n\ + Milena ru_RU # Здравствуйте!\n"; + // English text with an English system voice: no `-v`, so `say` keeps the user's voice. + assert_eq!(mac_voice("en-US", list, "en"), None); + assert_eq!(mac_voice("en", list, "en"), None); + // English text on a Russian system: an English voice, never a novelty one. + assert_eq!(mac_voice("en-US", list, "ru").as_deref(), Some("Samantha")); + assert_eq!(mac_voice("en-US", "Albert en_US # Hi\nBahh en_US # Hi\n", "ru"), None); + // The system voice's language: the chosen voice's, else the locale's, else English. + assert_eq!(mac_default_language(Some("Milena"), list, Some("en_US")), "ru"); + assert_eq!(mac_default_language(Some("Gone"), list, Some("de_DE")), "de"); + assert_eq!(mac_default_language(None, list, Some("ru_RU.UTF-8")), "ru"); + assert_eq!(mac_default_language(None, list, Some("C")), "en"); + assert_eq!(mac_default_language(None, "", None), "en"); + } + + #[test] + fn a_voice_the_program_lacks_falls_back_to_the_default() { + // espeak-ng exits with a failure, saying nothing, for a `-v` it doesn't know. + assert!(retry_without_voice(true, Some(false))); + // It spoke, it was given no voice, or it was stopped (or never exited on its own). + assert!(!retry_without_voice(true, Some(true))); + assert!(!retry_without_voice(false, Some(false))); + assert!(!retry_without_voice(true, None)); + } + + #[test] + fn right_to_left_text_uses_the_complex_script_tag() { + // Persian tagged `w:bidi="fa-IR"` on a `w:val="en-US"` run: Persian, not Arabic. + assert_eq!(speech_language("سلام دنیا", Some("en-US"), Some("fa-IR")).as_deref(), Some("fa-IR")); + assert_eq!(speech_language("שלום עולם", Some("en-US"), Some("he-IL")).as_deref(), Some("he-IL")); + // The complex-script tag is for complex-script text only. + assert_eq!(speech_language("Hello", Some("en-US"), Some("fa-IR")).as_deref(), Some("en-US")); + assert_eq!(speech_language("سلام", Some("en-US"), Some("he-IL")).as_deref(), Some("ar")); + } + + #[test] + fn mixed_sentences_follow_the_other_script() { + // More English letters than Cyrillic ones: the English voice would spell the Russian out. + assert_eq!(detect_language("Я открыл Microsoft Word and PowerPoint"), Some("ru")); + // A single foreign word in an English sentence isn't a real share: the default voice. + assert_eq!(detect_language("The word дом means house"), None); + // Ties between scripts always go the same way, the first script in the list. + assert_eq!(detect_language("αβ дг"), Some("ru")); + assert_eq!(detect_language("дг αβ"), Some("ru")); + } + + #[test] + fn han_letters_keep_a_japanese_or_korean_tag() { + assert_eq!(speech_language("東京都。", Some("ja-JP"), None).as_deref(), Some("ja-JP")); + assert_eq!(speech_language("東京都。", Some("ko-KR"), None).as_deref(), Some("ko-KR")); + assert_eq!(speech_language("東京都。", Some("en-US"), None).as_deref(), Some("zh")); + } + + #[test] + fn macos_voice_matches_the_language() { + let list = "Alex en_US # Most people recognize me by my voice.\n\ + Eddy (Russian (Russia)) ru_RU # Привет!\n\ + Milena ru_RU # Здравствуйте!\n\ + Samantha en_US # Hello!\n\ + Lesya uk_UA # Привіт!\n\ + Daniel en_GB # Hello!\n"; + assert_eq!(mac_voice("ru", list, "en").as_deref(), Some("Eddy (Russian (Russia))")); + assert_eq!(mac_voice("ru-RU", list, "en").as_deref(), Some("Eddy (Russian (Russia))")); + assert_eq!(mac_voice("uk", list, "en").as_deref(), Some("Lesya")); + assert_eq!(mac_voice("en-GB", list, "ru").as_deref(), Some("Daniel")); + // No voice for the language, or nothing listed: the default voice. + assert_eq!(mac_voice("el", list, "en"), None); + assert_eq!(mac_voice("ru", "", "en"), None); + assert_eq!(mac_voice("ru", "garbage line\n\n \n", "en"), None); + } } diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 02682e2a1..d2289fabf 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -194,6 +194,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| | 2026-10-16 | minor | Indonesian proofing (#498): id-ID spelling from the installed Hunspell `id_ID` dictionary (not bundled), Czech's dictionary-driven suggestions, repeated-word (hyphenated reduplication offered) and spacing checks with Indonesian messages; Review › Language, the status bar, Set Proofing Language and File › Options › Proofing list Indonesian; estimates unchanged | +| 2026-10-16 | trivial | Read Aloud picks a voice for the sentence's language (run tag, else script) when the default voice doesn't speak it, reads Windows text as UTF-8 | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | | 2026-10-13 | minor | Swedish interface catalog (2,504 entries), locale selection, saved preference, audit coverage and font checks (#1219) |