use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
use regex::Regex;
use kopitiam_pdf::{Page, TextSpan};
use super::{SAME_LINE_Y_TOLERANCE_RATIO, build_line};
const MARGIN_ZONE_FRACTION: f32 = 0.10;
const RECURRENCE_THRESHOLD_FRACTION: f32 = 0.5;
const MIN_RECURRENCE_COUNT: usize = 3;
const MIN_PAGES_FOR_RECURRENCE: usize = 4;
static BARE_PAGE_NUMBER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\W*\d{1,4}\W*$").unwrap());
static DIGIT_RUN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d+").unwrap());
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Edge {
NearZero,
NearHeight,
}
struct ZoneLine {
edge: Edge,
signature: String,
raw_text: String,
span_indices: Vec<usize>,
}
pub(crate) fn strip_marginalia(pages: &[Page]) -> Vec<Page> {
if pages.is_empty() {
return Vec::new();
}
let per_page: Vec<Vec<ZoneLine>> = pages.iter().map(zone_lines).collect();
let mut counts: HashMap<(Edge, &str), usize> = HashMap::new();
for lines in &per_page {
for line in lines {
if line.signature.is_empty() {
continue;
}
*counts
.entry((line.edge, line.signature.as_str()))
.or_default() += 1;
}
}
let recurring = recurring_signatures(&counts, pages.len());
pages
.iter()
.zip(&per_page)
.map(|(page, lines)| strip_page(page, lines, &recurring))
.collect()
}
fn recurring_signatures(
counts: &HashMap<(Edge, &str), usize>,
n_pages: usize,
) -> HashSet<(Edge, String)> {
if n_pages < MIN_PAGES_FOR_RECURRENCE {
return HashSet::new();
}
let threshold =
MIN_RECURRENCE_COUNT.max((n_pages as f32 * RECURRENCE_THRESHOLD_FRACTION).ceil() as usize);
counts
.iter()
.filter(|&(_, &count)| count >= threshold)
.map(|(&(edge, sig), _)| (edge, sig.to_string()))
.collect()
}
fn strip_page(page: &Page, lines: &[ZoneLine], recurring: &HashSet<(Edge, String)>) -> Page {
let mut drop: HashSet<usize> = HashSet::new();
for line in lines {
let is_bare_number = BARE_PAGE_NUMBER.is_match(&line.raw_text);
let is_running =
!line.signature.is_empty() && recurring.contains(&(line.edge, line.signature.clone()));
if is_bare_number || is_running {
drop.extend(&line.span_indices);
}
}
if drop.is_empty() {
return page.clone();
}
let spans = page
.spans
.iter()
.enumerate()
.filter(|(i, _)| !drop.contains(i))
.map(|(_, span)| span.clone())
.collect();
Page {
number: page.number,
width: page.width,
height: page.height,
spans,
}
}
fn zone_lines(page: &Page) -> Vec<ZoneLine> {
if page.height <= 0.0 {
return Vec::new();
}
let mut candidates: Vec<(usize, &TextSpan, Edge)> = page
.spans
.iter()
.enumerate()
.filter_map(|(i, span)| edge_of(span.y, page.height).map(|edge| (i, span, edge)))
.collect();
candidates.sort_by(|a, b| b.1.y.partial_cmp(&a.1.y).unwrap_or(Ordering::Equal));
let mut groups: Vec<Vec<(usize, &TextSpan, Edge)>> = Vec::new();
for candidate in candidates {
let (_, span, edge) = candidate;
let joins_last = groups.last().is_some_and(|group| {
let (_, anchor, anchor_edge) = group[0];
let tolerance = anchor.font_size.max(span.font_size) * SAME_LINE_Y_TOLERANCE_RATIO;
anchor_edge == edge && (anchor.y - span.y).abs() <= tolerance
});
if joins_last {
groups.last_mut().unwrap().push(candidate);
} else {
groups.push(vec![candidate]);
}
}
groups
.into_iter()
.map(|mut group| {
group.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap_or(Ordering::Equal));
let refs: Vec<&TextSpan> = group.iter().map(|(_, span, _)| *span).collect();
let text = build_line(&refs).text;
let raw_text = text.trim().to_string();
ZoneLine {
edge: group[0].2,
signature: normalize_signature(&raw_text),
raw_text,
span_indices: group.iter().map(|(i, _, _)| *i).collect(),
}
})
.collect()
}
fn edge_of(y: f32, height: f32) -> Option<Edge> {
let fraction = y / height;
if fraction < MARGIN_ZONE_FRACTION {
Some(Edge::NearZero)
} else if fraction > 1.0 - MARGIN_ZONE_FRACTION {
Some(Edge::NearHeight)
} else {
None
}
}
fn normalize_signature(trimmed_text: &str) -> String {
DIGIT_RUN.replace_all(trimmed_text, "#").to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
fn span(text: &str, x: f32, y: f32, width: f32, font_size: f32) -> TextSpan {
TextSpan {
text: text.to_string(),
x,
y,
width,
height: font_size,
font_size,
font_name: None,
..TextSpan::default()
}
}
fn page_with(head: &str, body: &str, page_number: &str) -> Page {
let mut spans = Vec::new();
if !head.is_empty() {
spans.push(span(head, 50.0, 770.0, 300.0, 10.0));
}
spans.push(span(body, 50.0, 400.0, 400.0, 10.0));
if !page_number.is_empty() {
spans.push(span(page_number, 300.0, 30.0, 20.0, 10.0));
}
Page {
number: 1,
width: 600.0,
height: 800.0,
spans,
}
}
fn texts(page: &Page) -> Vec<&str> {
page.spans.iter().map(|s| s.text.as_str()).collect()
}
#[test]
fn six_page_running_head_and_alternating_footer_numbers_all_stripped() {
let pages: Vec<Page> = (1..=6)
.map(|n| {
page_with(
"A Study of Synthetic Widgets",
&format!("Body sentence number {n} carries the real content here."),
&n.to_string(),
)
})
.collect();
let stripped = strip_marginalia(&pages);
for (i, page) in stripped.iter().enumerate() {
let remaining = texts(page);
assert_eq!(
remaining.len(),
1,
"page {} should keep only its body line, got {:?}",
i + 1,
remaining
);
assert!(
remaining[0].starts_with("Body sentence"),
"the surviving line must be the body, got {:?}",
remaining[0]
);
}
}
#[test]
fn short_document_does_not_strip_a_repeating_heading() {
let pages = vec![
page_with("Introduction", "First body paragraph of the document.", ""),
page_with("Introduction", "Second body paragraph of the document.", ""),
];
let stripped = strip_marginalia(&pages);
for page in &stripped {
assert!(
texts(page).contains(&"Introduction"),
"a heading on a short document must not be treated as a running head: {:?}",
texts(page)
);
}
}
#[test]
fn bare_number_in_zone_dropped_but_body_number_kept() {
let page = Page {
number: 1,
width: 600.0,
height: 800.0,
spans: vec![
span(
"Body text with the number 12 inside it.",
50.0,
400.0,
400.0,
10.0,
),
span("12", 300.0, 400.0, 20.0, 10.0), span("12", 300.0, 30.0, 20.0, 10.0), ],
};
let stripped = strip_marginalia(&[page]);
let remaining = texts(&stripped[0]);
assert_eq!(
remaining.len(),
2,
"only the zone page number should go: {remaining:?}"
);
assert!(remaining.contains(&"Body text with the number 12 inside it."));
assert!(remaining.contains(&"12"), "the mid-page 12 must be kept");
}
#[test]
fn punctuation_wrapped_page_number_is_stripped() {
let page = Page {
number: 1,
width: 600.0,
height: 800.0,
spans: vec![
span("Real body content on the page.", 50.0, 400.0, 400.0, 10.0),
span("- 7 -", 290.0, 25.0, 30.0, 10.0),
],
};
let stripped = strip_marginalia(&[page]);
assert_eq!(texts(&stripped[0]), vec!["Real body content on the page."]);
}
#[test]
fn works_with_top_left_origin_orientation() {
let pages: Vec<Page> = (1..=6)
.map(|n| Page {
number: n,
width: 600.0,
height: 800.0,
spans: vec![
span("Running Head Flipped", 50.0, 30.0, 300.0, 10.0),
span(
"Body content line goes here as prose.",
50.0,
400.0,
400.0,
10.0,
),
span(&n.to_string(), 300.0, 770.0, 20.0, 10.0),
],
})
.collect();
let stripped = strip_marginalia(&pages);
for page in &stripped {
assert_eq!(
texts(page),
vec!["Body content line goes here as prose."],
"orientation must not affect stripping"
);
}
}
#[test]
fn digit_normalized_signature_collapses_page_numbered_running_heads() {
let pages: Vec<Page> = (1..=5)
.map(|n| Page {
number: n,
width: 600.0,
height: 800.0,
spans: vec![
span(
"Body prose for this page of the report.",
50.0,
400.0,
400.0,
10.0,
),
span(&format!("Chapter 3 - {}", 40 + n), 50.0, 30.0, 200.0, 10.0),
],
})
.collect();
let stripped = strip_marginalia(&pages);
for page in &stripped {
assert_eq!(
texts(page),
vec!["Body prose for this page of the report."],
"digit-varying running foot must be recognised as one signature"
);
}
}
#[test]
fn empty_input_is_handled() {
assert!(strip_marginalia(&[]).is_empty());
}
}