Skip to main content

kernel/artifacts/
speech_text.rs

1//! Turning rendered markdown into speakable plain text: strip the structure a
2//! reader shouldn't hear (fences, tables, headings, list markers, links, and
3//! inline emphasis) and leave the words.
4
5use std::sync::LazyLock;
6
7use regex::Regex;
8
9/// The inline markdown transforms, applied in order to each surviving line.
10///
11/// Each entry is a compiled pattern and the replacement it substitutes (`$1`
12/// keeps the first capture). Compiled once.
13static INLINE: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
14    [
15        (r"^\s{0,3}#{1,6}\s+", ""),
16        (r"^\s{0,3}>\s?", ""),
17        (r"^(\s*)[-*+]\s+", "$1"),
18        (r"!?\[([^\]]*)\]\([^)]*\)", "$1"),
19        (r"\*{1,3}([^*]+)\*{1,3}", "$1"),
20        (r"_{1,3}([^_]+)_{1,3}", "$1"),
21        (r"`([^`]*)`", "$1"),
22    ]
23    .into_iter()
24    .filter_map(|(pattern, replacement)| Regex::new(pattern).ok().map(|re| (re, replacement)))
25    .collect()
26});
27
28/// Collapses three or more consecutive newlines down to a paragraph break.
29static BLANK_RUN: LazyLock<Option<Regex>> = LazyLock::new(|| Regex::new(r"\n{3,}").ok());
30
31/// The plain, speakable form of a markdown string: code fences and table rows
32/// are dropped whole, and every other line has its markdown syntax stripped so
33/// only the words a listener should hear remain.
34pub fn speakable(markdown: &str) -> String {
35    let mut lines: Vec<String> = Vec::new();
36    let mut inside_fence = false;
37    for line in markdown.split('\n') {
38        let trimmed = trim_spaces(line);
39        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
40            inside_fence = !inside_fence;
41            continue;
42        }
43        if inside_fence {
44            continue;
45        }
46        if trimmed.starts_with('|') && trimmed.ends_with('|') {
47            continue;
48        }
49        lines.push(strip_inline(line));
50    }
51    let joined = lines.join("\n");
52    let collapsed = match &*BLANK_RUN {
53        Some(re) => re.replace_all(&joined, "\n\n").into_owned(),
54        None => joined,
55    };
56    collapsed.trim().to_owned()
57}
58
59fn strip_inline(line: &str) -> String {
60    let mut text = line.to_owned();
61    for (pattern, replacement) in INLINE.iter() {
62        text = pattern.replace_all(&text, *replacement).into_owned();
63    }
64    text
65}
66
67/// Trims horizontal whitespace — tab plus every Unicode space separator (Zs) —
68/// but never line breaks, so that a fence or table marker led by a non-breaking
69/// space is still detected.
70fn trim_spaces(line: &str) -> &str {
71    line.trim_matches(is_horizontal_ws)
72}
73
74/// True for horizontal whitespace: tab or a Zs space separator. `char::is_whitespace`
75/// (the Unicode White_Space set) adds line breaks and the VT/FF/NEL controls,
76/// which are excluded here — so those are filtered back out.
77fn is_horizontal_ws(c: char) -> bool {
78    c.is_whitespace()
79        && !matches!(
80            c,
81            '\n' | '\r' | '\u{0B}' | '\u{0C}' | '\u{85}' | '\u{2028}' | '\u{2029}'
82        )
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn a_code_fence_and_its_body_are_dropped() {
91        let markdown = "before\n```rust\nlet x = 1;\n```\nafter";
92        assert_eq!(speakable(markdown), "before\nafter");
93    }
94
95    #[test]
96    fn a_tilde_fence_toggles_the_same_way() {
97        let markdown = "a\n~~~\nhidden\n~~~\nb";
98        assert_eq!(speakable(markdown), "a\nb");
99    }
100
101    #[test]
102    fn table_rows_are_skipped() {
103        let markdown = "text\n| col | col |\n| --- | --- |\nmore";
104        assert_eq!(speakable(markdown), "text\nmore");
105    }
106
107    #[test]
108    fn headings_lose_their_hashes() {
109        assert_eq!(speakable("## Title"), "Title");
110        assert_eq!(speakable("   ###### Deep"), "Deep");
111    }
112
113    #[test]
114    fn blockquotes_lose_their_marker() {
115        assert_eq!(speakable("> quoted"), "quoted");
116        assert_eq!(speakable(">no space"), "no space");
117    }
118
119    #[test]
120    fn list_markers_go_but_indentation_stays() {
121        assert_eq!(speakable("- item"), "item");
122        assert_eq!(speakable("+ plus"), "plus");
123        // The whole-output trim only touches the document edges, so a nested
124        // item in the middle keeps its indentation.
125        assert_eq!(speakable("top\n  * nested\nend"), "top\n  nested\nend");
126    }
127
128    #[test]
129    fn links_and_images_keep_only_their_label() {
130        assert_eq!(speakable("see [the docs](https://x.y)"), "see the docs");
131        assert_eq!(speakable("![alt text](img.png)"), "alt text");
132    }
133
134    #[test]
135    fn emphasis_and_inline_code_are_unwrapped() {
136        assert_eq!(speakable("**bold** and *italic*"), "bold and italic");
137        assert_eq!(speakable("_under_ and __strong__"), "under and strong");
138        assert_eq!(speakable("run `cargo test` now"), "run cargo test now");
139    }
140
141    #[test]
142    fn runs_of_blank_lines_collapse_to_one_paragraph_break() {
143        let markdown = "a\n\n\n\nb";
144        assert_eq!(speakable(markdown), "a\n\nb");
145    }
146
147    #[test]
148    fn leading_and_trailing_whitespace_is_trimmed() {
149        assert_eq!(speakable("\n\n  hello  \n\n"), "hello");
150    }
151
152    #[test]
153    fn an_empty_string_stays_empty() {
154        assert_eq!(speakable(""), "");
155    }
156
157    #[test]
158    fn a_fence_led_by_a_non_breaking_space_is_still_a_fence() {
159        // A non-breaking space (Zs) before the fence must still be trimmed for
160        // detection, or the whole code block would leak into the spoken text.
161        let markdown = "\u{A0}```\ncode\n```";
162        assert_eq!(speakable(markdown), "");
163    }
164
165    #[test]
166    fn a_table_row_with_a_trailing_nbsp_is_still_skipped() {
167        let markdown = "text\n| a | b |\u{A0}\nmore";
168        assert_eq!(speakable(markdown), "text\nmore");
169    }
170}