Skip to main content

kopitiam_document/validation/
mod.rs

1mod report;
2
3pub use report::ConversionReport;
4
5use kopitiam_pdf::{Page, TextSpan};
6
7use crate::{Block, Document};
8
9/// Markdown text `Figure::render` emits in place of an image (see
10/// `kopitiam-markdown`'s `renderer.rs`). It is renderer boilerplate, not
11/// content recovered from the source PDF, so [`strip_rendered_markdown_syntax`]
12/// removes it before counting. Kept as a literal here rather than a shared
13/// constant because `kopitiam-document` does not depend on `kopitiam-markdown`
14/// (dependencies flow the other way); if the renderer's wording changes this
15/// constant must be updated too, which is a known, cheap-to-miss coupling.
16const FIGURE_PLACEHOLDER: &str = "[Figure omitted from Markdown output]";
17
18/// Compares what was extracted against what was rendered, and tallies the
19/// block types found, so every conversion produces an auditable report
20/// rather than a silent best-effort guess.
21///
22/// The headline recovery signal ([`ConversionReport::recovery_ratio`]) is a
23/// non-whitespace character count, not a word count -- see that method's
24/// rustdoc for why. Word counts are still gathered and reported alongside
25/// it as an informational secondary signal (see kopitiam-wwr).
26pub 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
67/// Sums non-whitespace characters across every extracted `TextSpan`, on
68/// every page, treating a soft line-wrap hyphen (see [`is_wrap_hyphen`]) as
69/// not-content so it does not count against recovery once reconstruction
70/// repairs it away.
71///
72/// Non-whitespace characters are used, rather than whitespace-delimited
73/// words, because *how* the text is tokenized (one span per word, one span
74/// per OCR glyph run, one span per table cell, ...) is an artifact of PDF
75/// extraction and has nothing to do with whether any content was lost.
76/// Concatenating spans with or without a separating space never changes a
77/// non-whitespace character count, so this signal is naturally immune to
78/// re-tokenization -- which is exactly the failure mode that made the old
79/// word-count ratio unreliable (see kopitiam-wwr).
80fn 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
100/// True when `current`'s trailing hyphen is a soft line-wrap artifact
101/// rather than real content: `next` starts a new visual line -- its `y`
102/// differs from `current`'s by more than "same line" tolerance -- and
103/// begins with a lowercase letter.
104///
105/// This mirrors the rule `reconstruction::paragraphs::append_line` uses to
106/// repair the same hyphen when assembling prose: a hyphen immediately
107/// before a capitalized word (e.g. "Anglo-Saxon") is a real compound and is
108/// left as content on both sides of the comparison, while a hyphen at a
109/// justified line's right margin followed by the wrapped word's remainder
110/// ("develop-" / "ment") is not -- reconstruction deletes that hyphen when
111/// it rejoins the word, so counting it on the extracted side would be an
112/// artifact mismatch, not lost content.
113///
114/// The two rules are independent implementations of the same idea rather
115/// than shared code: reconstruction operates on already-grouped `Line`s,
116/// while this operates directly on raw `TextSpan`s (validation must stay
117/// usable even if reconstruction's internal grouping changes). Kept in sync
118/// by the hyphenation unit tests below and in `reconstruction::paragraphs`.
119fn 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
131/// Strips Markdown scaffolding syntax that `kopitiam-markdown`'s renderer
132/// adds -- heading hashes, list markers, table pipes and separator rows,
133/// blockquote markers, code fences, and the figure-omitted placeholder --
134/// before counting rendered content.
135///
136/// This matters in both directions. If scaffolding were left in, a
137/// document with many short table cells could push `recovery_ratio` above
138/// 100% (every `|` and `---` the renderer adds counts as "recovered"
139/// content that never existed in the source PDF), which would mask real
140/// content loss elsewhere in the same document -- the opposite failure
141/// mode from the old metric's false FAILs, but just as untrustworthy.
142///
143/// This is line-oriented, regex-free text surgery rather than a full
144/// Markdown parser: it recognizes exactly the small, fixed vocabulary of
145/// syntax `kopitiam-markdown`'s renderer (`renderer.rs`) is known to
146/// produce, not arbitrary Markdown. It does not attempt to reparse or
147/// validate the rendered output.
148fn 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; // code fence delimiter, not content
155        }
156        if trimmed == FIGURE_PLACEHOLDER {
157            continue; // renderer boilerplate, never present in the source PDF
158        }
159        if is_table_separator_line(trimmed) {
160            continue; // e.g. "| --- | --- |"
161        }
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
177/// Strips a leading `#`..`######` heading marker (`Heading::render` always
178/// emits `"{hashes} {text}"`).
179fn 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
188/// Strips a leading unordered (`"- "`) or ordered (`"1. "`) list marker
189/// (`List::render`'s two branches). A plain-prose line that coincidentally
190/// starts the same way (a sentence beginning "12. " or a paragraph opening
191/// with an en-dash rendered as `"- "`) is stripped too; this is a known,
192/// deliberately conservative false-positive -- it can only ever remove a
193/// few characters from the *rendered* side, which pushes the ratio down,
194/// never up, so it cannot turn real content loss into a false PASS.
195fn 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
208/// Strips the leading `"| "` / trailing `" |"` a table row (`render_row`)
209/// adds, splits cells on the `" | "` separator it joins them with, and
210/// unescapes `"\|"` back to a literal `|` (the escaping `escape_cell`
211/// applies to a cell containing a real pipe character, so unescaping keeps
212/// that character counted as content rather than discarding it as syntax).
213fn 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
224/// True for a table separator row (`render_separator`'s `"| --- | --- |"`):
225/// a line made up of only `|`, `-`, `:`, and spaces, containing at least one
226/// dash. Real content never renders as a bare line of dashes and pipes, so
227/// this cannot misfire on prose.
228fn 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    // -- headline signal: content genuinely dropped => low ratio => FAIL --
273
274    #[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    // -- hyphenation repaired across a line break => still ~100% => PASS --
297
298    #[test]
299    fn repaired_hyphenation_still_passes() {
300        // Two spans on different lines simulate a justified paragraph where
301        // "development" wraps as "develop-" / "ment"; reconstruction joins
302        // them back into one word and drops the hyphen.
303        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        // "Anglo-" / "Saxon" is a genuine compound, not a line-wrap; both
323        // reconstruction and this metric must leave the hyphen as content.
324        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    // -- a table rendered with pipe syntax => pipes don't inflate ratio => PASS --
341
342    #[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    // -- OCR word-gap merge ("hel lo" -> "hello") => still PASS --
371
372    #[test]
373    fn ocr_word_gap_merge_still_passes() {
374        // "Boo" and "k" simulate an OCR text layer that split one word
375        // into two spans (see reconstruction::group_lines); reconstruction
376        // reads them back as "Book" with no space.
377        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    // -- normalization building blocks --
397
398    #[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}