use std::collections::HashSet;
use serde::{Deserialize, Serialize};
const TEXT_EXCERPT_LEN: usize = 500;
const LETTERHEAD_LINES_CHECKED: usize = 5;
const LETTERHEAD_CAPS_RATIO: f32 = 0.7;
const LETTERHEAD_QUALIFYING_LINES: usize = 2;
const LETTERHEAD_SHORT_LINE_MAX: usize = 60;
const PAGE_ONE_MARKER_WINDOW: usize = 2_000;
const SIGNATURE_KEYWORDS: &[&str] = &[
"sincerely",
"regards",
"yours truly",
"yours faithfully",
"signed",
"signature",
"/s/",
];
const SIGNATURE_SHORT_LINE_MIN: usize = 3;
const SIGNATURE_SHORT_LINE_MAX: usize = 80;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultidocInput {
pub page_count: u32,
pub pages: Vec<PageSignals>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageSignals {
pub page_number: u32,
pub text_excerpt: String,
pub starts_with_letterhead_like: bool,
pub has_page_number_one_marker: bool,
pub has_signature_block: bool,
pub layout_text_density: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DocumentBoundary {
pub start_page: u32,
pub end_page: u32,
pub confidence: f32,
pub reason: BoundaryReason,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BoundaryReason {
Start,
PageOneMarker,
LetterheadReset,
DensityShift,
End,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultidocThresholds {
pub density_shift_threshold: f32,
pub bigram_overlap_min: f32,
}
impl Default for MultidocThresholds {
fn default() -> Self {
Self {
density_shift_threshold: 0.3,
bigram_overlap_min: 0.1,
}
}
}
impl PageSignals {
pub fn from_page_text(page_number: u32, text: &str, layout_text_density: f32) -> Self {
let text_excerpt = text.chars().take(TEXT_EXCERPT_LEN).collect::<String>();
Self {
page_number,
text_excerpt,
starts_with_letterhead_like: detect_letterhead(text),
has_page_number_one_marker: detect_page_one_marker(text),
has_signature_block: detect_signature_block(text),
layout_text_density,
}
}
}
fn detect_letterhead(text: &str) -> bool {
let qualifying = text
.lines()
.filter(|l| !l.trim().is_empty())
.take(LETTERHEAD_LINES_CHECKED)
.filter(|line| {
let trimmed = line.trim();
if trimmed.len() > LETTERHEAD_SHORT_LINE_MAX {
return false;
}
let ascii_letters = trimmed.chars().filter(|c| c.is_ascii_alphabetic());
let total: usize = ascii_letters.clone().count();
if total == 0 {
return false;
}
let upper: usize = ascii_letters.filter(|c| c.is_ascii_uppercase()).count();
(upper as f32 / total as f32) >= LETTERHEAD_CAPS_RATIO
})
.count();
qualifying >= LETTERHEAD_QUALIFYING_LINES
}
fn detect_page_one_marker(text: &str) -> bool {
let window: String = text.chars().take(PAGE_ONE_MARKER_WINDOW).collect();
let lower = window.to_ascii_lowercase();
if lower.contains("1 of ") {
return true;
}
let mut search = lower.as_str();
while let Some(pos) = search.find("page 1") {
let after = &search[pos + "page 1".len()..];
let next_char = after.chars().next();
match next_char {
None => return true,
Some(c) if !c.is_ascii_digit() => return true,
_ => {}
}
search = &search[pos + 1..];
}
false
}
fn detect_signature_block(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
let has_keyword = SIGNATURE_KEYWORDS.iter().any(|kw| lower.contains(kw));
if !has_keyword {
return false;
}
text.lines().rev().take(10).any(|line| {
let len = line.trim().len();
(SIGNATURE_SHORT_LINE_MIN..=SIGNATURE_SHORT_LINE_MAX).contains(&len)
})
}
pub fn boundaries_from_extraction_result(
result: &crate::types::ExtractedDocument,
thresholds: &MultidocThresholds,
) -> Vec<DocumentBoundary> {
let pages = match result.pages.as_deref() {
None | Some([]) => {
return detect_boundaries(
&MultidocInput {
page_count: 1,
pages: vec![PageSignals {
page_number: 1,
text_excerpt: result.content.chars().take(TEXT_EXCERPT_LEN).collect(),
starts_with_letterhead_like: false,
has_page_number_one_marker: false,
has_signature_block: false,
layout_text_density: 0.0,
}],
},
thresholds,
);
}
Some(pages) => pages,
};
let page_count = pages.len() as u32;
let signals: Vec<PageSignals> = pages
.iter()
.map(|page| {
let density = approximate_text_density(&page.content);
PageSignals::from_page_text(page.page_number, &page.content, density)
})
.collect();
detect_boundaries(
&MultidocInput {
page_count,
pages: signals,
},
thresholds,
)
}
const DENSITY_CHAR_SCALE: f32 = 800.0;
fn approximate_text_density(text: &str) -> f32 {
let visible = text.chars().filter(|c| !c.is_whitespace()).count() as f32;
(1.0 - (-visible / DENSITY_CHAR_SCALE).exp()).clamp(0.0, 1.0)
}
pub fn detect_boundaries(input: &MultidocInput, thresholds: &MultidocThresholds) -> Vec<DocumentBoundary> {
if input.page_count == 0 || input.pages.is_empty() {
return vec![];
}
let mut boundaries = vec![DocumentBoundary {
start_page: 1,
end_page: 1,
confidence: 1.0,
reason: BoundaryReason::Start,
}];
for i in 0..input.pages.len().saturating_sub(1) {
let current = &input.pages[i];
let next = &input.pages[i + 1];
if let Some(boundary) = detect_page_transition(current, next, thresholds) {
boundaries.push(boundary);
}
}
if input.page_count > 0 {
boundaries.push(DocumentBoundary {
start_page: input.page_count,
end_page: input.page_count,
confidence: 1.0,
reason: BoundaryReason::End,
});
}
boundaries
}
fn detect_page_transition(
current: &PageSignals,
next: &PageSignals,
thresholds: &MultidocThresholds,
) -> Option<DocumentBoundary> {
if next.has_page_number_one_marker || has_page_one_pattern(&next.text_excerpt) {
return Some(DocumentBoundary {
start_page: next.page_number,
end_page: next.page_number,
confidence: 0.9,
reason: BoundaryReason::PageOneMarker,
});
}
if current.has_signature_block && next.starts_with_letterhead_like {
return Some(DocumentBoundary {
start_page: next.page_number,
end_page: next.page_number,
confidence: 0.85,
reason: BoundaryReason::LetterheadReset,
});
}
let density_delta = (current.layout_text_density - next.layout_text_density).abs();
if density_delta > thresholds.density_shift_threshold {
let overlap_ratio = compute_bigram_overlap(¤t.text_excerpt, &next.text_excerpt);
if overlap_ratio < thresholds.bigram_overlap_min {
return Some(DocumentBoundary {
start_page: next.page_number,
end_page: next.page_number,
confidence: 0.5,
reason: BoundaryReason::DensityShift,
});
}
}
None
}
fn compute_bigram_overlap(text_a: &str, text_b: &str) -> f32 {
let bigrams_a = extract_bigrams(text_a);
let bigrams_b = extract_bigrams(text_b);
if bigrams_a.is_empty() || bigrams_b.is_empty() {
return 0.0;
}
let intersection = bigrams_a.intersection(&bigrams_b).count();
let union_size = bigrams_a.len() + bigrams_b.len() - intersection;
if union_size == 0 {
0.0
} else {
intersection as f32 / union_size as f32
}
}
fn extract_bigrams(text: &str) -> HashSet<String> {
let normalized = text.to_ascii_lowercase();
let chars: Vec<char> = normalized.chars().collect();
(0..chars.len().saturating_sub(1))
.map(|i| format!("{}{}", chars[i], chars[i + 1]))
.collect()
}
fn has_page_one_pattern(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
lower.contains("page 1") || lower.contains("1 of ")
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_page(
page_number: u32,
text_excerpt: &str,
starts_with_letterhead_like: bool,
has_page_number_one_marker: bool,
has_signature_block: bool,
layout_text_density: f32,
) -> PageSignals {
PageSignals {
page_number,
text_excerpt: text_excerpt.to_string(),
starts_with_letterhead_like,
has_page_number_one_marker,
has_signature_block,
layout_text_density,
}
}
#[test]
fn density_is_sparse_for_short_pages_and_dense_for_long_pages() {
let empty = approximate_text_density("");
let sparse = approximate_text_density(&"word ".repeat(40));
let dense = approximate_text_density(&"word ".repeat(1000));
assert_eq!(empty, 0.0, "empty page has zero density");
assert!(sparse < 0.3, "a short memo page should score low, got {sparse}");
assert!(dense > 0.9, "a dense paper page should saturate high, got {dense}");
assert!(
dense - sparse > MultidocThresholds::default().density_shift_threshold,
"sparse→dense delta must exceed the density-shift threshold"
);
}
#[test]
fn density_delta_between_two_dense_pages_stays_small() {
let page_a = approximate_text_density(&"lorem ipsum ".repeat(300));
let page_b = approximate_text_density(&"dolor sit amet ".repeat(300));
assert!(
(page_a - page_b).abs() < MultidocThresholds::default().density_shift_threshold,
"dense→dense delta {} must stay below the threshold",
(page_a - page_b).abs()
);
}
#[test]
fn from_page_text_detects_letterhead() {
let text = "ACME CORPORATION\nLEGAL DEPT\nThis is body text about something.\n";
let signals = PageSignals::from_page_text(1, text, 0.5);
assert!(
signals.starts_with_letterhead_like,
"Expected letterhead detection for all-caps short lines"
);
}
#[test]
fn from_page_text_no_letterhead_for_long_lines() {
let long_line = "THIS IS A VERY LONG LINE THAT EXCEEDS THE MAXIMUM LETTERHEAD LENGTH THRESHOLD BY FAR";
let text = format!("{long_line}\n{long_line}\nBody text follows.");
let signals = PageSignals::from_page_text(1, &text, 0.5);
assert!(
!signals.starts_with_letterhead_like,
"Long ALL-CAPS lines should not trigger letterhead detection"
);
}
#[test]
fn from_page_text_detects_page_one_marker() {
let text = "Page 1 of 5\nThis is a document.";
let signals = PageSignals::from_page_text(2, text, 0.5);
assert!(signals.has_page_number_one_marker, "Expected page-one marker detection");
}
#[test]
fn from_page_text_page_one_marker_one_of_n() {
let text = "1 of 10\nDocument content here.";
let signals = PageSignals::from_page_text(3, text, 0.5);
assert!(signals.has_page_number_one_marker, "Expected '1 of N' marker detection");
}
#[test]
fn from_page_text_no_page_one_marker_for_page_10() {
let text = "Page 10 of 20\nDocument body text.";
let signals = PageSignals::from_page_text(10, text, 0.5);
assert!(
!signals.has_page_number_one_marker,
"page 10 must not trigger page-one marker"
);
}
#[test]
fn from_page_text_detects_signature_block() {
let text = "Thank you for your business.\n\nSincerely,\nJohn Smith\n2024-01-15";
let signals = PageSignals::from_page_text(1, text, 0.5);
assert!(signals.has_signature_block, "Expected signature block detection");
}
#[test]
fn from_page_text_detects_signature_slash_s() {
let text = "Agreement is hereby acknowledged.\n\n/s/ Jane Doe\nChief Executive Officer";
let signals = PageSignals::from_page_text(1, text, 0.5);
assert!(signals.has_signature_block, "Expected /s/ signature detection");
}
#[test]
fn from_page_text_no_signature_for_body_prose() {
let text = "Please provide your signature on the form attached. This is a long line that \
exceeds the short-line threshold so it should not be treated as a closing block \
in the signature detection heuristic.";
let signals = PageSignals::from_page_text(1, text, 0.5);
assert!(
!signals.has_signature_block,
"Body prose mentioning 'signature' without short closing lines should not trigger"
);
}
#[test]
fn from_page_text_text_excerpt_truncated() {
let long_text: String = "x".repeat(1000);
let signals = PageSignals::from_page_text(1, &long_text, 0.5);
assert_eq!(
signals.text_excerpt.len(),
TEXT_EXCERPT_LEN,
"text_excerpt should be truncated to TEXT_EXCERPT_LEN"
);
}
fn make_extraction_result(pages: Vec<(&str, u32)>) -> crate::types::ExtractedDocument {
use crate::types::PageContent;
crate::types::ExtractedDocument {
content: pages.iter().map(|(t, _)| *t).collect::<Vec<_>>().join("\n"),
pages: Some(
pages
.into_iter()
.map(|(text, page_number)| PageContent {
page_number,
content: text.to_string(),
tables: vec![],
image_indices: vec![],
hierarchy: None,
is_blank: None,
layout_regions: None,
speaker_notes: None,
section_name: None,
sheet_name: None,
})
.collect(),
),
..Default::default()
}
}
#[test]
fn boundaries_from_result_three_pages_detects_second_doc() {
let result = make_extraction_result(vec![
("First document body text for page one.", 1),
("Page 1 of 3\nACME CORP\nSecond document header content.", 2),
("Continuation of second document.", 3),
]);
let thresholds = MultidocThresholds::default();
let boundaries = boundaries_from_extraction_result(&result, &thresholds);
let page_2_boundary = boundaries.iter().find(|b| b.start_page == 2);
assert!(
page_2_boundary.is_some(),
"Expected a boundary at page 2 (new document starts)"
);
assert_eq!(page_2_boundary.unwrap().reason, BoundaryReason::PageOneMarker);
assert!(
(page_2_boundary.unwrap().confidence - 0.9).abs() < f32::EPSILON,
"Page-one marker should have confidence 0.9"
);
}
#[test]
fn boundaries_from_result_single_page_returns_start_end() {
let result = make_extraction_result(vec![("Single page document.", 1)]);
let thresholds = MultidocThresholds::default();
let boundaries = boundaries_from_extraction_result(&result, &thresholds);
assert_eq!(boundaries.len(), 2);
assert_eq!(boundaries[0].reason, BoundaryReason::Start);
assert_eq!(boundaries[1].reason, BoundaryReason::End);
}
#[test]
fn boundaries_from_result_no_pages_returns_start_end() {
let result = crate::types::ExtractedDocument {
content: "Whole document as one blob.".to_string(),
pages: None,
..Default::default()
};
let thresholds = MultidocThresholds::default();
let boundaries = boundaries_from_extraction_result(&result, &thresholds);
assert_eq!(boundaries.len(), 2);
assert_eq!(boundaries[0].reason, BoundaryReason::Start);
assert_eq!(boundaries[1].reason, BoundaryReason::End);
}
#[test]
fn boundaries_from_result_empty_pages_returns_start_end() {
let result = crate::types::ExtractedDocument {
content: "Document content.".to_string(),
pages: Some(vec![]),
..Default::default()
};
let thresholds = MultidocThresholds::default();
let boundaries = boundaries_from_extraction_result(&result, &thresholds);
assert_eq!(boundaries.len(), 2);
assert_eq!(boundaries[0].reason, BoundaryReason::Start);
assert_eq!(boundaries[1].reason, BoundaryReason::End);
}
#[test]
fn boundaries_from_result_letterhead_after_signature_detected() {
let result = make_extraction_result(vec![
("Dear Customer,\n\nPlease review our offer.\n\nSincerely,\nAlice", 1),
("ACME CORP\nINVOICE DEPT\nNew document starts here.", 2),
]);
let thresholds = MultidocThresholds::default();
let boundaries = boundaries_from_extraction_result(&result, &thresholds);
let page_2_boundary = boundaries.iter().find(|b| b.start_page == 2);
assert!(
page_2_boundary.is_some(),
"Expected boundary at page 2 (letterhead after signature)"
);
assert_eq!(page_2_boundary.unwrap().reason, BoundaryReason::LetterheadReset);
}
#[test]
fn test_single_page_input() {
let input = MultidocInput {
page_count: 1,
pages: vec![sample_page(1, "Hello world", false, false, false, 0.5)],
};
let thresholds = MultidocThresholds::default();
let boundaries = detect_boundaries(&input, &thresholds);
assert_eq!(boundaries.len(), 2);
assert_eq!(boundaries[0].reason, BoundaryReason::Start);
assert_eq!(boundaries[1].reason, BoundaryReason::End);
}
#[test]
fn test_invoice_receipt_scenario() {
let input = MultidocInput {
page_count: 5,
pages: vec![
sample_page(1, "Invoice #12345. Total: $500", false, false, false, 0.6),
sample_page(2, "Thank you. Sincerely, John Doe", false, false, true, 0.4),
sample_page(3, "Receipt. Page 1 of 3. ACME Corp header", true, true, false, 0.7),
sample_page(4, "Item 1: $10\nItem 2: $20", false, false, false, 0.65),
sample_page(5, "Total: $30. Thank you", false, false, false, 0.5),
],
};
let thresholds = MultidocThresholds::default();
let boundaries = detect_boundaries(&input, &thresholds);
let page_3_boundaries: Vec<_> = boundaries.iter().filter(|b| b.start_page == 3).collect();
assert!(!page_3_boundaries.is_empty());
let strongest = page_3_boundaries.iter().max_by(|a, b| {
a.confidence
.partial_cmp(&b.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
});
assert_eq!(strongest.unwrap().reason, BoundaryReason::PageOneMarker);
}
#[test]
fn test_page_one_marker_detection() {
let input = MultidocInput {
page_count: 2,
pages: vec![
sample_page(1, "First document text", false, false, false, 0.5),
sample_page(2, "Page 1 of 5. Second document here", false, true, false, 0.6),
],
};
let thresholds = MultidocThresholds::default();
let boundaries = detect_boundaries(&input, &thresholds);
let page_2_boundary = boundaries
.iter()
.find(|b| b.start_page == 2)
.expect("Should detect boundary at page 2");
assert_eq!(page_2_boundary.reason, BoundaryReason::PageOneMarker);
assert_eq!(page_2_boundary.confidence, 0.9);
}
#[test]
fn test_letterhead_reset_detection() {
let input = MultidocInput {
page_count: 2,
pages: vec![
sample_page(1, "Letter content. Sincerely, John", false, false, true, 0.5),
sample_page(2, "NEW CORP LETTERHEAD. Invoice header", true, false, false, 0.6),
],
};
let thresholds = MultidocThresholds::default();
let boundaries = detect_boundaries(&input, &thresholds);
let page_2_boundary = boundaries
.iter()
.find(|b| b.start_page == 2)
.expect("Should detect boundary at page 2");
assert_eq!(page_2_boundary.reason, BoundaryReason::LetterheadReset);
assert_eq!(page_2_boundary.confidence, 0.85);
}
#[test]
fn test_density_shift_detection() {
let input = MultidocInput {
page_count: 2,
pages: vec![
sample_page(1, "sparse page text", false, false, false, 0.2),
sample_page(
2,
"completely different document content that has nothing in common",
false,
false,
false,
0.8,
),
],
};
let thresholds = MultidocThresholds::default();
let boundaries = detect_boundaries(&input, &thresholds);
let page_2_boundary = boundaries
.iter()
.find(|b| b.start_page == 2)
.expect("Should detect boundary at page 2 due to density shift");
assert_eq!(page_2_boundary.reason, BoundaryReason::DensityShift);
assert_eq!(page_2_boundary.confidence, 0.5);
}
#[test]
fn test_no_boundary_with_high_bigram_overlap() {
let common_text = "The quick brown fox jumps over the lazy dog";
let input = MultidocInput {
page_count: 2,
pages: vec![
sample_page(1, common_text, false, false, false, 0.5),
sample_page(2, common_text, false, false, false, 0.8),
],
};
let thresholds = MultidocThresholds::default();
let boundaries = detect_boundaries(&input, &thresholds);
let page_2_density_shift = boundaries
.iter()
.find(|b| b.start_page == 2 && b.reason == BoundaryReason::DensityShift);
assert!(page_2_density_shift.is_none());
}
#[test]
fn test_priority_page_one_over_letterhead() {
let input = MultidocInput {
page_count: 2,
pages: vec![
sample_page(1, "Letter. Sincerely", false, false, true, 0.5),
sample_page(2, "Page 1 of 10. CORP HEADER", true, true, false, 0.6),
],
};
let thresholds = MultidocThresholds::default();
let boundaries = detect_boundaries(&input, &thresholds);
let page_2_boundary = boundaries
.iter()
.find(|b| b.start_page == 2)
.expect("Should detect boundary at page 2");
assert_eq!(page_2_boundary.reason, BoundaryReason::PageOneMarker);
assert_eq!(page_2_boundary.confidence, 0.9);
}
#[test]
fn test_empty_input() {
let input = MultidocInput {
page_count: 0,
pages: vec![],
};
let thresholds = MultidocThresholds::default();
let boundaries = detect_boundaries(&input, &thresholds);
assert_eq!(boundaries.len(), 0);
}
#[test]
fn test_bigram_overlap_identical_text() {
let text = "hello world";
let overlap = compute_bigram_overlap(text, text);
assert_eq!(overlap, 1.0);
}
#[test]
fn test_bigram_overlap_completely_different() {
let text_a = "aaaa";
let text_b = "bbbb";
let overlap = compute_bigram_overlap(text_a, text_b);
assert_eq!(overlap, 0.0);
}
#[test]
fn test_bigram_overlap_partial() {
let text_a = "hello";
let text_b = "hella";
let overlap = compute_bigram_overlap(text_a, text_b);
assert!(overlap > 0.5 && overlap < 0.7);
}
#[test]
fn test_extract_bigrams() {
let bigrams = extract_bigrams("ab");
assert_eq!(bigrams.len(), 1);
assert!(bigrams.contains("ab"));
let bigrams = extract_bigrams("abc");
assert_eq!(bigrams.len(), 2);
assert!(bigrams.contains("ab"));
assert!(bigrams.contains("bc"));
}
#[test]
fn test_default_thresholds() {
let thresholds = MultidocThresholds::default();
assert_eq!(thresholds.density_shift_threshold, 0.3);
assert_eq!(thresholds.bigram_overlap_min, 0.1);
}
}