mod report;
pub use report::ConversionReport;
use kopitiam_pdf::{Page, TextSpan};
use crate::{Block, Document};
const FIGURE_PLACEHOLDER: &str = "[Figure omitted from Markdown output]";
pub fn validate(pages: &[Page], document: &Document, rendered_markdown: &str) -> ConversionReport {
let extracted_words = pages
.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(pages),
rendered_chars: rendered_content_chars(rendered_markdown),
headings_found,
lists_found,
tables_found,
citations_found: document.citations.len(),
}
}
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 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 {
number: 1,
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 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 omitted from Markdown output]\n";
let stripped = strip_rendered_markdown_syntax(markdown);
assert!(!stripped.contains('#'));
assert!(!stripped.contains('|'));
assert!(!stripped.contains('>'));
assert!(!stripped.contains("```"));
assert!(!stripped.contains("[Figure omitted"));
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());
}
}