use std::cell::Cell;
use std::collections::HashSet;
use std::ops::Range;
use std::sync::OnceLock;
use crate::{heading, lang, vocab};
const SHORT_LINE_WORDS: usize = 16;
const LABEL_WORDS: usize = 4;
const NUMBERED_LABEL_WORDS: usize = 2;
pub(super) const LANG_BUDGET: usize = lang::SAMPLE_WORDS + lang::SAMPLE_WORDS / 5;
#[derive(Debug, Clone, Copy, Default)]
pub(super) struct Context {
pub para_start: bool,
pub continuation: bool,
pub words: Option<usize>,
}
pub(super) struct Scrambler {
seed: u64,
lang_budget: Cell<usize>,
}
pub(super) fn seed_of(bytes: &[u8]) -> u64 {
splitmix(fnv1a(bytes))
}
impl Scrambler {
pub fn new(seed: u64) -> Self {
Self {
seed,
lang_budget: Cell::new(usize::MAX),
}
}
pub fn set_lang_budget(&self, words: usize) {
self.lang_budget.set(words);
}
pub fn scramble(&self, text: &[char], protect: &[bool], para_start: bool) -> Vec<char> {
let cx = Context {
para_start,
..Context::default()
};
self.scramble_in(text, protect, cx)
}
pub fn scramble_in(&self, text: &[char], protect: &[bool], cx: Context) -> Vec<char> {
debug_assert_eq!(text.len(), protect.len());
let mut out = text.to_vec();
let mut start = 0;
let mut first = cx.para_start;
loop {
let end = text[start..]
.iter()
.position(|&c| c == '\n')
.map_or(text.len(), |p| start + p);
let line = Context {
para_start: first,
..cx
};
self.line(
&text[start..end],
&protect[start..end],
line,
&mut out[start..end],
);
first = first && !text[start..end].iter().any(|c| c.is_alphabetic());
if end >= text.len() {
break;
}
start = end + 1;
}
out
}
fn line(&self, line: &[char], protect: &[bool], cx: Context, out: &mut [char]) {
if line.is_empty() {
return;
}
if heading::is_scene_break(&line.iter().collect::<String>()) {
return;
}
let para_start = cx.para_start;
let words = find_words(line, protect);
let short = cx.words.unwrap_or(words.len()) <= SHORT_LINE_WORDS;
let low: Vec<char> = line.iter().map(|&c| lower1(c)).collect();
let lead = line.iter().position(|c| c.is_alphabetic()).unwrap_or(0);
let has_digit = line.iter().any(char::is_ascii_digit);
let mut keep = vec![false; line.len()];
let mut mark = |markers: &mut dyn Iterator<Item = &&str>, only_at_lead: bool| {
for m in markers {
let len = m.chars().count();
for at in find_all(&low, m) {
let before = at == 0 || !low[at - 1].is_alphabetic();
let after = !m.ends_with(char::is_alphabetic)
|| low.get(at + len).is_none_or(|c| !c.is_alphabetic());
if before && after && (!only_at_lead || at == lead) {
keep[at..at + len].fill(true);
}
}
}
};
mark(&mut vocab::WEB_MARKERS.iter(), false);
mark(
&mut vocab::COPYRIGHT_MARKERS
.iter()
.filter(|m| **m == vocab::COPYRIGHT_LEAD),
true,
);
mark(
&mut vocab::COPYRIGHT_MARKERS
.iter()
.filter(|m| **m != vocab::COPYRIGHT_LEAD),
false,
);
if short && has_digit {
let clock = vocab::CLOCK_WORDS
.iter()
.chain(vocab::CLOCK_AFTER_HOURS)
.filter(|m| m.contains('.'));
mark(&mut vocab::COUNT_WORDS.iter().chain(clock), false);
}
let mut author_line = false;
if para_start {
let rest: String = low[lead..].iter().collect();
let starts = vocab::DEDICATION_STARTS
.iter()
.chain(vocab::AUTHOR_PREFIXES)
.find(|p| rest.starts_with(*p));
if let Some(p) = starts {
keep[lead..lead + p.chars().count()].fill(true);
author_line = vocab::AUTHOR_PREFIXES.contains(p);
}
}
let plain: String = line
.iter()
.zip(protect)
.filter(|&(c, &p)| !p && !matches!(c, '*' | '_' | '`'))
.map(|(&c, _)| c)
.collect();
let plain = plain.trim_matches(|c: char| !c.is_alphanumeric());
let plain_low = plain.to_lowercase();
let label_words = if cx.continuation {
None
} else {
let info = heading::classify(plain);
info.is_label().then_some(if info.number.is_some() {
NUMBERED_LABEL_WORDS
} else {
LABEL_WORDS
})
};
let end_line = {
let w: Vec<&str> = plain_low.split_whitespace().collect();
vocab::END_MARKS.contains(&w.as_slice())
};
let creator_line = vocab::CREATOR_PLACEHOLDERS.contains(&plain_low.as_str());
let dateline = short && has_digit;
for (n, w) in words.into_iter().enumerate() {
let budget = self.lang_budget.get();
self.lang_budget.set(budget.saturating_sub(1));
if w.clone().any(|i| keep[i]) {
continue;
}
let letters: Vec<usize> = w.filter(|&i| line[i].is_alphabetic()).collect();
let word: String = letters.iter().map(|&i| line[i]).collect();
let key: String = word.chars().map(lower1).collect();
let after_digit = line[..letters[0]]
.iter()
.rev()
.find(|c| !c.is_whitespace())
.is_some_and(char::is_ascii_digit);
let read = (budget > 0 && language_word(&key))
|| (label_words.is_some_and(|m| n < m) && structure_word(&word))
|| ((end_line || creator_line) && structure_word(&word))
|| (dateline && vocab::MONTHS.contains(&key.as_str()))
|| (dateline
&& after_digit
&& (vocab::CLOCK_WORDS.contains(&key.as_str())
|| vocab::CLOCK_AFTER_HOURS.contains(&key.as_str())))
|| (author_line && vocab::AUTHOR_PARTICLES.contains(&key.as_str()));
if read {
continue;
}
let new = self.replacement(&key);
for (&i, &r) in letters.iter().zip(&new) {
out[i] = with_case_of(line[i], r);
}
}
}
fn replacement(&self, key: &str) -> Vec<char> {
let letters: Vec<char> = key.chars().collect();
let mut attempt = 0u64;
loop {
let mut rng = Rng(splitmix(self.seed ^ fnv1a(key.as_bytes())) ^ attempt);
let new: Vec<char> = letters
.iter()
.map(|&c| replace_letter(c, &mut rng))
.collect();
let text: String = new.iter().collect();
if attempt >= 64 || !reserved(&text) || reserved(key) {
return new;
}
attempt += 1;
}
}
}
fn find_words(line: &[char], protect: &[bool]) -> Vec<Range<usize>> {
let mut words = Vec::new();
let mut i = 0;
while i < line.len() {
if protect[i] || !line[i].is_alphabetic() {
i += 1;
continue;
}
let start = i;
while i < line.len() && !protect[i] && (line[i].is_alphabetic() || is_joiner(line[i])) {
i += 1;
}
words.push(start..i);
}
words
}
fn is_joiner(c: char) -> bool {
matches!(c,
'\u{ad}' | '\u{200b}'..='\u{200d}' | '\u{2060}' | '\u{feff}'
| '\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}'
| '\u{20d0}'..='\u{20ff}' | '\u{fe00}'..='\u{fe0f}' | '\u{fe20}'..='\u{fe2f}')
}
fn find_all(hay: &[char], needle: &str) -> Vec<usize> {
let n: Vec<char> = needle.chars().collect();
if n.is_empty() || hay.len() < n.len() {
return Vec::new();
}
(0..=hay.len() - n.len())
.filter(|&i| hay[i..i + n.len()] == n[..])
.collect()
}
fn lower1(c: char) -> char {
let mut it = c.to_lowercase();
match (it.next(), it.next()) {
(Some(l), None) => l,
_ => c,
}
}
fn with_case_of(original: char, r: char) -> char {
if !original.is_uppercase() {
return r;
}
if r == 'ß' {
return 'ẞ';
}
let mut up = r.to_uppercase();
match (up.next(), up.next()) {
(Some(u), None) => u,
_ => r,
}
}
fn language_word(key: &str) -> bool {
lang::EN_WORDS.contains(&key) || lang::DE_WORDS.contains(&key)
}
fn structure_word(word: &str) -> bool {
let key: String = word.chars().map(lower1).collect();
structure_words().contains(key.as_str())
|| heading::parse_number(word).is_some()
|| heading::german_ordinal(&key).is_some()
}
fn reserved(key: &str) -> bool {
language_word(key) || structure_word(key) || prefix_words().contains(key)
}
fn structure_words() -> &'static HashSet<String> {
static WORDS: OnceLock<HashSet<String>> = OnceLock::new();
WORDS.get_or_init(|| {
let mut set = HashSet::new();
for (_, names) in vocab::SECTION_NAMES {
add_words(&mut set, names);
}
let lists: &[&[&str]] = &[
vocab::ALSO_BY,
vocab::CHAPTER_LABELS,
vocab::PART_LABELS,
vocab::EN_ONES,
vocab::EN_TENS,
vocab::DE_ONES,
vocab::DE_TENS,
vocab::AUTHOR_PARTICLES,
vocab::MONTHS,
vocab::CLOCK_WORDS,
vocab::COPYRIGHT_MARKERS,
vocab::WEB_MARKERS,
vocab::COUNT_WORDS,
vocab::CREATOR_PLACEHOLDERS,
];
for list in lists {
add_words(&mut set, list);
}
for mark in vocab::END_MARKS {
add_words(&mut set, mark);
}
set
})
}
fn prefix_words() -> &'static HashSet<String> {
static WORDS: OnceLock<HashSet<String>> = OnceLock::new();
WORDS.get_or_init(|| {
let mut set = HashSet::new();
add_words(&mut set, vocab::DEDICATION_STARTS);
add_words(&mut set, vocab::AUTHOR_PREFIXES);
set
})
}
fn add_words(set: &mut HashSet<String>, phrases: &[&str]) {
for phrase in phrases {
for w in phrase.split(|c: char| !c.is_alphabetic()) {
if w.chars().count() >= 2 {
set.insert(w.to_string());
}
}
}
}
const VOWELS: &str = "aeiouàáâãåāăąæèéêēėęìíîīįòóôõøōőùúûūůűųœ";
const UMLAUTS: [char; 3] = ['ä', 'ö', 'ü'];
const PLAIN_VOWELS: [char; 5] = ['a', 'e', 'i', 'o', 'u'];
const CONSONANTS: [char; 20] = [
'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'q', 'r', 's', 't', 'v', 'w', 'x',
'z',
];
const CYRILLIC_VOWELS: &str = "аеёиоуыэюяіїєѣ";
const CYRILLIC_VOWEL_SET: [char; 9] = ['а', 'е', 'и', 'о', 'у', 'ы', 'э', 'ю', 'я'];
const CYRILLIC_CONSONANTS: [char; 20] = [
'б', 'в', 'г', 'д', 'ж', 'з', 'к', 'л', 'м', 'н', 'п', 'р', 'с', 'т', 'ф', 'х', 'ц', 'ч', 'ш',
'щ',
];
const GREEK_VOWELS: &str = "αεηιουωάέήίόύώ";
const GREEK_VOWEL_SET: [char; 7] = ['α', 'ε', 'η', 'ι', 'ο', 'υ', 'ω'];
const GREEK_CONSONANTS: [char; 14] = [
'β', 'γ', 'δ', 'ζ', 'θ', 'κ', 'λ', 'μ', 'ν', 'ξ', 'π', 'ρ', 'σ', 'τ',
];
fn replace_letter(c: char, rng: &mut Rng) -> char {
match c {
'ß' => 'ß',
'ä' | 'ö' | 'ü' | 'ë' | 'ï' | 'ÿ' => rng.pick_not(&UMLAUTS, c),
_ if VOWELS.contains(c) => rng.pick_not(&PLAIN_VOWELS, c),
'\u{400}'..='\u{52f}' => {
if CYRILLIC_VOWELS.contains(c) {
rng.pick_not(&CYRILLIC_VOWEL_SET, c)
} else {
rng.pick_not(&CYRILLIC_CONSONANTS, c)
}
}
'\u{370}'..='\u{3ff}' | '\u{1f00}'..='\u{1fff}' => {
if GREEK_VOWELS.contains(c) {
rng.pick_not(&GREEK_VOWEL_SET, c)
} else {
rng.pick_not(&GREEK_CONSONANTS, c)
}
}
'\u{3041}'..='\u{3096}' => rng.in_range_not('\u{3041}', '\u{3096}', c),
'\u{30a1}'..='\u{30fa}' => rng.in_range_not('\u{30a1}', '\u{30fa}', c),
'\u{4e00}'..='\u{9fff}' => rng.in_range_not('\u{4e00}', '\u{9fff}', c),
'\u{ac00}'..='\u{d7a3}' => rng.in_range_not('\u{ac00}', '\u{d7a3}', c),
_ => rng.pick_not(&CONSONANTS, c),
}
}
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
splitmix(self.0)
}
fn pick_not<const N: usize>(&mut self, set: &[char; N], not: char) -> char {
let others = set.iter().filter(|&&c| c != not).count();
let mut n = (self.next() % others.max(1) as u64) as usize;
for &c in set {
if c == not {
continue;
}
if n == 0 {
return c;
}
n -= 1;
}
not
}
fn in_range_not(&mut self, lo: char, hi: char, not: char) -> char {
let span = hi as u32 - lo as u32; let mut n = lo as u32 + (self.next() % u64::from(span)) as u32;
if char::from_u32(n) == Some(not) {
n = if n == hi as u32 { lo as u32 } else { n + 1 };
}
char::from_u32(n).unwrap_or(not)
}
}
fn fnv1a(bytes: &[u8]) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64;
for &b in bytes {
h ^= u64::from(b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
fn splitmix(mut z: u64) -> u64 {
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
#[cfg(test)]
#[path = "words_tests.rs"]
mod tests;