kopitiam_document/validation/
mod.rs1mod report;
2
3pub use report::ConversionReport;
4
5use kopitiam_pdf::{Page, TextSpan};
6
7use crate::{Block, Document};
8
9const FIGURE_PLACEHOLDER: &str = "[Figure omitted from Markdown output]";
17
18pub fn validate(pages: &[Page], document: &Document, rendered_markdown: &str) -> ConversionReport {
27 let extracted_words = pages
28 .iter()
29 .flat_map(|page| &page.spans)
30 .map(|span| word_count(&span.text))
31 .sum();
32
33 let mut headings_found = 0;
34 let mut lists_found = 0;
35 let mut tables_found = 0;
36
37 for block in &document.blocks {
38 match block {
39 Block::Heading(_) => headings_found += 1,
40 Block::List(_) => lists_found += 1,
41 Block::Table(_) => tables_found += 1,
42 _ => {}
43 }
44 }
45
46 ConversionReport {
47 pages: pages.len(),
48 extracted_words,
49 rendered_words: word_count(rendered_markdown),
50 extracted_chars: extracted_content_chars(pages),
51 rendered_chars: rendered_content_chars(rendered_markdown),
52 headings_found,
53 lists_found,
54 tables_found,
55 citations_found: document.citations.len(),
56 }
57}
58
59fn word_count(text: &str) -> usize {
60 text.split_whitespace().count()
61}
62
63fn content_char_count(text: &str) -> usize {
64 text.chars().filter(|c| !c.is_whitespace()).count()
65}
66
67fn extracted_content_chars(pages: &[Page]) -> usize {
81 let mut total = 0;
82 for page in pages {
83 let spans = &page.spans;
84 for (i, span) in spans.iter().enumerate() {
85 let text = span.text.as_str();
86 let drop_trailing_hyphen = spans
87 .get(i + 1)
88 .is_some_and(|next| is_wrap_hyphen(span, next));
89 let counted = if drop_trailing_hyphen {
90 &text[..text.len() - 1]
91 } else {
92 text
93 };
94 total += content_char_count(counted);
95 }
96 }
97 total
98}
99
100fn is_wrap_hyphen(current: &TextSpan, next: &TextSpan) -> bool {
120 let ends_with_hyphen = current.text.ends_with('-');
121 let same_line_tolerance = current.font_size.max(next.font_size) * 0.4;
122 let different_line = (current.y - next.y).abs() > same_line_tolerance;
123 let continues_lowercase = next.text.chars().next().is_some_and(char::is_lowercase);
124 ends_with_hyphen && different_line && continues_lowercase
125}
126
127fn rendered_content_chars(markdown: &str) -> usize {
128 content_char_count(&strip_rendered_markdown_syntax(markdown))
129}
130
131fn strip_rendered_markdown_syntax(markdown: &str) -> String {
149 let mut out = String::with_capacity(markdown.len());
150 for line in markdown.lines() {
151 let trimmed = line.trim();
152
153 if trimmed.starts_with("```") {
154 continue; }
156 if trimmed == FIGURE_PLACEHOLDER {
157 continue; }
159 if is_table_separator_line(trimmed) {
160 continue; }
162
163 let content = strip_heading_hashes(trimmed);
164 let content = content
165 .strip_prefix("> ")
166 .or_else(|| content.strip_prefix('>'))
167 .unwrap_or(content);
168 let content = strip_list_marker(content);
169 let content = strip_table_pipes(content);
170
171 out.push_str(&content);
172 out.push('\n');
173 }
174 out
175}
176
177fn strip_heading_hashes(line: &str) -> &str {
180 let hashes = line.chars().take_while(|&c| c == '#').count();
181 if (1..=6).contains(&hashes) && line.as_bytes().get(hashes) == Some(&b' ') {
182 &line[hashes + 1..]
183 } else {
184 line
185 }
186}
187
188fn strip_list_marker(line: &str) -> &str {
196 if let Some(rest) = line.strip_prefix("- ") {
197 return rest;
198 }
199 let digits = line.chars().take_while(|c| c.is_ascii_digit()).count();
200 if digits > 0
201 && let Some(rest) = line[digits..].strip_prefix(". ")
202 {
203 return rest;
204 }
205 line
206}
207
208fn strip_table_pipes(line: &str) -> String {
214 let Some(inner) = line.strip_prefix("| ").and_then(|s| s.strip_suffix(" |")) else {
215 return line.to_string();
216 };
217 inner
218 .split(" | ")
219 .map(|cell| cell.replace("\\|", "|"))
220 .collect::<Vec<_>>()
221 .join(" ")
222}
223
224fn is_table_separator_line(line: &str) -> bool {
229 line.starts_with('|')
230 && line.ends_with('|')
231 && line.contains('-')
232 && line.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '))
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use crate::{Heading, Metadata, Paragraph};
239
240 fn span(text: &str, x: f32, y: f32, width: f32, font_size: f32) -> TextSpan {
241 TextSpan {
242 text: text.to_string(),
243 x,
244 y,
245 width,
246 height: font_size,
247 font_size,
248 font_name: None,
249 ..TextSpan::default()
250 }
251 }
252
253 fn page(spans: Vec<TextSpan>) -> Page {
254 Page {
255 number: 1,
256 width: 600.0,
257 height: 800.0,
258 spans,
259 }
260 }
261
262 fn empty_document(blocks: Vec<Block>) -> Document {
263 Document {
264 title: None,
265 metadata: Metadata { source_pages: 1 },
266 block_pages: vec![1; blocks.len()],
267 blocks,
268 citations: Vec::new(),
269 }
270 }
271
272 #[test]
275 fn dropped_content_fails() {
276 let pages = vec![page(vec![span(
277 "This paragraph has plenty of words that never make it into the output.",
278 50.0,
279 700.0,
280 500.0,
281 10.0,
282 )])];
283 let document = empty_document(vec![Block::Paragraph(Paragraph {
284 text: "This paragraph has".to_string(),
285 })]);
286 let report = validate(&pages, &document, "This paragraph has\n");
287
288 assert!(
289 report.recovery_ratio() < 0.5,
290 "expected a low ratio for dropped content, got {}",
291 report.recovery_ratio()
292 );
293 assert!(!report.passes(), "truncated content must not PASS");
294 }
295
296 #[test]
299 fn repaired_hyphenation_still_passes() {
300 let pages = vec![page(vec![
304 span("develop-", 50.0, 700.0, 60.0, 10.0),
305 span("ment continues steadily.", 50.0, 688.0, 150.0, 10.0),
306 ])];
307 let document = empty_document(vec![Block::Paragraph(Paragraph {
308 text: "development continues steadily.".to_string(),
309 })]);
310 let report = validate(&pages, &document, "development continues steadily.\n");
311
312 assert!(
313 report.recovery_ratio() >= 0.99,
314 "hyphenation repair must not be penalized, got {}",
315 report.recovery_ratio()
316 );
317 assert!(report.passes());
318 }
319
320 #[test]
321 fn a_real_compound_hyphen_is_not_stripped_from_either_side() {
322 let pages = vec![page(vec![
325 span("Anglo-", 50.0, 700.0, 40.0, 10.0),
326 span("Saxon history.", 50.0, 688.0, 90.0, 10.0),
327 ])];
328 let document = empty_document(vec![Block::Paragraph(Paragraph {
329 text: "Anglo-Saxon history.".to_string(),
330 })]);
331 let report = validate(&pages, &document, "Anglo-Saxon history.\n");
332
333 assert!(
334 report.recovery_ratio() >= 0.99,
335 "expected ~100%, got {}",
336 report.recovery_ratio()
337 );
338 }
339
340 #[test]
343 fn table_pipe_syntax_does_not_inflate_recovery() {
344 let pages = vec![page(vec![
345 span("Metric", 50.0, 700.0, 60.0, 10.0),
346 span("Value", 200.0, 700.0, 60.0, 10.0),
347 span("Speed", 50.0, 688.0, 60.0, 10.0),
348 span("42", 200.0, 688.0, 60.0, 10.0),
349 ])];
350 let document = empty_document(vec![Block::Table(crate::Table {
351 headers: vec!["Metric".to_string(), "Value".to_string()],
352 rows: vec![vec!["Speed".to_string(), "42".to_string()]],
353 })]);
354 let rendered = "| Metric | Value |\n| --- | --- |\n| Speed | 42 |\n";
355 let report = validate(&pages, &document, rendered);
356
357 assert!(
358 report.recovery_ratio() <= 1.0 + 1e-9,
359 "table scaffolding must not push recovery above 100%, got {}",
360 report.recovery_ratio()
361 );
362 assert!(
363 report.recovery_ratio() >= 0.99,
364 "expected ~100% once pipes/separator are stripped, got {}",
365 report.recovery_ratio()
366 );
367 assert!(report.passes());
368 }
369
370 #[test]
373 fn ocr_word_gap_merge_still_passes() {
374 let pages = vec![page(vec![
378 span("Boo", 50.0, 700.0, 18.0, 10.0),
379 span("k", 68.2, 700.0, 6.0, 10.0),
380 span("Reviews", 78.0, 700.0, 50.0, 10.0),
381 ])];
382 let document = empty_document(vec![Block::Heading(Heading {
383 level: 1,
384 text: "Book Reviews".to_string(),
385 })]);
386 let report = validate(&pages, &document, "# Book Reviews\n");
387
388 assert!(
389 report.recovery_ratio() >= 0.99,
390 "word-gap re-tokenization must not be penalized, got {}",
391 report.recovery_ratio()
392 );
393 assert!(report.passes());
394 }
395
396 #[test]
399 fn strip_rendered_markdown_syntax_removes_all_known_scaffolding() {
400 let markdown = "# Title\n\n\
401 Body paragraph.\n\n\
402 - First item\n\
403 1. Ordered item\n\n\
404 | A | B |\n\
405 | --- | --- |\n\
406 | 1 | 2 |\n\n\
407 > Quoted line\n\n\
408 ```rust\n\
409 fn main() {}\n\
410 ```\n\n\
411 Caption text.\n\n\
412 [Figure omitted from Markdown output]\n";
413 let stripped = strip_rendered_markdown_syntax(markdown);
414
415 assert!(!stripped.contains('#'));
416 assert!(!stripped.contains('|'));
417 assert!(!stripped.contains('>'));
418 assert!(!stripped.contains("```"));
419 assert!(!stripped.contains("[Figure omitted"));
420 assert!(stripped.contains("Title"));
421 assert!(stripped.contains("Body paragraph."));
422 assert!(stripped.contains("First item"));
423 assert!(stripped.contains("Ordered item"));
424 assert!(stripped.contains("Quoted line"));
425 assert!(stripped.contains("fn main() {}"));
426 assert!(stripped.contains("Caption text."));
427 }
428
429 #[test]
430 fn table_pipes_are_stripped_but_a_literal_pipe_in_a_cell_survives_unescaped() {
431 assert_eq!(strip_table_pipes("| A | B |"), "A B");
432 assert_eq!(strip_table_pipes("| A\\|B | C |"), "A|B C");
433 }
434
435 #[test]
436 fn empty_extraction_reports_full_recovery_by_convention() {
437 let report = validate(&[], &empty_document(vec![]), "");
438 assert_eq!(report.recovery_ratio(), 1.0);
439 assert!(report.passes());
440 }
441}