use std::collections::{HashMap, HashSet};
use kurbo::Rect;
use crate::lines::Line;
const BAND: f64 = 0.12;
const MIN_PAGES: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Band {
Top,
Bottom,
}
#[must_use]
pub fn mask(pages: &[(&[Line], Rect)]) -> Vec<Vec<bool>> {
let keys: Vec<Vec<Option<(Band, String)>>> = pages
.iter()
.map(|(lines, page)| {
lines
.iter()
.map(|line| band(line, *page).map(|b| (b, key(&line.text))))
.collect()
})
.collect();
let mut on_pages: HashMap<&(Band, String), usize> = HashMap::new();
for page in &keys {
let distinct: HashSet<&(Band, String)> = page.iter().flatten().collect();
for k in distinct {
*on_pages.entry(k).or_default() += 1;
}
}
let running = |k: &(Band, String)| {
on_pages
.get(k)
.is_some_and(|&n| n >= MIN_PAGES && n * 2 > pages.len())
};
keys.iter()
.map(|page| {
page.iter()
.map(|k| k.as_ref().is_some_and(running))
.collect()
})
.collect()
}
fn band(line: &Line, page: Rect) -> Option<Band> {
if page.height() <= 0.0 || line.bbox.area() <= 0.0 {
return None;
}
let reach = page.height() * BAND;
let center = f64::midpoint(line.bbox.y0, line.bbox.y1);
if center > page.y1 - reach {
Some(Band::Top)
} else if center < page.y0 + reach {
Some(Band::Bottom)
} else {
None
}
}
fn key(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for word in text.split_whitespace() {
if !out.is_empty() {
out.push(' ');
}
let mut in_digits = false;
for c in word.chars() {
if c.is_ascii_digit() {
if !in_digits {
out.push('#');
}
in_digits = true;
} else {
out.push(c);
in_digits = false;
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::{key, mask};
use crate::lines::Line;
use kurbo::Rect;
fn line(text: &str, top: f64) -> Line {
Line {
text: text.to_owned(),
bbox: Rect::new(72.0, top - 10.0, 300.0, top),
font_size: 10.0,
bold: false,
bold_prefix: 0,
mono: false,
mcids: Vec::new(),
segments: Vec::new(),
}
}
const PAGE: Rect = Rect::new(0.0, 0.0, 595.0, 842.0);
fn guide_page(n: usize, total: usize) -> Vec<Line> {
vec![
line("Foxit MobilePDF", 805.0),
line("Quick Guide", 786.0),
line(&format!("Body text of page {n}"), 700.0),
line(&format!("{n} / {total} www.foxitsoftware.com"), 79.7),
]
}
#[test]
fn digits_are_one_mark_and_spacing_is_collapsed() {
assert_eq!(key("Page 3 of 11"), "Page # of #");
assert_eq!(key("3 / 11"), "# / #");
assert_eq!(key(" Quick Guide "), "Quick Guide");
assert_eq!(key("v2.10a"), "v#.#a");
}
#[test]
fn lines_repeated_in_a_band_on_most_pages_go_and_the_body_stays() {
let pages: Vec<Vec<Line>> = (1..=11).map(|n| guide_page(n, 11)).collect();
let input: Vec<(&[Line], Rect)> = pages.iter().map(|p| (p.as_slice(), PAGE)).collect();
let got = mask(&input);
assert_eq!(got.len(), 11);
for page in &got {
assert_eq!(page, &[true, true, false, true]);
}
}
#[test]
fn two_pages_are_not_a_pattern() {
let pages: Vec<Vec<Line>> = (1..=2).map(|n| guide_page(n, 2)).collect();
let input: Vec<(&[Line], Rect)> = pages.iter().map(|p| (p.as_slice(), PAGE)).collect();
for page in mask(&input) {
assert_eq!(page, &[false, false, false, false]);
}
}
#[test]
fn a_repeat_needs_a_majority_and_a_band() {
let pages: Vec<Vec<Line>> = (1..=6)
.map(|n| {
let mut page = vec![line("Chapter One", 700.0), line("Body", 650.0)];
if n <= 3 {
page.push(line("Draft", 830.0));
}
if n <= 4 {
page.push(line("Confidential", 20.0));
}
page
})
.collect();
let input: Vec<(&[Line], Rect)> = pages.iter().map(|p| (p.as_slice(), PAGE)).collect();
let got = mask(&input);
assert_eq!(got[0], [false, false, false, true]);
assert_eq!(got[3], [false, false, true]);
assert_eq!(got[5], [false, false]);
}
}