Skip to main content

setzer_import/scrub/
markdown.rs

1//! The scrambled copy of a Markdown manuscript.
2//!
3//! Only prose changes. Kept as written: the front matter's keys and its
4//! language, `:::` fence lines, `\newpage`, the `Table:` of a caption line,
5//! footnote labels (`[^1]`), link and image targets (the path, not the title
6//! or caption), autolinks (`<https://…>`), Pandoc attributes (`{-}`,
7//! `{.smallcaps}`, `{width=50%}`) and, as everywhere, every non-letter: `#`,
8//! `>`, list markers, emphasis marks, table pipes, line endings and byte
9//! order mark.
10//!
11//! The front matter's extent comes from the importer's own reader, so the
12//! two cannot disagree about where the book starts.
13
14use super::words::{Context, LANG_BUDGET, Scrambler, seed_of};
15use super::{ScrubError, image};
16use crate::markdown::front_matter;
17
18/// A scrambled Markdown manuscript and the blank pictures it refers to.
19#[derive(Debug, Clone, PartialEq)]
20pub struct ScrubbedMarkdown {
21    pub text: String,
22    /// `(path as written in the manuscript, blank picture)` for each
23    /// picture the importer asked for and the caller had. The copy refers
24    /// to these paths; a writer puts them next to the copy (and must not
25    /// follow a path out of the folder: `..`, an absolute path).
26    pub images: Vec<(String, Vec<u8>)>,
27}
28
29pub(super) fn scrub(
30    bytes: &[u8],
31    mut pictures: impl FnMut(&str) -> Option<Vec<u8>>,
32) -> Result<ScrubbedMarkdown, ScrubError> {
33    let text = std::str::from_utf8(bytes).map_err(|_| ScrubError::NotUtf8)?;
34    // Windows editors start UTF-8 files with a byte order mark; the
35    // importer reads past it, and the copy keeps it.
36    let (bom, src) = match text.strip_prefix('\u{feff}') {
37        Some(rest) => ("\u{feff}", rest),
38        None => ("", text),
39    };
40    let comments_out = crate::markdown::strip_comments(src);
41    let lines: Vec<&str> = comments_out.lines().collect();
42    let pieces: Vec<&str> = src.split_inclusive('\n').collect();
43    // A comment alone on the last line, with no line end after it, leaves
44    // nothing there once stripped: one piece more than lines, and that
45    // piece is the comment.
46    let comment_tail =
47        pieces.len() == lines.len() + 1 && comments_out.ends_with('\n') && !src.ends_with('\n');
48    if pieces.len() != lines.len() && !comment_tail {
49        return Err(ScrubError::Part {
50            part: "the text".into(),
51            why: "its lines could not be told apart".into(),
52        });
53    }
54    let (front_end, lang_given) = if comments_out.trim().is_empty() {
55        (0, false)
56    } else {
57        let front = front_matter::read(&lines);
58        (front.end, front.lang_given)
59    };
60    let units = units(&pieces, front_end);
61
62    let sc = Scrambler::new(seed_of(bytes));
63    let mut out = String::with_capacity(text.len());
64    out.push_str(bom);
65    // The importer counts the function words of the body's first words to
66    // tell the language, unless the front matter says it.
67    sc.set_lang_budget(0);
68    for (i, piece) in pieces.iter().enumerate() {
69        let body = piece.strip_suffix('\n').unwrap_or(piece);
70        let ending = &piece[body.len()..];
71        let new = if i < front_end {
72            front_matter_line(&sc, body)
73        } else {
74            if i == front_end {
75                sc.set_lang_budget(if lang_given { 0 } else { LANG_BUDGET });
76            }
77            body_line(&sc, body, units[i])
78        };
79        out.push_str(&new);
80        out.push_str(ending);
81    }
82
83    // The pictures: ask the way the importer does, so the copy has
84    // exactly the pictures the original's import found.
85    let mut images = Vec::new();
86    let mut failed = None;
87    let _ = crate::markdown::import(src, |path| {
88        let bytes = pictures(path)?;
89        match image::blank(&bytes) {
90            Ok(blank) => images.push((path.to_string(), blank)),
91            Err(why) => {
92                failed.get_or_insert((path.to_string(), why));
93            }
94        }
95        Some(bytes)
96    });
97    if let Some((name, why)) = failed {
98        return Err(ScrubError::Picture { name, why });
99    }
100    Ok(ScrubbedMarkdown { text: out, images })
101}
102
103/// A front-matter line: the key stays, the value is prose (except the
104/// language). A comment, a list item or a continuation is prose whole.
105fn front_matter_line(sc: &Scrambler, line: &str) -> String {
106    let trimmed = line.trim();
107    if trimmed.is_empty() || trimmed == "---" || trimmed == "..." {
108        return line.to_string();
109    }
110    let indented = line.starts_with([' ', '\t']);
111    if !indented
112        && !trimmed.starts_with(['#', '-'])
113        && let Some((key, value)) = line.split_once(':')
114    {
115        if front_matter::is_lang_key(key.trim().trim_matches(['"', '\''])) {
116            // The tag stays; a YAML comment after it is prose.
117            return match value.find(" #") {
118                Some(at) => format!(
119                    "{key}:{}{}",
120                    &value[..at],
121                    scramble(sc, &value[at..], false)
122                ),
123                None => line.to_string(),
124            };
125        }
126        return format!("{key}:{}", scramble(sc, value, false));
127    }
128    scramble(sc, line, false)
129}
130
131/// Where a line of the book stands in its paragraph.
132#[derive(Debug, Clone, Copy, Default)]
133struct Unit {
134    /// The line begins a paragraph, a heading, a fence or a list item.
135    start: bool,
136    /// How many words the paragraph has, over all its source lines.
137    words: usize,
138}
139
140/// The paragraphs of the book, line by line (index = line; the front
141/// matter's lines have none). A hard-wrapped paragraph is one paragraph:
142/// the importer reads it whole, so a source line is no unit to judge
143/// "short" by. A heading, a fence line and a list item begin a paragraph
144/// of their own; a line right after a heading, a fence or the front matter
145/// begins one too, blank line or not.
146fn units(pieces: &[&str], front_end: usize) -> Vec<Unit> {
147    let n = pieces.len();
148    let mut first_of: Vec<Option<usize>> = vec![None; n];
149    let mut total = vec![0usize; n];
150    let mut open: Option<usize> = None; // the paragraph the next line may join
151    for i in front_end..n {
152        let t = pieces[i].trim();
153        if t.is_empty() {
154            open = None;
155            continue;
156        }
157        let alone = is_alone(t);
158        let first = match open {
159            Some(f) if !alone && !is_list_item(t) => f,
160            _ => i,
161        };
162        first_of[i] = Some(first);
163        total[first] += t
164            .split(|c: char| !c.is_alphabetic())
165            .filter(|w| !w.is_empty())
166            .count();
167        open = (!alone).then_some(first);
168    }
169    (0..n)
170        .map(|i| match first_of[i] {
171            Some(f) => Unit {
172                start: f == i,
173                words: total[f],
174            },
175            None => Unit::default(),
176        })
177        .collect()
178}
179
180/// A line that is a paragraph of its own: a heading, a fence, a page break.
181fn is_alone(t: &str) -> bool {
182    t.starts_with('#') || t.starts_with(":::") || matches!(t, "\\newpage" | "\\pagebreak")
183}
184
185/// `- item`, `* item`, `+ item`, `3. item`, `3) item`.
186fn is_list_item(t: &str) -> bool {
187    let after_marker = |rest: &str| rest.starts_with([' ', '\t']);
188    if let Some(rest) = t.strip_prefix(['-', '*', '+']) {
189        return after_marker(rest);
190    }
191    let digits = t.chars().take_while(char::is_ascii_digit).count();
192    digits > 0
193        && t[digits..]
194            .strip_prefix(['.', ')'])
195            .is_some_and(after_marker)
196}
197
198/// A line of the book.
199fn body_line(sc: &Scrambler, line: &str, unit: Unit) -> String {
200    let trimmed = line.trim();
201    // `:::verse`, `:::`; `\newpage`, `\pagebreak`.
202    if trimmed.starts_with(":::") || matches!(trimmed, "\\newpage" | "\\pagebreak") {
203        return line.to_string();
204    }
205    let chars: Vec<char> = line.chars().collect();
206    let mut protect = protected(&chars);
207    keep_caption_marker(&chars, &mut protect);
208    let cx = Context {
209        para_start: unit.start,
210        continuation: !unit.start,
211        words: Some(unit.words),
212    };
213    sc.scramble_in(&chars, &protect, cx).into_iter().collect()
214}
215
216/// The `Table:` that starts a Pandoc caption line (`Table: text`) is a word
217/// the importer reads, so it stays.
218fn keep_caption_marker(line: &[char], keep: &mut [bool]) {
219    const MARKER: [char; 6] = ['T', 'a', 'b', 'l', 'e', ':'];
220    let Some(start) = line.iter().position(|c| !c.is_whitespace()) else {
221        return;
222    };
223    let after = start + MARKER.len();
224    if line[start..].starts_with(&MARKER)
225        && line.get(after).is_some_and(|c| matches!(c, ' ' | '\t'))
226    {
227        keep[start..after].fill(true);
228    }
229}
230
231fn scramble(sc: &Scrambler, text: &str, para_start: bool) -> String {
232    let chars: Vec<char> = text.chars().collect();
233    let protect = vec![false; chars.len()];
234    sc.scramble(&chars, &protect, para_start)
235        .into_iter()
236        .collect()
237}
238
239/// Which chars of a line are syntax or addresses, not prose.
240fn protected(line: &[char]) -> Vec<bool> {
241    let n = line.len();
242    let mut keep = vec![false; n];
243    let find = |from: usize, c: char| {
244        line[from.min(n)..]
245            .iter()
246            .position(|&x| x == c)
247            .map(|p| from + p)
248    };
249    let mut i = 0;
250    while i < n {
251        match line[i] {
252            // [^label]
253            '[' if line.get(i + 1) == Some(&'^') => {
254                if let Some(j) = find(i + 2, ']') {
255                    keep[i..=j].fill(true);
256                    i = j + 1;
257                    continue;
258                }
259            }
260            // ](target "title"): the target, not the title
261            ']' if line.get(i + 1) == Some(&'(') => {
262                let end = target_end(line, i + 2);
263                keep[i + 2..end].fill(true);
264                i = end;
265                continue;
266            }
267            // <https://…>, <mailto:…>; not a comment
268            '<' => {
269                if let Some(j) = find(i + 1, '>') {
270                    let inner = &line[i + 1..j];
271                    if !inner.iter().any(|c| c.is_whitespace())
272                        && inner.contains(&':')
273                        && inner.first() != Some(&'!')
274                    {
275                        keep[i..=j].fill(true);
276                        i = j + 1;
277                        continue;
278                    }
279                }
280            }
281            // {-}, {.smallcaps}, {#id}, {width=50%}
282            '{' => {
283                if let Some(j) = find(i + 1, '}') {
284                    let inner = &line[i + 1..j];
285                    if inner.first().is_some_and(|c| matches!(c, '.' | '#' | '-'))
286                        || inner.contains(&'=')
287                    {
288                        keep[i..=j].fill(true);
289                        i = j + 1;
290                        continue;
291                    }
292                }
293            }
294            _ => {}
295        }
296        i += 1;
297    }
298    keep
299}
300
301/// Where the target of a link or image that starts at `start` ends: at the
302/// `>` of `<…>`, before a quoted title, or at the closing parenthesis.
303/// Paths may contain spaces and parentheses.
304fn target_end(line: &[char], start: usize) -> usize {
305    if line.get(start) == Some(&'<')
306        && let Some(p) = line[start..].iter().position(|&c| c == '>')
307    {
308        return start + p + 1;
309    }
310    let mut depth = 0usize;
311    for (k, &c) in line.iter().enumerate().skip(start) {
312        match c {
313            '(' => depth += 1,
314            ')' if depth == 0 => return k,
315            ')' => depth -= 1,
316            '"' if k > start && matches!(line[k - 1], ' ' | '\t') => return k - 1,
317            _ => {}
318        }
319    }
320    line.len()
321}
322
323#[cfg(test)]
324#[path = "markdown_tests.rs"]
325mod tests;