1use super::words::{Context, LANG_BUDGET, Scrambler, seed_of};
15use super::{ScrubError, image};
16use crate::markdown::front_matter;
17
18#[derive(Debug, Clone, PartialEq)]
20pub struct ScrubbedMarkdown {
21 pub text: String,
22 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 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 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 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 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
103fn 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 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#[derive(Debug, Clone, Copy, Default)]
133struct Unit {
134 start: bool,
136 words: usize,
138}
139
140fn 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; 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
180fn is_alone(t: &str) -> bool {
182 t.starts_with('#') || t.starts_with(":::") || matches!(t, "\\newpage" | "\\pagebreak")
183}
184
185fn 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
198fn body_line(sc: &Scrambler, line: &str, unit: Unit) -> String {
200 let trimmed = line.trim();
201 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
216fn 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
239fn 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 '[' 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 ']' 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 '<' => {
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 '{' => {
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
301fn 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;