use super::*;
use crate::shared::i18n::Locale;
pub fn speakable_text(markdown: &str, loc: &'static Locale) -> String {
let normalized = normalize_delimiters(markdown);
let mut opts = Options::empty();
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
opts.insert(Options::ENABLE_MATH);
opts.insert(Options::ENABLE_TABLES);
let mut speaker = Speaker::new(loc);
for event in Parser::new_ext(&normalized, opts) {
speaker.handle(event);
}
speaker.finish()
}
struct Speaker {
loc: &'static Locale,
blocks: Vec<String>,
cur: String,
skip_code: bool,
skip_table: bool,
image_alt: Option<String>,
}
impl Speaker {
fn new(loc: &'static Locale) -> Self {
Self {
loc,
blocks: Vec::new(),
cur: String::new(),
skip_code: false,
skip_table: false,
image_alt: None,
}
}
fn handle(&mut self, event: Event<'_>) {
if self.skip_code {
if matches!(event, Event::End(TagEnd::CodeBlock)) {
self.skip_code = false;
}
return;
}
if self.skip_table {
if matches!(event, Event::End(TagEnd::Table)) {
self.skip_table = false;
}
return;
}
match event {
Event::Start(tag) => self.start(tag),
Event::End(tag) => self.end(tag),
Event::Text(text) => self.text(&text),
Event::Code(code) => self.push(&code),
Event::InlineMath(content) => {
if looks_like_price_fragment(&content) {
let literal = format!("${content}$");
self.push(&literal);
} else {
let converted = latex_to_unicode(&content);
self.push(&converted);
}
}
Event::DisplayMath(_) => self.note("speak.skip.formula"),
Event::SoftBreak => self.push(" "),
Event::HardBreak => self.flush(),
Event::InlineHtml(html) | Event::Html(html) if is_br(&html) => self.flush(),
Event::Rule => self.flush(),
_ => {}
}
}
fn start(&mut self, tag: Tag<'_>) {
match tag {
Tag::CodeBlock(kind) => {
let info = match kind {
CodeBlockKind::Fenced(ref lang) => lang.as_ref(),
CodeBlockKind::Indented => "",
};
let lang = info.split([',', ' ', '\t']).next().unwrap_or("");
let key = if lang.eq_ignore_ascii_case("mermaid") {
"speak.skip.mermaid"
} else {
"speak.skip.code"
};
self.note(key);
self.skip_code = true;
}
Tag::Table(_) => {
self.note("speak.skip.table");
self.skip_table = true;
}
Tag::Image { .. } => self.image_alt = Some(String::new()),
Tag::Paragraph
| Tag::Heading { .. }
| Tag::BlockQuote(_)
| Tag::List(_)
| Tag::Item => self.flush(),
_ => {}
}
}
fn end(&mut self, tag: TagEnd) {
match tag {
TagEnd::Image => {
let alt = self.image_alt.take().unwrap_or_default();
if alt.trim().is_empty() {
let note = self.loc.t("speak.skip.image").to_string();
self.push(¬e);
} else {
self.push(&alt);
}
}
TagEnd::Paragraph
| TagEnd::Heading(_)
| TagEnd::BlockQuote(_)
| TagEnd::List(_)
| TagEnd::Item => self.flush(),
_ => {}
}
}
fn text(&mut self, text: &str) {
if let Some(alt) = &mut self.image_alt {
alt.push_str(text);
return;
}
let spoken = rewrite_urls(text, self.loc);
self.push(&spoken);
}
fn push(&mut self, text: &str) {
self.cur.push_str(text);
}
fn flush(&mut self) {
let collapsed = collapse_ws(&self.cur);
self.cur.clear();
if collapsed.is_empty() {
return;
}
let ends_sentence = collapsed.ends_with(['.', '!', '?', ':', ';', '…']);
self.blocks.push(if ends_sentence {
collapsed
} else {
format!("{collapsed}.")
});
}
fn note(&mut self, key: &str) {
self.flush();
let note = self.loc.t(key).to_string();
self.push(¬e);
self.flush();
}
fn finish(mut self) -> String {
self.flush();
self.blocks.join("\n")
}
}
fn collapse_ws(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn rewrite_urls(text: &str, loc: &Locale) -> String {
if !text.contains("://") {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
for piece in text.split_inclusive(char::is_whitespace) {
let core = piece.trim_end();
out.push_str(&rewrite_token(core, loc));
out.push_str(&piece[core.len()..]);
}
out
}
fn rewrite_token(token: &str, loc: &Locale) -> String {
const LEAD: [char; 4] = ['(', '[', '«', '"'];
const TRAIL: [char; 9] = [')', ']', '»', '"', '.', ',', ';', '!', '?'];
let lead_len = token.len() - token.trim_start_matches(LEAD).len();
let (lead, rest) = token.split_at(lead_len);
let core_len = rest.trim_end_matches(TRAIL).len();
let (core, trail) = rest.split_at(core_len);
let lower = core.to_ascii_lowercase();
if !(lower.starts_with("http://") || lower.starts_with("https://")) {
return token.to_string();
}
let domain = domain_of(core);
let spoken = loc.tf("speak.link", &[("domain", &domain)]);
format!("{lead}{spoken}{trail}")
}
fn domain_of(url: &str) -> String {
let rest = url.split_once("://").map_or(url, |(_, r)| r);
let host = rest
.split(['/', '?', '#', ':'])
.next()
.unwrap_or(rest)
.trim_start_matches("www.");
host.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::i18n::{Lang, locale};
fn ru() -> &'static Locale {
locale(Lang::Ru)
}
fn speak(md: &str) -> String {
speakable_text(md, ru())
}
#[test]
fn golden_typical_llm_answer() {
let md = "\
# Отчёт
Сложность равна $O(n \\log n)$, см. `sort()`.
```rust
fn main() {}
```
```mermaid
flowchart LR
A --> B
```
| A | B |
| :--- | :--- |
| 1 | 2 |
$$E = mc^2$$
Подробности — [в документации](https://docs.example.com/guide) и на https://example.org/page.
- первый пункт
- второй пункт";
let got = speak(md);
let expected = "\
Отчёт.
Сложность равна O(n log n), см. sort().
(блок кода пропущен).
(диаграмма пропущена).
(таблица пропущена).
(формула пропущена).
Подробности — в документации и на ссылка: example.org.
первый пункт.
второй пункт.";
assert_eq!(got, expected);
}
#[test]
fn english_output_has_no_cyrillic() {
let md = "Text.\n\n```rust\nfn main() {}\n```\n\n| A |\n| - |\n| 1 |\n\n$$x$$\n\n\n\nhttps://example.com";
let en = speakable_text(md, locale(Lang::En));
let ru_out = speak(md);
assert_ne!(en, ru_out, "the notes aren't localized");
assert!(
!en.chars()
.any(|c| matches!(c, 'а'..='я' | 'А'..='Я' | 'ё' | 'Ё')),
"Cyrillic in the English output: {en}"
);
assert!(en.contains("(code block skipped)"), "{en}");
assert!(en.contains("(table skipped)"), "{en}");
assert!(en.contains("(formula skipped)"), "{en}");
assert!(en.contains("(image)"), "{en}");
assert!(en.contains("link: example.com"), "{en}");
}
#[test]
fn empty_input_gives_empty_output() {
assert_eq!(speak(""), "");
assert_eq!(speak(" \n\n\t "), "");
}
#[test]
fn only_code_block_gives_note() {
assert_eq!(
speak("```python\nprint('hi')\n```"),
"(блок кода пропущен)."
);
assert_eq!(speak(" let x = 1;"), "(блок кода пропущен).");
}
#[test]
fn unclosed_fence_is_skipped() {
let got = speak("Начало.\n\n```rust\nfn main() {\n");
assert_eq!(got, "Начало.\n(блок кода пропущен).");
}
#[test]
fn nested_lists_speak_item_by_item() {
let got = speak("- один\n - вложенный\n- два");
assert_eq!(got, "один.\nвложенный.\nдва.");
}
#[test]
fn autolink_and_bare_url_read_as_domain() {
assert_eq!(
speak("см. <https://www.example.com/a/b?q=1> тут"),
"см. ссылка: example.com тут."
);
assert_eq!(
speak("сайт https://example.org, потом текст"),
"сайт ссылка: example.org, потом текст."
);
}
#[test]
fn link_reads_text_without_url() {
assert_eq!(
speak("открой [документацию](https://docs.example.com)"),
"открой документацию."
);
}
#[test]
fn image_reads_alt_or_note() {
assert_eq!(speak(""), "схема сети.");
assert_eq!(speak(""), "(изображение).");
}
#[test]
fn inline_code_stays_in_sentence() {
assert_eq!(
speak("вызови `note_save` перед выходом"),
"вызови note_save перед выходом."
);
}
#[test]
fn headings_and_quotes_are_separate_phrases() {
assert_eq!(
speak("## Итог\n\n> цитата без точки\n\nконец!"),
"Итог.\nцитата без точки.\nконец!"
);
}
#[test]
fn inline_math_and_price_range() {
assert_eq!(speak("формула $x^2$ тут"), "формула x² тут.");
assert_eq!(speak("товар $5-$10 сегодня"), "товар $5-$10 сегодня.");
}
#[test]
fn emoji_pass_through() {
assert_eq!(speak("готово 🎉"), "готово 🎉.");
}
#[test]
fn soft_breaks_collapse_to_single_spaces() {
assert_eq!(
speak("первая\nвторая\n\n\nтретья"),
"первая вторая.\nтретья."
);
}
#[test]
fn domain_extraction() {
assert_eq!(domain_of("https://example.com/a?b=1#c"), "example.com");
assert_eq!(domain_of("http://www.example.com:8080/x"), "example.com");
}
}