mod report;
pub use report::{ConversionReport, PageRecovery};
use std::collections::BTreeMap;
use kopitiam_pdf::{Page, TextSpan};
use crate::reconstruction::{collapse_figure_regions, strip_marginalia};
use crate::{Block, Document};
const FIGURE_PLACEHOLDER: &str = "[figure]";
pub fn validate(pages: &[Page], document: &Document, rendered_markdown: &str) -> ConversionReport {
let stripped = strip_marginalia(pages);
let stripped = collapse_figure_regions(&stripped);
let extracted_words = stripped
.iter()
.flat_map(|page| &page.spans)
.map(|span| word_count(&span.text))
.sum();
let mut headings_found = 0;
let mut lists_found = 0;
let mut tables_found = 0;
for block in &document.blocks {
match block {
Block::Heading(_) => headings_found += 1,
Block::List(_) => lists_found += 1,
Block::Table(_) => tables_found += 1,
_ => {}
}
}
ConversionReport {
pages: pages.len(),
extracted_words,
rendered_words: word_count(rendered_markdown),
extracted_chars: extracted_content_chars(&stripped),
rendered_chars: rendered_content_chars(rendered_markdown),
headings_found,
lists_found,
tables_found,
citations_found: document.citations.len(),
per_page: per_page_recovery(&stripped, document, rendered_markdown),
}
}
fn per_page_recovery(
stripped: &[Page],
document: &Document,
rendered_markdown: &str,
) -> Vec<PageRecovery> {
let Some(&first_page) = document.block_pages.first() else {
return Vec::new();
};
let rendered_by_page = rendered_chars_by_page(rendered_markdown, first_page);
(0..stripped.len())
.map(|i| {
let page = i + 1;
PageRecovery {
page,
extracted_chars: extracted_content_chars(std::slice::from_ref(&stripped[i])),
rendered_chars: rendered_by_page.get(&page).copied().unwrap_or(0),
}
})
.collect()
}
fn rendered_chars_by_page(markdown: &str, first_page: usize) -> BTreeMap<usize, usize> {
let mut by_page: BTreeMap<usize, usize> = BTreeMap::new();
let mut current_page = first_page;
let mut segment = String::new();
let mut flush = |page: usize, segment: &mut String| {
let chars = content_char_count(&strip_rendered_markdown_syntax(segment));
*by_page.entry(page).or_insert(0) += chars;
segment.clear();
};
for line in markdown.lines() {
if let Some(page) = parse_page_anchor(line.trim()) {
flush(current_page, &mut segment);
current_page = page;
} else {
segment.push_str(line);
segment.push('\n');
}
}
flush(current_page, &mut segment);
by_page
}
fn parse_page_anchor(line: &str) -> Option<usize> {
line.strip_prefix("<!-- page ")?
.strip_suffix(" -->")?
.parse()
.ok()
}
fn word_count(text: &str) -> usize {
text.split_whitespace().count()
}
fn content_char_count(text: &str) -> usize {
text.chars().filter(|c| !c.is_whitespace()).count()
}
fn extracted_content_chars(pages: &[Page]) -> usize {
let mut total = 0;
for page in pages {
let spans = &page.spans;
for (i, span) in spans.iter().enumerate() {
let text = span.text.as_str();
let drop_trailing_hyphen = spans
.get(i + 1)
.is_some_and(|next| is_wrap_hyphen(span, next));
let counted = if drop_trailing_hyphen {
&text[..text.len() - 1]
} else {
text
};
total += content_char_count(counted);
}
}
total
}
fn is_wrap_hyphen(current: &TextSpan, next: &TextSpan) -> bool {
let ends_with_hyphen = current.text.ends_with('-');
let same_line_tolerance = current.font_size.max(next.font_size) * 0.4;
let different_line = (current.y - next.y).abs() > same_line_tolerance;
let continues_lowercase = next.text.chars().next().is_some_and(char::is_lowercase);
ends_with_hyphen && different_line && continues_lowercase
}
fn rendered_content_chars(markdown: &str) -> usize {
content_char_count(&strip_rendered_markdown_syntax(markdown))
}
fn strip_rendered_markdown_syntax(markdown: &str) -> String {
let mut out = String::with_capacity(markdown.len());
for line in markdown.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
continue; }
if trimmed == FIGURE_PLACEHOLDER {
continue; }
if parse_page_anchor(trimmed).is_some() {
continue;
}
if is_table_separator_line(trimmed) {
continue; }
let content = strip_heading_hashes(trimmed);
let content = content
.strip_prefix("> ")
.or_else(|| content.strip_prefix('>'))
.unwrap_or(content);
let content = strip_list_marker(content);
let content = strip_table_pipes(content);
out.push_str(&content);
out.push('\n');
}
out
}
fn strip_heading_hashes(line: &str) -> &str {
let hashes = line.chars().take_while(|&c| c == '#').count();
if (1..=6).contains(&hashes) && line.as_bytes().get(hashes) == Some(&b' ') {
&line[hashes + 1..]
} else {
line
}
}
fn strip_list_marker(line: &str) -> &str {
if let Some(rest) = line.strip_prefix("- ") {
return rest;
}
let digits = line.chars().take_while(|c| c.is_ascii_digit()).count();
if digits > 0
&& let Some(rest) = line[digits..].strip_prefix(". ")
{
return rest;
}
line
}
fn strip_table_pipes(line: &str) -> String {
let Some(inner) = line.strip_prefix("| ").and_then(|s| s.strip_suffix(" |")) else {
return line.to_string();
};
inner
.split(" | ")
.map(|cell| cell.replace("\\|", "|"))
.collect::<Vec<_>>()
.join(" ")
}
fn is_table_separator_line(line: &str) -> bool {
line.starts_with('|')
&& line.ends_with('|')
&& line.contains('-')
&& line.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Heading, Metadata, Paragraph};
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(spans: Vec<TextSpan>) -> Page {
page_n(1, spans)
}
fn page_n(number: usize, spans: Vec<TextSpan>) -> Page {
Page {
number,
width: 600.0,
height: 800.0,
spans,
}
}
fn empty_document(blocks: Vec<Block>) -> Document {
Document {
title: None,
metadata: Metadata { source_pages: 1 },
block_pages: vec![1; blocks.len()],
blocks,
citations: Vec::new(),
}
}
#[test]
fn dropped_content_fails() {
let pages = vec![page(vec![span(
"This paragraph has plenty of words that never make it into the output.",
50.0,
700.0,
500.0,
10.0,
)])];
let document = empty_document(vec![Block::Paragraph(Paragraph {
text: "This paragraph has".to_string(),
})]);
let report = validate(&pages, &document, "This paragraph has\n");
assert!(
report.recovery_ratio() < 0.5,
"expected a low ratio for dropped content, got {}",
report.recovery_ratio()
);
assert!(!report.passes(), "truncated content must not PASS");
}
#[test]
fn repaired_hyphenation_still_passes() {
let pages = vec![page(vec![
span("develop-", 50.0, 700.0, 60.0, 10.0),
span("ment continues steadily.", 50.0, 688.0, 150.0, 10.0),
])];
let document = empty_document(vec![Block::Paragraph(Paragraph {
text: "development continues steadily.".to_string(),
})]);
let report = validate(&pages, &document, "development continues steadily.\n");
assert!(
report.recovery_ratio() >= 0.99,
"hyphenation repair must not be penalized, got {}",
report.recovery_ratio()
);
assert!(report.passes());
}
#[test]
fn a_real_compound_hyphen_is_not_stripped_from_either_side() {
let pages = vec![page(vec![
span("Anglo-", 50.0, 700.0, 40.0, 10.0),
span("Saxon history.", 50.0, 688.0, 90.0, 10.0),
])];
let document = empty_document(vec![Block::Paragraph(Paragraph {
text: "Anglo-Saxon history.".to_string(),
})]);
let report = validate(&pages, &document, "Anglo-Saxon history.\n");
assert!(
report.recovery_ratio() >= 0.99,
"expected ~100%, got {}",
report.recovery_ratio()
);
}
#[test]
fn table_pipe_syntax_does_not_inflate_recovery() {
let pages = vec![page(vec![
span("Metric", 50.0, 700.0, 60.0, 10.0),
span("Value", 200.0, 700.0, 60.0, 10.0),
span("Speed", 50.0, 688.0, 60.0, 10.0),
span("42", 200.0, 688.0, 60.0, 10.0),
])];
let document = empty_document(vec![Block::Table(crate::Table {
headers: vec!["Metric".to_string(), "Value".to_string()],
rows: vec![vec!["Speed".to_string(), "42".to_string()]],
})]);
let rendered = "| Metric | Value |\n| --- | --- |\n| Speed | 42 |\n";
let report = validate(&pages, &document, rendered);
assert!(
report.recovery_ratio() <= 1.0 + 1e-9,
"table scaffolding must not push recovery above 100%, got {}",
report.recovery_ratio()
);
assert!(
report.recovery_ratio() >= 0.99,
"expected ~100% once pipes/separator are stripped, got {}",
report.recovery_ratio()
);
assert!(report.passes());
}
#[test]
fn ocr_word_gap_merge_still_passes() {
let pages = vec![page(vec![
span("Boo", 50.0, 700.0, 18.0, 10.0),
span("k", 68.2, 700.0, 6.0, 10.0),
span("Reviews", 78.0, 700.0, 50.0, 10.0),
])];
let document = empty_document(vec![Block::Heading(Heading {
level: 1,
text: "Book Reviews".to_string(),
})]);
let report = validate(&pages, &document, "# Book Reviews\n");
assert!(
report.recovery_ratio() >= 0.99,
"word-gap re-tokenization must not be penalized, got {}",
report.recovery_ratio()
);
assert!(report.passes());
}
#[test]
fn stripped_running_head_keeps_recovery_ratio_honest() {
let head = "Confidential Draft Running Header";
let body = "Body sentence carrying the real content of the page.";
let pages: Vec<Page> = (1..=4)
.map(|n| Page {
number: n,
width: 600.0,
height: 800.0,
spans: vec![
span(head, 50.0, 770.0, 300.0, 10.0),
span(body, 50.0, 400.0, 400.0, 10.0),
],
})
.collect();
let rendered = format!("{body}\n").repeat(4);
let document = empty_document(vec![Block::Paragraph(Paragraph {
text: body.to_string(),
})]);
let report = validate(&pages, &document, &rendered);
assert!(
report.recovery_ratio() <= 1.0 + 1e-9,
"ratio must not exceed 1.0, got {}",
report.recovery_ratio()
);
assert!(
report.recovery_ratio() >= 0.98,
"discounting the stripped head on the extracted side must keep the \
ratio within PASS range, got {}",
report.recovery_ratio()
);
assert!(report.passes(), "a doc whose only loss is a running head must PASS");
let body_chars = content_char_count(body) * 4;
assert_eq!(
report.extracted_chars, body_chars,
"extracted side must count body only, not the stripped head"
);
}
#[test]
fn collapsed_figure_labels_keep_recovery_ratio_honest() {
let caption = "Fig. 1 System architecture of the platform";
let labels = [
"Sensor Array", "Data Ingestion", "Message Queue", "Stream Processor",
"Control Unit", "PID Controller", "Actuator Bank", "Feedback Loop",
"State Store", "Cache Layer", "Load Balancer", "API Gateway",
];
let mut spans = Vec::new();
for (i, label) in labels.iter().enumerate() {
let x = 50.0 + ((i * 97) % 450) as f32;
let y = 720.0 - (i as f32) * 12.0;
spans.push(span(label, x, y, 80.0, 10.0));
}
spans.push(span(caption, 50.0, 340.0, 400.0, 10.0));
let pages = vec![page(spans)];
let rendered = format!("{caption}\n");
let document = empty_document(vec![Block::Figure(crate::Figure {
caption: Some(caption.to_string()),
image_path: None,
})]);
let report = validate(&pages, &document, &rendered);
assert!(
report.recovery_ratio() <= 1.0 + 1e-9,
"ratio must not exceed 1.0, got {}",
report.recovery_ratio()
);
assert!(
report.recovery_ratio() >= 0.98,
"discounting collapsed labels on the extracted side must keep the \
ratio in PASS range, got {}",
report.recovery_ratio()
);
assert!(report.passes(), "a doc whose only loss is diagram labels must PASS");
assert_eq!(
report.extracted_chars,
content_char_count(caption),
"extracted side must count the caption only, not the collapsed labels"
);
}
#[test]
fn strip_rendered_markdown_syntax_removes_all_known_scaffolding() {
let markdown = "# Title\n\n\
Body paragraph.\n\n\
- First item\n\
1. Ordered item\n\n\
| A | B |\n\
| --- | --- |\n\
| 1 | 2 |\n\n\
> Quoted line\n\n\
```rust\n\
fn main() {}\n\
```\n\n\
Caption text.\n\n\
[figure]\n\n\
<!-- page 2 -->\n";
let stripped = strip_rendered_markdown_syntax(markdown);
assert!(!stripped.contains('#'));
assert!(!stripped.contains('|'));
assert!(!stripped.contains('>'));
assert!(!stripped.contains("```"));
assert!(!stripped.contains("<!-- page"));
assert!(!stripped.contains("[figure]"));
assert!(stripped.contains("Title"));
assert!(stripped.contains("Body paragraph."));
assert!(stripped.contains("First item"));
assert!(stripped.contains("Ordered item"));
assert!(stripped.contains("Quoted line"));
assert!(stripped.contains("fn main() {}"));
assert!(stripped.contains("Caption text."));
}
#[test]
fn table_pipes_are_stripped_but_a_literal_pipe_in_a_cell_survives_unescaped() {
assert_eq!(strip_table_pipes("| A | B |"), "A B");
assert_eq!(strip_table_pipes("| A\\|B | C |"), "A|B C");
}
#[test]
fn empty_extraction_reports_full_recovery_by_convention() {
let report = validate(&[], &empty_document(vec![]), "");
assert_eq!(report.recovery_ratio(), 1.0);
assert!(report.passes());
}
#[test]
fn page_anchor_scaffolding_does_not_inflate_recovery() {
let body1 = "First page body sentence carrying the real content.";
let body2 = "Second page body sentence carrying the real content.";
let pages = vec![
page_n(1, vec![span(body1, 50.0, 400.0, 400.0, 10.0)]),
page_n(2, vec![span(body2, 50.0, 400.0, 400.0, 10.0)]),
];
let document = Document {
title: None,
metadata: Metadata { source_pages: 2 },
block_pages: vec![1, 2],
blocks: vec![
Block::Paragraph(Paragraph { text: body1.to_string() }),
Block::Paragraph(Paragraph { text: body2.to_string() }),
],
citations: Vec::new(),
};
let rendered = format!("{body1}\n\n<!-- page 2 -->\n\n{body2}\n");
let report = validate(&pages, &document, &rendered);
assert!(
report.recovery_ratio() <= 1.0 + 1e-9,
"page anchors must not push recovery above 100%, got {}",
report.recovery_ratio()
);
assert!(
report.recovery_ratio() >= 0.98,
"expected ~100% once anchors are stripped, got {}",
report.recovery_ratio()
);
assert!(report.passes());
}
#[test]
fn per_page_recovery_is_reported_and_sums_to_the_document_wide_figure() {
let body1 = "Alpha content on the very first page here.";
let body2 = "Beta content on the second page as well now.";
let body3_full = "Gamma content on the third page that is mostly dropped downstream.";
let body3_rendered = "Gamma content"; let pages = vec![
page_n(1, vec![span(body1, 50.0, 400.0, 400.0, 10.0)]),
page_n(2, vec![span(body2, 50.0, 400.0, 400.0, 10.0)]),
page_n(3, vec![span(body3_full, 50.0, 400.0, 400.0, 10.0)]),
];
let document = Document {
title: None,
metadata: Metadata { source_pages: 3 },
block_pages: vec![1, 2, 3],
blocks: vec![
Block::Paragraph(Paragraph { text: body1.to_string() }),
Block::Paragraph(Paragraph { text: body2.to_string() }),
Block::Paragraph(Paragraph { text: body3_rendered.to_string() }),
],
citations: Vec::new(),
};
let rendered =
format!("{body1}\n\n<!-- page 2 -->\n\n{body2}\n\n<!-- page 3 -->\n\n{body3_rendered}\n");
let report = validate(&pages, &document, &rendered);
assert_eq!(report.per_page.len(), 3);
assert_eq!(
report.per_page.iter().map(|p| p.page).collect::<Vec<_>>(),
vec![1, 2, 3]
);
assert!(report.per_page[0].recovery_ratio() >= 0.99);
assert!(report.per_page[1].recovery_ratio() >= 0.99);
assert!(
report.per_page[2].recovery_ratio() < 0.5,
"the truncated page 3 must show a low per-page ratio, got {}",
report.per_page[2].recovery_ratio()
);
let sum_extracted: usize = report.per_page.iter().map(|p| p.extracted_chars).sum();
let sum_rendered: usize = report.per_page.iter().map(|p| p.rendered_chars).sum();
assert_eq!(sum_extracted, report.extracted_chars);
assert_eq!(sum_rendered, report.rendered_chars);
let doc_ratio = report.recovery_ratio();
assert!(doc_ratio < report.per_page[0].recovery_ratio());
assert!(doc_ratio > report.per_page[2].recovery_ratio());
}
#[test]
fn a_specific_page_anchor_is_literal_and_greppable_in_the_rendered_output() {
let rendered = "Page one.\n\n<!-- page 2 -->\n\nPage two.\n";
assert!(rendered.contains("<!-- page 2 -->"));
assert_eq!(parse_page_anchor("<!-- page 2 -->"), Some(2));
assert_eq!(parse_page_anchor("<!-- page 717 -->"), Some(717));
assert_eq!(parse_page_anchor("<!-- note -->"), None);
assert_eq!(parse_page_anchor("plain text"), None);
}
#[test]
fn a_document_without_page_provenance_reports_no_per_page_breakdown() {
let document = Document {
title: None,
metadata: Metadata::default(),
block_pages: Vec::new(),
blocks: vec![Block::Paragraph(Paragraph { text: "Body.".to_string() })],
citations: Vec::new(),
};
let pages = vec![page(vec![span("Body.", 50.0, 400.0, 100.0, 10.0)])];
let report = validate(&pages, &document, "Body.\n");
assert!(report.per_page.is_empty());
}
}