use super::words::{Context, LANG_BUDGET, Scrambler, seed_of};
use super::{ScrubError, image};
use crate::markdown::front_matter;
#[derive(Debug, Clone, PartialEq)]
pub struct ScrubbedMarkdown {
pub text: String,
pub images: Vec<(String, Vec<u8>)>,
}
pub(super) fn scrub(
bytes: &[u8],
mut pictures: impl FnMut(&str) -> Option<Vec<u8>>,
) -> Result<ScrubbedMarkdown, ScrubError> {
let text = std::str::from_utf8(bytes).map_err(|_| ScrubError::NotUtf8)?;
let (bom, src) = match text.strip_prefix('\u{feff}') {
Some(rest) => ("\u{feff}", rest),
None => ("", text),
};
let comments_out = crate::markdown::strip_comments(src);
let lines: Vec<&str> = comments_out.lines().collect();
let pieces: Vec<&str> = src.split_inclusive('\n').collect();
let comment_tail =
pieces.len() == lines.len() + 1 && comments_out.ends_with('\n') && !src.ends_with('\n');
if pieces.len() != lines.len() && !comment_tail {
return Err(ScrubError::Part {
part: "the text".into(),
why: "its lines could not be told apart".into(),
});
}
let (front_end, lang_given) = if comments_out.trim().is_empty() {
(0, false)
} else {
let front = front_matter::read(&lines);
(front.end, front.lang_given)
};
let units = units(&pieces, front_end);
let sc = Scrambler::new(seed_of(bytes));
let mut out = String::with_capacity(text.len());
out.push_str(bom);
sc.set_lang_budget(0);
for (i, piece) in pieces.iter().enumerate() {
let body = piece.strip_suffix('\n').unwrap_or(piece);
let ending = &piece[body.len()..];
let new = if i < front_end {
front_matter_line(&sc, body)
} else {
if i == front_end {
sc.set_lang_budget(if lang_given { 0 } else { LANG_BUDGET });
}
body_line(&sc, body, units[i])
};
out.push_str(&new);
out.push_str(ending);
}
let mut images = Vec::new();
let mut failed = None;
let _ = crate::markdown::import(src, |path| {
let bytes = pictures(path)?;
match image::blank(&bytes) {
Ok(blank) => images.push((path.to_string(), blank)),
Err(why) => {
failed.get_or_insert((path.to_string(), why));
}
}
Some(bytes)
});
if let Some((name, why)) = failed {
return Err(ScrubError::Picture { name, why });
}
Ok(ScrubbedMarkdown { text: out, images })
}
fn front_matter_line(sc: &Scrambler, line: &str) -> String {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed == "---" || trimmed == "..." {
return line.to_string();
}
let indented = line.starts_with([' ', '\t']);
if !indented
&& !trimmed.starts_with(['#', '-'])
&& let Some((key, value)) = line.split_once(':')
{
if front_matter::is_lang_key(key.trim().trim_matches(['"', '\''])) {
return match value.find(" #") {
Some(at) => format!(
"{key}:{}{}",
&value[..at],
scramble(sc, &value[at..], false)
),
None => line.to_string(),
};
}
return format!("{key}:{}", scramble(sc, value, false));
}
scramble(sc, line, false)
}
#[derive(Debug, Clone, Copy, Default)]
struct Unit {
start: bool,
words: usize,
}
fn units(pieces: &[&str], front_end: usize) -> Vec<Unit> {
let n = pieces.len();
let mut first_of: Vec<Option<usize>> = vec![None; n];
let mut total = vec![0usize; n];
let mut open: Option<usize> = None; for i in front_end..n {
let t = pieces[i].trim();
if t.is_empty() {
open = None;
continue;
}
let alone = is_alone(t);
let first = match open {
Some(f) if !alone && !is_list_item(t) => f,
_ => i,
};
first_of[i] = Some(first);
total[first] += t
.split(|c: char| !c.is_alphabetic())
.filter(|w| !w.is_empty())
.count();
open = (!alone).then_some(first);
}
(0..n)
.map(|i| match first_of[i] {
Some(f) => Unit {
start: f == i,
words: total[f],
},
None => Unit::default(),
})
.collect()
}
fn is_alone(t: &str) -> bool {
t.starts_with('#') || t.starts_with(":::") || matches!(t, "\\newpage" | "\\pagebreak")
}
fn is_list_item(t: &str) -> bool {
let after_marker = |rest: &str| rest.starts_with([' ', '\t']);
if let Some(rest) = t.strip_prefix(['-', '*', '+']) {
return after_marker(rest);
}
let digits = t.chars().take_while(char::is_ascii_digit).count();
digits > 0
&& t[digits..]
.strip_prefix(['.', ')'])
.is_some_and(after_marker)
}
fn body_line(sc: &Scrambler, line: &str, unit: Unit) -> String {
let trimmed = line.trim();
if trimmed.starts_with(":::") || matches!(trimmed, "\\newpage" | "\\pagebreak") {
return line.to_string();
}
let chars: Vec<char> = line.chars().collect();
let mut protect = protected(&chars);
keep_caption_marker(&chars, &mut protect);
let cx = Context {
para_start: unit.start,
continuation: !unit.start,
words: Some(unit.words),
};
sc.scramble_in(&chars, &protect, cx).into_iter().collect()
}
fn keep_caption_marker(line: &[char], keep: &mut [bool]) {
const MARKER: [char; 6] = ['T', 'a', 'b', 'l', 'e', ':'];
let Some(start) = line.iter().position(|c| !c.is_whitespace()) else {
return;
};
let after = start + MARKER.len();
if line[start..].starts_with(&MARKER)
&& line.get(after).is_some_and(|c| matches!(c, ' ' | '\t'))
{
keep[start..after].fill(true);
}
}
fn scramble(sc: &Scrambler, text: &str, para_start: bool) -> String {
let chars: Vec<char> = text.chars().collect();
let protect = vec![false; chars.len()];
sc.scramble(&chars, &protect, para_start)
.into_iter()
.collect()
}
fn protected(line: &[char]) -> Vec<bool> {
let n = line.len();
let mut keep = vec![false; n];
let find = |from: usize, c: char| {
line[from.min(n)..]
.iter()
.position(|&x| x == c)
.map(|p| from + p)
};
let mut i = 0;
while i < n {
match line[i] {
'[' if line.get(i + 1) == Some(&'^') => {
if let Some(j) = find(i + 2, ']') {
keep[i..=j].fill(true);
i = j + 1;
continue;
}
}
']' if line.get(i + 1) == Some(&'(') => {
let end = target_end(line, i + 2);
keep[i + 2..end].fill(true);
i = end;
continue;
}
'<' => {
if let Some(j) = find(i + 1, '>') {
let inner = &line[i + 1..j];
if !inner.iter().any(|c| c.is_whitespace())
&& inner.contains(&':')
&& inner.first() != Some(&'!')
{
keep[i..=j].fill(true);
i = j + 1;
continue;
}
}
}
'{' => {
if let Some(j) = find(i + 1, '}') {
let inner = &line[i + 1..j];
if inner.first().is_some_and(|c| matches!(c, '.' | '#' | '-'))
|| inner.contains(&'=')
{
keep[i..=j].fill(true);
i = j + 1;
continue;
}
}
}
_ => {}
}
i += 1;
}
keep
}
fn target_end(line: &[char], start: usize) -> usize {
if line.get(start) == Some(&'<')
&& let Some(p) = line[start..].iter().position(|&c| c == '>')
{
return start + p + 1;
}
let mut depth = 0usize;
for (k, &c) in line.iter().enumerate().skip(start) {
match c {
'(' => depth += 1,
')' if depth == 0 => return k,
')' => depth -= 1,
'"' if k > start && matches!(line[k - 1], ' ' | '\t') => return k - 1,
_ => {}
}
}
line.len()
}
#[cfg(test)]
#[path = "markdown_tests.rs"]
mod tests;