mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-02 16:59:16 +00:00
+5








462ecc8770
* fix(engine): do not treat decimal points as sentence boundaries in Sentence case In crates/engine/src/cmd/format.rs, convert_chars treated any period as a sentence boundary, causing numbers with decimal points (e.g. 3.14, 2.5) to capitalize the subsequent word in Sentence case. Check if a period is surrounded by ASCII digits, and do not trigger uppercase capitalization when encountering a decimal point. Closes #3485 * proof: drop U+FE0F from FORBIDDENWORD and NOSUGGEST flags too Review follow-up: the directives that name one flag read the same emoji form, so they now skip the variation selector like PFX/SFX and stem flags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fmt: rustfmt equation array centring * #3108 review: behind-text and in-front-of-text floats don't grow the row FredApps' review: the row-growth loop counted every non-inline object, so a page-anchored logo or watermark behind a cell's text would stretch its row to the object's bottom edge - contradicting table.rs (behind-text frames count for vertical alignment but don't increase the row's flow height) and Word. Only floats that take part in the text flow (Square, Tight, Through, TopAndBottom) grow the row now, the same classification layout already uses elsewhere for "takes part in wrapping". New test: a 300pt behind-text and a 300pt in-front-of-text picture each leave the row at its text height; it fails with the old condition. The existing Square-wrap tests and cell_frame_centres_empty still pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Engine: no dead-code warnings on wasm `cargo check -p wordcraft-engine --target wasm32-unknown-unknown` warned about five items whose only callers are platform-gated: - citations `by_author`, user_templates `MAX_ENTRIES` and `label_of`: used only by the desktop (non-wasm) source import and template listing. - speech `voice_language`: used by espeak (Unix but macOS) and the macOS voice picker, so `any(unix, test)`, not `any(not(windows), test)` (which also took in wasm). - speech `primary_tag`: used by those plus the Windows synthesizer, so `any(unix, windows, test)`. Each item now carries exactly its callers' gate rather than a blanket allow(dead_code). Checked with cargo check on Linux, Windows (gnu), macOS and wasm32 (zero warnings each), clippy -D warnings, and the engine tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Integration: #1847 reject comment IDs above u32::MAX * Integration: #3267 leave footnote definitions out of link definitions * Integration: #3471 drop duplicate egui options block; install_ui_fonts sets them * Integration: #2153 drop trailing blank line and use assert! for bool checks in tab_separated_list_markers * Integration: #2268 ODT lengths accept only the standard pc unit for picas * Integration: #2269 LaTeX lengths keep macro names case-sensitive * Integration: #2673 Khmer substitutes return the (list, flag) pair after #3245 * Integration: #2273 Latin weight/style and line styles take precedence over Asian, complex and line-type attributes * Integration: #1851 DOCPROPERTY accepts the reference app's LastSavedBy name * Integration: #1985 only ^0nnn codes read as Windows-1252 * Integration: #2416 collapse the nested vertical-align if (clippy::collapsible_if) * Integration: #2185 hyperlink_target slices with get() and needs the whole field name * Integration: #2034 objects without a stacking order draw above the ones before them * Integration: #3434 drop review-bot notes from test doc comments * Integration: #3419 doc comments back on their own items (EquationFrame and its test above them) * Integration: #2334 test a formula string result continued in a CONTINUE record * Integration: #3429 the based-on-chain test's doc comment back above its test * Integration: #2453 drawing_payload test builds the shape fill as a Fill * Integration: #3257 separate its Find-box test from #2407's recent-files test (auto-merge interleaved them) * Integration: cargo fmt * Integration: #1728 close the layout test the merge resolution left open * Integration: cargo fmt * Integration: cargo fmt * Integration: cargo fmt * Integration: #2722 Dutch registry comment on its own line * Integration: #3203 English text for the keys the Romanian catalog lacks Its complete-catalog test needs every key of every other catalog; the merged PRs added 72 since it was written. Rows carry the English text until translated. * Integration: #1076 English text for the keys the Slovenian catalog lacks Its complete-catalog test needs every key of every other catalog. Rows carry the English text until translated. * Integration: status lines list each change once Combining the tracks' status lines repeated a few entries (#2852, #1500, #2617). * Integration: cargo xtask parity * Integration: cargo fmt * Integration: #1832 drop a useless .into() in the end-of-document citation test clippy::useless_conversion failed cargo xtask ci. * Integration: #2244 VML on/off values read case-insensitively by the shape fill reader Main (#1500) moved VML fill reading into shape_style.rs, where filled="False" still counted as on; #2244's test vml_shape_wrap_side_and_anchor_case_insensitivity expects no fill. * Integration: #2041 English text in the Polish catalog for the keys Italian gained polish_covers_every_key_of_every_other_catalog expects Italian to have only "Style: APA" beyond Polish; #2041 translated 14 newer strings into Italian. Rows carry the English text until translated. * Integration: #2722 English text for four labels the Dutch and Danish catalogs lack every_tab_and_command_is_translated needs Format Shape Pane, Full Screen View, Personal Templates and Underline Color, added by other merged PRs. Rows carry the English text until translated. * Integration: #3335 header Esc test allows the half-pixel return scroll that #3201 brings #3201 makes entering an empty header scroll to it; Esc then scrolls back to the caret, landing 0.5 px from the start (17291.0 vs 17290.5). The test checks the view is back within a pixel. * IME: the caret follows the composition text (#3556) * Header and footer: a click in the other band edits it there (#3557) * README: the app icon above the title * Release 0.10.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * The inspector's removals are refused in every protected mode `file.inspect` with removals takes content out of the document — the comments, the revisions, the properties, the hidden text, the headers. Under readOnly and comments it was refused, or rolled back by the post-check where the document had an exception; under trackedChanges nothing stopped it, so a document that tracks changes could have its comments and revisions taken out with no revision to show for it. The removals are refused now, in every mode a protected document can be in, next to the properties and the privacy options. An inspection without removals only reads the document, and still works. The protection test covers the modes now, not only readOnly. Co-Authored-By: opencode <noreply@opencode.ai> * Docs: the session author, not the user name "the user name can't change while the document is protected" was the wrong term twice over: it is the session author — the name new tracked changes and comments are recorded under, and the one a named exception is matched against — and it is `file.setAuthor` that refuses to change it. The CLI's `--author` names the session directly, so it works on a protected document. The parity row, the two status lines and the two progress rows now say that. Co-Authored-By: opencode <noreply@opencode.ai> * #3279: Keep protected session author fixed in CLI and Options * The Document Inspector refuses its removals while the document tracks changes The inspector's removals delete straight from the document: the hidden text, the comments, the revisions and the headers go without ever reaching the engine's tracked-delete path. On a document that tracks changes — and `trackedChanges` protection turns tracking on — the content was gone with no revision to show for it, and the revision trail was broken behind the user's back. The removals are not tracked, so they cannot be: rather than untrack them silently, the inspector refuses the whole removal form while the document tracks changes, and says why ("the document tracks changes: stop tracking before removing content"). The check comes before anything is removed, so the document is left untouched, and reading the document — the inspector without removals — keeps working. A document that does not track changes is inspected as before. Co-Authored-By: opencode <noreply@opencode.ai> * The inspector's tracking refusal doesn't cite an unrelated issue #1344 is about the inspector missing hidden text, not about its removals going untracked, so the reference only sent a reader to the wrong place. Co-Authored-By: opencode <noreply@opencode.ai> * A version snapshot belongs to the document it was taken from `Session::set_document` resets everything about the open document — the document id, the document, the selection, the undo history, the password, the file name — but not the version snapshots. A snapshot taken of one document therefore survived `file.new`, `file.open`, `file.newFromTemplate` and `file.recover`, and `file.versions {"restore": index}` then put the previous document's content into the open one. The snapshots go with the document they were taken from: `set_document` clears them, so a restore can only ever bring back a version of the document that is open. Every caller replaces the document with another one (a new or opened file, a recovered copy, a merge result, a sheet of labels), so none of them loses anything it needs. Co-Authored-By: opencode <noreply@opencode.ai> * Docs: the inspector's tracking refusal and the version snapshot's scope Co-Authored-By: opencode <noreply@opencode.ai> * A comparison clears the version snapshots of the document it replaces `review.compare` and `review.combine` open the original with `s.doc = doc`, which bypasses `set_document` — so the version snapshots of the document the comparison replaces survived it, and a restore could put the previous document back over the comparison result. The snapshots go with the document they were taken from, as everywhere else. The document itself is still assigned directly: `set_document` resets the undo history, and the comparison's own undo step would go with it. Co-Authored-By: opencode <noreply@opencode.ai> * #3277: Test Inspector refusal without protection * #1327: Keep snapshots with their document through Compare * #1327: Preserve file identity after rejected document replacement * test: mark the portal test's blocking sleep as intentional (SonarCloud) (#3989) The future is polled by hand with no async runtime, so a blocking sleep is how the test models a slow dialog. 🤖 Generated with Codebuff Co-authored-by: Codebuff <noreply@codebuff.com> * #3542: gate the label_of test off wasm32 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Divyanshu Gupta <divyanshu2074@gmail.com> Co-authored-by: dmim2502-commits <234869083+dmim2502-commits@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: vangel76 <vangel1976@googlemail.com> Co-authored-by: aiongg <aiongtaigi@gmail.com> Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com> Co-authored-by: bflatastic <bombayapirat@gmail.com> Co-authored-by: Brandon Thomas <brandon@storyteller.ai> Co-authored-by: Mat Strange <mat@matstrange.net> Co-authored-by: opencode <noreply@opencode.ai> Co-authored-by: skylar taylor-barrick <hi@skylar.cc> Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com> Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
1189 lines
50 KiB
Rust
1189 lines
50 KiB
Rust
//! Read Aloud: the document as a queue of sentences that the system's speech program speaks one
|
||
//! at a time, so reading can be paused, skipped forwards and backwards, and sped up.
|
||
//!
|
||
//! Citation-manager fields can be left out: Zotero, Mendeley and EndNote citations and
|
||
//! bibliographies (range fields with an `ADDIN …` code) and Word's own `CITATION` and
|
||
//! `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
|
||
//! worker, which stops the running sentence when the state changed under it.
|
||
|
||
use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
|
||
|
||
use serde_json::{Value, json};
|
||
use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK};
|
||
use wordcraft_doc::{Document, InlineObject, Pos};
|
||
|
||
/// Longest sentence handed to the speech program (longer ones are split at a space).
|
||
const MAX_SENTENCE: usize = 600;
|
||
/// Most sentences in one queue.
|
||
const MAX_SENTENCES: usize = 20_000;
|
||
/// Speed limits (1.0 = the normal speed).
|
||
pub const MIN_RATE: f32 = 0.5;
|
||
pub const MAX_RATE: f32 = 3.0;
|
||
/// Words per minute at speed 1.0 (`say` and `espeak` both default to about this).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
const BASE_WPM: f32 = 180.0;
|
||
|
||
/// Words that end with a full stop without ending the sentence.
|
||
const ABBREVIATIONS: &[&str] = &[
|
||
"al", "e.g", "i.e", "eg", "ie", "cf", "vs", "etc", "fig", "figs", "eq", "eqs", "no", "nos", "p", "pp", "vol", "vols", "ch", "chap", "sec", "tab",
|
||
"ref", "refs", "dr", "mr", "mrs", "ms", "prof", "st", "approx", "ca", "resp", "ed", "eds", "suppl", "jr", "sr", "inc", "ltd", "co", "dept",
|
||
"est", "min", "max", "viz", "ibid",
|
||
];
|
||
|
||
/// Is this field code a citation or a bibliography (Zotero, Mendeley, EndNote, Word)?
|
||
pub fn is_citation_code(instr: &str) -> bool {
|
||
let code = instr.trim_start();
|
||
let word = code.split_whitespace().next().unwrap_or("").to_ascii_uppercase();
|
||
if word == "CITATION" || word == "BIBLIOGRAPHY" {
|
||
return true;
|
||
}
|
||
if word != "ADDIN" {
|
||
return false;
|
||
}
|
||
let rest = code.get(5..).unwrap_or("").trim_start().to_ascii_uppercase();
|
||
["ZOTERO_ITEM", "ZOTERO_BIBL", "CSL_CITATION", "CSL_BIBLIOGRAPHY", "MENDELEY", "EN.CITE", "EN.REFLIST"].iter().any(|k| rest.starts_with(k))
|
||
}
|
||
|
||
/// One sentence to speak: its text and where it is in the document.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
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 {
|
||
pub fn to_json(&self) -> Value {
|
||
json!({"text": self.text, "start": crate::cmd::pos_json(&self.start), "end": crate::cmd::pos_json(&self.end)})
|
||
}
|
||
}
|
||
|
||
/// The sentences from `from` (to `to`, if given) in `from`'s story, in reading order.
|
||
/// With `skip_citations`, citation and bibliography fields are left out.
|
||
pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: bool) -> Vec<Utterance> {
|
||
let story = from.story;
|
||
// Spans to leave out: whole range fields, both markers included.
|
||
let skipped: Vec<(Pos, Pos)> = if skip_citations {
|
||
doc.field_ranges(story).into_iter().filter(|r| is_citation_code(&r.instr)).map(|r| (r.start, r.end)).collect()
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
let in_skipped = |p: &Pos| skipped.iter().any(|(a, b)| a <= p && p <= b);
|
||
let mut out = Vec::new();
|
||
for path in doc.para_paths(story) {
|
||
if path < from.path {
|
||
continue;
|
||
}
|
||
if let Some(to) = to
|
||
&& path > to.path
|
||
{
|
||
break;
|
||
}
|
||
let Some(para) = doc.para(story, &path) else { continue };
|
||
// The spoken text, and for each char the paragraph offset it came from.
|
||
let mut text = String::new();
|
||
let mut map: Vec<(usize, usize)> = Vec::new(); // (start, end) offsets in the paragraph
|
||
let mut k = 0;
|
||
for (off, c) in para.text.char_indices() {
|
||
let end = off + c.len_utf8();
|
||
let obj = if c == OBJ {
|
||
k += 1;
|
||
para.objects.get(k - 1)
|
||
} else {
|
||
None
|
||
};
|
||
if (path == from.path && off < from.off) || to.is_some_and(|t| path == t.path && off >= t.off) {
|
||
continue;
|
||
}
|
||
let pos = Pos::new(story, path.clone(), off);
|
||
if in_skipped(&pos) {
|
||
continue;
|
||
}
|
||
match obj {
|
||
Some(InlineObject::Field { instr, .. }) if skip_citations && is_citation_code(instr) => {}
|
||
Some(o) => {
|
||
for ch in o.plain_text().chars() {
|
||
text.push(ch);
|
||
map.push((off, end));
|
||
}
|
||
}
|
||
None if c == OBJ => {}
|
||
None => {
|
||
let ch = if matches!(c, LINE_BREAK | PAGE_BREAK | COLUMN_BREAK | '\t') { ' ' } else { c };
|
||
text.push(ch);
|
||
map.push((off, end));
|
||
}
|
||
}
|
||
}
|
||
for (a, b) in split_sentences(&text) {
|
||
if out.len() >= MAX_SENTENCES {
|
||
return out;
|
||
}
|
||
let chars: Vec<char> = text.chars().skip(a).take(b - a).collect();
|
||
let spoken = tidy(&chars.iter().collect::<String>());
|
||
if !spoken.chars().any(char::is_alphanumeric) {
|
||
continue;
|
||
}
|
||
let (Some(first), Some(last)) = (map.get(a), map.get(b.saturating_sub(1))) else { continue };
|
||
// 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`.
|
||
#[cfg(any(unix, windows, test))]
|
||
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(unix, 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();
|
||
let mut out = Vec::new();
|
||
let mut start = 0;
|
||
let mut i = 0;
|
||
while i < chars.len() {
|
||
let c = chars.get(i).copied().unwrap_or(' ');
|
||
let mut end = None;
|
||
if matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?') {
|
||
// Include closing quotes and brackets.
|
||
let mut j = i + 1;
|
||
while chars.get(j).is_some_and(|c| matches!(c, '"' | '\'' | '”' | '’' | ')' | ']' | '»' | '.' | '!' | '?')) {
|
||
j += 1;
|
||
}
|
||
let at_end = j >= chars.len();
|
||
let space_after = chars.get(j).is_some_and(|c| c.is_whitespace());
|
||
if (at_end || space_after) && (c != '.' || !is_abbreviation(&chars, start, i)) {
|
||
end = Some(j);
|
||
}
|
||
i = j.max(i + 1);
|
||
} else {
|
||
i += 1;
|
||
}
|
||
// Very long sentences are split at a space so skipping stays fine-grained.
|
||
if end.is_none() && i - start >= MAX_SENTENCE && chars.get(i).is_some_and(|c| c.is_whitespace()) {
|
||
end = Some(i);
|
||
}
|
||
if let Some(e) = end {
|
||
push_trimmed(&chars, start, e, &mut out);
|
||
start = e;
|
||
}
|
||
}
|
||
push_trimmed(&chars, start, chars.len(), &mut out);
|
||
out
|
||
}
|
||
|
||
fn push_trimmed(chars: &[char], mut a: usize, mut b: usize, out: &mut Vec<(usize, usize)>) {
|
||
while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) {
|
||
a += 1;
|
||
}
|
||
while b > a && chars.get(b - 1).is_some_and(|c| c.is_whitespace()) {
|
||
b -= 1;
|
||
}
|
||
if a < b {
|
||
out.push((a, b));
|
||
}
|
||
}
|
||
|
||
/// Does the full stop at `dot` end an abbreviation ("et al.", "Fig.", "J. Smith") rather than a sentence?
|
||
fn is_abbreviation(chars: &[char], start: usize, dot: usize) -> bool {
|
||
let mut w = dot;
|
||
while w > start && chars.get(w - 1).is_some_and(|c| c.is_alphanumeric() || *c == '.') {
|
||
w -= 1;
|
||
}
|
||
let word: String = chars.get(w..dot).unwrap_or(&[]).iter().collect::<String>().to_lowercase();
|
||
if word.is_empty() {
|
||
return false;
|
||
}
|
||
// An initial: "J. Smith", "A. B. Jones".
|
||
let letters: Vec<char> = word.chars().filter(|c| *c != '.').collect();
|
||
if letters.len() == 1 && letters.first().is_some_and(|c| c.is_alphabetic()) {
|
||
return true;
|
||
}
|
||
ABBREVIATIONS.contains(&word.as_str())
|
||
}
|
||
|
||
/// Collapse whitespace and tidy what removed citations leave behind: "( )", "[]", " ." → ".".
|
||
fn tidy(s: &str) -> String {
|
||
let mut t: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
loop {
|
||
let before = t.len();
|
||
for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] {
|
||
t = t.replace(from, to);
|
||
}
|
||
if t.len() == before {
|
||
break;
|
||
}
|
||
}
|
||
t.trim().to_string()
|
||
}
|
||
|
||
/// Reading state.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum State {
|
||
Stopped,
|
||
Playing,
|
||
Paused,
|
||
}
|
||
|
||
impl State {
|
||
pub fn name(self) -> &'static str {
|
||
match self {
|
||
State::Stopped => "stopped",
|
||
State::Playing => "playing",
|
||
State::Paused => "paused",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// How sentences are spoken.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Backend {
|
||
/// The system's speech program.
|
||
System,
|
||
/// Silent; a sentence lasts until something interrupts it (tests, `WORDCRAFT_SPEECH=off`).
|
||
Hold,
|
||
/// Silent; every sentence finishes at once (tests).
|
||
Instant,
|
||
}
|
||
|
||
struct Shared {
|
||
queue: Vec<Utterance>,
|
||
index: usize,
|
||
state: State,
|
||
rate: f32,
|
||
/// Bumped on every change that should stop the sentence being spoken.
|
||
generation: u64,
|
||
error: Option<String>,
|
||
/// A worker thread is running (cleared under the lock as it exits).
|
||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||
running: bool,
|
||
}
|
||
|
||
/// The Read Aloud player.
|
||
pub struct ReadAloud {
|
||
shared: Arc<(Mutex<Shared>, Condvar)>,
|
||
pub backend: Backend,
|
||
/// Leave citations and bibliographies out of the next reading.
|
||
pub skip_citations: bool,
|
||
/// The player window is open (it stays open after the last sentence until closed).
|
||
pub open: bool,
|
||
}
|
||
|
||
impl Default for ReadAloud {
|
||
fn default() -> Self {
|
||
let off = cfg!(test) || std::env::var("WORDCRAFT_SPEECH").is_ok_and(|v| v == "off");
|
||
ReadAloud {
|
||
shared: Arc::new((
|
||
Mutex::new(Shared { queue: Vec::new(), index: 0, state: State::Stopped, rate: 1.0, generation: 0, error: None, running: false }),
|
||
Condvar::new(),
|
||
)),
|
||
backend: if off { Backend::Hold } else { Backend::System },
|
||
skip_citations: true,
|
||
open: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn lock(m: &Mutex<Shared>) -> MutexGuard<'_, Shared> {
|
||
m.lock().unwrap_or_else(PoisonError::into_inner)
|
||
}
|
||
|
||
impl ReadAloud {
|
||
/// Change the shared state, stop the current sentence and wake the worker.
|
||
fn change(&mut self, f: impl FnOnce(&mut Shared)) {
|
||
{
|
||
let (m, cv) = &*self.shared;
|
||
let mut s = lock(m);
|
||
f(&mut s);
|
||
s.generation = s.generation.wrapping_add(1);
|
||
cv.notify_all();
|
||
}
|
||
self.ensure_worker();
|
||
}
|
||
|
||
/// Start reading `queue` from its first sentence.
|
||
pub fn start(&mut self, queue: Vec<Utterance>) {
|
||
self.open = true;
|
||
self.change(|s| {
|
||
s.state = if queue.is_empty() { State::Stopped } else { State::Playing };
|
||
s.queue = queue;
|
||
s.index = 0;
|
||
s.error = None;
|
||
});
|
||
}
|
||
|
||
/// Pause, or carry on (a finished reading starts again from the top).
|
||
pub fn play_pause(&mut self) {
|
||
self.change(|s| {
|
||
s.state = match s.state {
|
||
State::Playing => State::Paused,
|
||
_ if s.queue.is_empty() => State::Stopped,
|
||
_ => {
|
||
if s.index >= s.queue.len() {
|
||
s.index = 0;
|
||
}
|
||
State::Playing
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
/// Move by `delta` sentences; a stopped player starts playing there.
|
||
pub fn skip(&mut self, delta: i64) {
|
||
self.change(|s| {
|
||
let last = s.queue.len().saturating_sub(1) as i64;
|
||
let at = (s.index.min(s.queue.len()) as i64).saturating_add(delta).clamp(0, last.max(0));
|
||
s.index = at as usize;
|
||
if s.state == State::Stopped && !s.queue.is_empty() {
|
||
s.state = State::Playing;
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Set the speed (1.0 = normal); the current sentence restarts at the new speed.
|
||
pub fn set_rate(&mut self, rate: f32) {
|
||
let r = if rate.is_finite() { rate.clamp(MIN_RATE, MAX_RATE) } else { 1.0 };
|
||
let playing = self.state() == State::Playing;
|
||
if playing {
|
||
self.change(|s| s.rate = r);
|
||
} else {
|
||
lock(&self.shared.0).rate = r;
|
||
}
|
||
}
|
||
|
||
/// Stop reading and close the player.
|
||
pub fn stop(&mut self) {
|
||
self.open = false;
|
||
self.change(|s| s.state = State::Stopped);
|
||
}
|
||
|
||
pub fn state(&self) -> State {
|
||
lock(&self.shared.0).state
|
||
}
|
||
pub fn rate(&self) -> f32 {
|
||
lock(&self.shared.0).rate
|
||
}
|
||
/// The sentence being read (or paused on).
|
||
pub fn current(&self) -> Option<Utterance> {
|
||
let s = lock(&self.shared.0);
|
||
if s.state == State::Stopped { None } else { s.queue.get(s.index).cloned() }
|
||
}
|
||
/// (index, number of sentences).
|
||
pub fn progress(&self) -> (usize, usize) {
|
||
let s = lock(&self.shared.0);
|
||
(s.index, s.queue.len())
|
||
}
|
||
/// The last speech error (no speech program…), if any.
|
||
pub fn error(&self) -> Option<String> {
|
||
lock(&self.shared.0).error.clone()
|
||
}
|
||
|
||
pub fn status(&self) -> Value {
|
||
let s = lock(&self.shared.0);
|
||
let current = if s.state == State::Stopped { None } else { s.queue.get(s.index) };
|
||
json!({
|
||
"state": s.state.name(),
|
||
"open": self.open,
|
||
"index": s.index,
|
||
"count": s.queue.len(),
|
||
"rate": s.rate,
|
||
"skipCitations": self.skip_citations,
|
||
"current": current.map(Utterance::to_json),
|
||
"error": s.error,
|
||
})
|
||
}
|
||
|
||
#[cfg(target_arch = "wasm32")]
|
||
fn ensure_worker(&mut self) {
|
||
let (m, _) = &*self.shared;
|
||
let mut s = lock(m);
|
||
if s.state == State::Playing && self.backend == Backend::System {
|
||
s.state = State::Stopped;
|
||
s.error = Some("Read Aloud isn't available in the browser".into());
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn ensure_worker(&mut self) {
|
||
let mut s = lock(&self.shared.0);
|
||
if s.state != State::Playing || s.running {
|
||
return;
|
||
}
|
||
s.running = true;
|
||
let shared = Arc::clone(&self.shared);
|
||
let backend = self.backend;
|
||
if let Err(e) = std::thread::Builder::new().name("read-aloud".into()).spawn(move || worker(&shared, backend)) {
|
||
s.running = false;
|
||
s.state = State::Stopped;
|
||
s.error = Some(format!("couldn't start Read Aloud: {e}"));
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for ReadAloud {
|
||
fn drop(&mut self) {
|
||
let (m, cv) = &*self.shared;
|
||
let mut s = lock(m);
|
||
s.state = State::Stopped;
|
||
s.generation = s.generation.wrapping_add(1);
|
||
cv.notify_all();
|
||
}
|
||
}
|
||
|
||
/// How a sentence ended.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
enum Spoken {
|
||
Done,
|
||
Interrupted,
|
||
Failed(String),
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
|
||
let (m, cv) = shared;
|
||
loop {
|
||
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);
|
||
}
|
||
if s.state == State::Stopped {
|
||
s.running = false;
|
||
return;
|
||
}
|
||
match s.queue.get(s.index) {
|
||
Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate),
|
||
None => {
|
||
s.state = State::Stopped;
|
||
s.running = false;
|
||
return;
|
||
}
|
||
}
|
||
};
|
||
let changed = || lock(m).generation != generation;
|
||
let outcome = match backend {
|
||
Backend::System => speak(&text, lang.as_deref(), rate, &changed),
|
||
Backend::Instant => Spoken::Done,
|
||
Backend::Hold => {
|
||
let mut s = lock(m);
|
||
while s.generation == generation {
|
||
s = cv.wait(s).unwrap_or_else(PoisonError::into_inner);
|
||
}
|
||
Spoken::Interrupted
|
||
}
|
||
};
|
||
let mut s = lock(m);
|
||
if s.generation != generation {
|
||
continue;
|
||
}
|
||
match outcome {
|
||
Spoken::Done => {
|
||
s.index += 1;
|
||
if s.index >= s.queue.len() {
|
||
s.state = State::Stopped;
|
||
s.running = false;
|
||
return;
|
||
}
|
||
}
|
||
Spoken::Interrupted => {}
|
||
Spoken::Failed(e) => {
|
||
s.state = State::Stopped;
|
||
s.error = Some(e);
|
||
s.running = false;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Windows PowerShell by its full path, `<SystemRoot>\System32\WindowsPowerShell\v1.0\powershell.exe`
|
||
/// (`C:\Windows` when `SystemRoot` is unset), rather than whichever `powershell` the program search
|
||
/// finds first.
|
||
#[cfg(any(target_os = "windows", test))]
|
||
fn windows_powershell(system_root: Option<std::ffi::OsString>) -> std::path::PathBuf {
|
||
let root = system_root.filter(|r| !r.is_empty()).unwrap_or_else(|| "C:\\Windows".into());
|
||
std::path::Path::new(&root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe")
|
||
}
|
||
|
||
/// Win32 `CREATE_NO_WINDOW` process creation flag: a console program starts without a console window.
|
||
#[cfg(any(target_os = "windows", test))]
|
||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||
|
||
/// How Read Aloud starts Windows PowerShell to speak at `rate`: the program, its arguments and
|
||
/// its process creation flags. `speak` builds its `Command` from exactly this; std can't read
|
||
/// creation flags back from a `Command`, so the tests check this value instead.
|
||
#[cfg(any(target_os = "windows", test))]
|
||
struct WindowsSpeech {
|
||
program: std::path::PathBuf,
|
||
args: [String; 4],
|
||
creation_flags: u32,
|
||
}
|
||
|
||
#[cfg(any(target_os = "windows", test))]
|
||
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}; {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),
|
||
args: ["-NoProfile".into(), "-NonInteractive".into(), "-Command".into(), script],
|
||
// PowerShell is a console program: without this, every sentence flashes a console window
|
||
// over the document (#1122).
|
||
creation_flags: CREATE_NO_WINDOW,
|
||
}
|
||
}
|
||
|
||
/// 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, 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. 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]);
|
||
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, 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, 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(args);
|
||
tries.push((c, voiced));
|
||
}
|
||
}
|
||
let _ = (&wpm, lang);
|
||
let mut child = None;
|
||
let mut last_err = String::from("no speech program on this system");
|
||
for (mut c, voiced) in tries {
|
||
match c.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() {
|
||
Ok(ch) => {
|
||
child = Some((ch, voiced));
|
||
break;
|
||
}
|
||
Err(e) => last_err = e.to_string(),
|
||
}
|
||
}
|
||
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());
|
||
// Dropping stdin closes it, which tells the program the text is complete.
|
||
}
|
||
loop {
|
||
match child.try_wait() {
|
||
Ok(Some(status)) => return (Spoken::Done, voiced, Some(status.success())),
|
||
Ok(None) => {}
|
||
Err(e) => return (Spoken::Failed(e.to_string()), voiced, None),
|
||
}
|
||
if changed() {
|
||
let _ = child.kill();
|
||
let _ = child.wait();
|
||
return (Spoken::Interrupted, voiced, None);
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn texts(v: &[Utterance]) -> Vec<&str> {
|
||
v.iter().map(|u| u.text.as_str()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn powershell_is_launched_from_the_system_folder() {
|
||
let tail = std::path::Path::new("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe");
|
||
let p = windows_powershell(Some("D:\\Win".into()));
|
||
assert!(p.starts_with("D:\\Win") && p.ends_with(&tail), "{}", p.display());
|
||
// No SystemRoot, or an empty one: the default Windows folder.
|
||
for root in [None, Some("".into())] {
|
||
let p = windows_powershell(root);
|
||
assert!(p.starts_with("C:\\Windows") && p.ends_with(&tail), "{}", p.display());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn powershell_starts_without_a_console_window() {
|
||
// 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, 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, None).args[3].contains("$s.Rate = 10;"));
|
||
}
|
||
|
||
#[test]
|
||
fn splits_sentences_but_not_abbreviations() {
|
||
let t = "Smith et al. found 3.5 times more (see Fig. 2). J. Smith agreed! Did they? Yes… Pick one, e.g. this one.";
|
||
let spans = split_sentences(t);
|
||
let got: Vec<String> = spans.iter().map(|(a, b)| t.chars().skip(*a).take(b - a).collect()).collect();
|
||
assert_eq!(got, ["Smith et al. found 3.5 times more (see Fig. 2).", "J. Smith agreed!", "Did they?", "Yes…", "Pick one, e.g. this one."]);
|
||
}
|
||
|
||
#[test]
|
||
fn long_sentences_are_split() {
|
||
let t = "word ".repeat(400);
|
||
let spans = split_sentences(&t);
|
||
assert!(spans.len() >= 3);
|
||
assert!(spans.iter().all(|(a, b)| b - a <= MAX_SENTENCE + 5));
|
||
}
|
||
|
||
#[test]
|
||
fn tidy_removes_citation_leftovers() {
|
||
assert_eq!(tidy("as shown ( ) ."), "as shown.");
|
||
assert_eq!(tidy("as shown [] , and"), "as shown, and");
|
||
}
|
||
|
||
#[test]
|
||
fn citation_codes() {
|
||
assert!(is_citation_code(" ADDIN ZOTERO_ITEM CSL_CITATION {}"));
|
||
assert!(is_citation_code("ADDIN ZOTERO_BIBL {\"uncited\":[]} CSL_BIBLIOGRAPHY"));
|
||
assert!(is_citation_code("ADDIN EN.CITE <EndNote>"));
|
||
assert!(is_citation_code("ADDIN CSL_CITATION {}"));
|
||
assert!(is_citation_code("CITATION Smi20 \\l 1033"));
|
||
assert!(is_citation_code("BIBLIOGRAPHY"));
|
||
assert!(!is_citation_code("ADDIN ZOTERO_PREF_1"));
|
||
assert!(!is_citation_code("PAGE"));
|
||
assert!(!is_citation_code(""));
|
||
assert!(!is_citation_code("ADD"));
|
||
}
|
||
|
||
#[test]
|
||
fn plain_document_sentences() {
|
||
let doc = Document::from_text("First one. Second one.\nThird paragraph");
|
||
let s = sentences(&doc, &Pos::body(0, 0), None, true);
|
||
assert_eq!(texts(&s), ["First one.", "Second one.", "Third paragraph"]);
|
||
assert_eq!(s[1].start, Pos::body(0, 11));
|
||
assert_eq!(s[1].end, Pos::body(0, 22));
|
||
// From the caret, and only up to the end of a selection.
|
||
let s = sentences(&doc, &Pos::body(0, 11), Some(&Pos::body(1, 5)), true);
|
||
assert_eq!(texts(&s), ["Second one.", "Third"]);
|
||
}
|
||
|
||
#[test]
|
||
fn player_moves_through_sentences() {
|
||
let doc = Document::from_text("One. Two. Three.");
|
||
let mut r = ReadAloud::default();
|
||
r.backend = Backend::Hold;
|
||
r.start(sentences(&doc, &Pos::body(0, 0), None, true));
|
||
assert_eq!(r.state(), State::Playing);
|
||
assert_eq!(r.current().map(|u| u.text), Some("One.".into()));
|
||
r.skip(1);
|
||
r.skip(5);
|
||
assert_eq!(r.progress(), (2, 3));
|
||
r.skip(-10);
|
||
assert_eq!(r.progress(), (0, 3));
|
||
r.play_pause();
|
||
assert_eq!(r.state(), State::Paused);
|
||
r.set_rate(99.0);
|
||
assert_eq!(r.rate(), MAX_RATE);
|
||
r.set_rate(f32::NAN);
|
||
assert_eq!(r.rate(), 1.0);
|
||
r.play_pause();
|
||
assert_eq!(r.state(), State::Playing);
|
||
r.stop();
|
||
assert_eq!(r.state(), State::Stopped);
|
||
assert!(!r.open);
|
||
assert!(r.current().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn player_finishes_on_its_own() {
|
||
let doc = Document::from_text("One. Two. Three.");
|
||
let mut r = ReadAloud::default();
|
||
r.backend = Backend::Instant;
|
||
r.start(sentences(&doc, &Pos::body(0, 0), None, true));
|
||
for _ in 0..200 {
|
||
if r.state() == State::Stopped {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
assert_eq!(r.state(), State::Stopped);
|
||
assert_eq!(r.progress(), (3, 3));
|
||
assert!(r.open, "the player stays open after the last sentence");
|
||
// Play again starts from the top.
|
||
r.backend = Backend::Hold;
|
||
r.play_pause();
|
||
assert_eq!(r.progress().0, 0);
|
||
assert_eq!(r.state(), State::Playing);
|
||
}
|
||
|
||
#[test]
|
||
fn empty_queue_does_not_play() {
|
||
let mut r = ReadAloud::default();
|
||
r.start(Vec::new());
|
||
assert_eq!(r.state(), State::Stopped);
|
||
r.skip(1);
|
||
r.skip(-1);
|
||
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);
|
||
}
|
||
}
|