#1843: Read Aloud speaks Russian text with a Russian voice (#2430)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-18 04:55:18 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5 Dmitrii Khlapov Brandon Thomas
parent 5a82eafa5a
commit 3a2cea6ca4
3 changed files with 474 additions and 27 deletions
+3 -1
View File
@@ -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 |
+469 -25
View File
@@ -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<String>,
}
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<String> {
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<String> {
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<String> {
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<String> {
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<Item = (String, String)> + '_ {
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<String> {
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<String> = 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>) -> 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<String> = 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<char> = text.chars().collect();
@@ -443,7 +688,7 @@ enum Spoken {
fn worker(shared: &(Mutex<Shared>, 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<Shared>, 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<Shared>, 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<std::ffi::OsString>, rate: f32) -> WindowsSpeech {
fn windows_speech(system_root: Option<std::ffi::OsString>, 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<std::ffi::OsString>, 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<bool>) {
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<Command> = 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::<Vec<_>>(), [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::<Vec<_>>(), [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);
}
}
+2 -1
View File
@@ -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) |