Skip to main content

kopitiam_document/validation/
mod.rs

1mod report;
2
3pub use report::{ConversionReport, PageRecovery};
4
5use std::collections::BTreeMap;
6
7use kopitiam_pdf::{Page, TextSpan};
8
9use crate::reconstruction::{collapse_figure_regions, strip_marginalia};
10use crate::{Block, Document};
11
12/// Marker text `Figure::render` emits for a figure that has **no** caption (see
13/// `kopitiam-markdown`'s `renderer.rs`). It is renderer boilerplate, not
14/// content recovered from the source PDF, so [`strip_rendered_markdown_syntax`]
15/// removes it before counting. Kept as a literal here rather than a shared
16/// constant because `kopitiam-document` does not depend on `kopitiam-markdown`
17/// (dependencies flow the other way); if the renderer's wording changes this
18/// constant must be updated too, which is a known, cheap-to-miss coupling
19/// (`kopitiam_token_max.md` §2.3). A *captioned* figure now renders only its
20/// caption (real extracted content), so no placeholder is emitted for it.
21const FIGURE_PLACEHOLDER: &str = "[figure]";
22
23/// Compares what was extracted against what was rendered, and tallies the
24/// block types found, so every conversion produces an auditable report
25/// rather than a silent best-effort guess.
26///
27/// The headline recovery signal ([`ConversionReport::recovery_ratio`]) is a
28/// non-whitespace character count, not a word count -- see that method's
29/// rustdoc for why. Word counts are still gathered and reported alongside
30/// it as an informational secondary signal (see kopitiam-wwr).
31pub fn validate(pages: &[Page], document: &Document, rendered_markdown: &str) -> ConversionReport {
32    // Recovery-ratio honesty (`kopitiam_token_max.md` §2.1). Reconstruction
33    // deletes running heads, running feet, and bare page numbers, so those
34    // characters are legitimately absent from `rendered_markdown`. If the
35    // extracted side still counted them, `recovery_ratio` would sink below the
36    // 0.98 PASS threshold on every document that has a running head -- masking
37    // real content loss behind noise we intended to drop. `strip_marginalia` is
38    // a pure, deterministic function of `pages`, and reconstruction ran it over
39    // exactly these same pages, so rerunning it here removes byte-for-byte the
40    // same spans. The removed header/footer text is then absent from *both*
41    // `extracted_chars` and `rendered_chars`, and the ratio measures only body
42    // recovery, staying honestly inside [0.98, 1.0].
43    //
44    // The identical reasoning applies to figure-region collapsing: reconstruction
45    // drops scattered diagram-label spans (keeping only the caption), so those
46    // labels are legitimately absent from `rendered_markdown`. `collapse_figure_regions`
47    // is likewise pure and deterministic in its input pages, and reconstruction
48    // ran it over exactly these same (already marginalia-stripped) pages in the
49    // same order, so rerunning the same composition here drops byte-for-byte the
50    // same label spans -- discounting them from `extracted_chars` too, exactly
51    // as I-C does for running heads.
52    let stripped = strip_marginalia(pages);
53    let stripped = collapse_figure_regions(&stripped);
54
55    let extracted_words = stripped
56        .iter()
57        .flat_map(|page| &page.spans)
58        .map(|span| word_count(&span.text))
59        .sum();
60
61    let mut headings_found = 0;
62    let mut lists_found = 0;
63    let mut tables_found = 0;
64
65    for block in &document.blocks {
66        match block {
67            Block::Heading(_) => headings_found += 1,
68            Block::List(_) => lists_found += 1,
69            Block::Table(_) => tables_found += 1,
70            _ => {}
71        }
72    }
73
74    ConversionReport {
75        pages: pages.len(),
76        extracted_words,
77        rendered_words: word_count(rendered_markdown),
78        extracted_chars: extracted_content_chars(&stripped),
79        rendered_chars: rendered_content_chars(rendered_markdown),
80        headings_found,
81        lists_found,
82        tables_found,
83        citations_found: document.citations.len(),
84        per_page: per_page_recovery(&stripped, document, rendered_markdown),
85    }
86}
87
88/// Attributes character recovery to individual source pages using the
89/// `<!-- page N -->` anchors the renderer emits at page boundaries
90/// (`kopitiam_token_max.md` §8 card I-B).
91///
92/// # How pages are numbered
93///
94/// Both sides use the **same** 1-based page numbering: `reconstruct` labels a
95/// block with `page_index + 1` (its position in the input `pages`, see
96/// `reconstruction::merge_page_breaks`), and the renderer's anchors carry those
97/// same `block_pages` numbers. `strip_marginalia`/`collapse_figure_regions`
98/// preserve the one-page-per-input-page mapping, so `stripped[i]` is page
99/// `i + 1` — the same number a block on that page records. That alignment is
100/// what lets the extracted side (indexed by position) and the rendered side
101/// (split on anchors) be compared page-for-page.
102///
103/// # Honesty (`kopitiam_token_max.md` §2.1)
104///
105/// The extracted side is the *same* `strip_marginalia` + `collapse_figure_regions`
106/// pages the document-wide count uses, summed per page; the rendered side is the
107/// *same* `strip_rendered_markdown_syntax` normalization, applied per anchor
108/// segment. The anchor lines strip to nothing, so the per-page figures partition
109/// the document-wide totals exactly (they sum back to them).
110///
111/// Returns an empty vec for a document with no page provenance
112/// (`block_pages` empty): there is no page to attribute content to, and
113/// guessing page 1 for everything would be dishonest.
114fn per_page_recovery(
115    stripped: &[Page],
116    document: &Document,
117    rendered_markdown: &str,
118) -> Vec<PageRecovery> {
119    let Some(&first_page) = document.block_pages.first() else {
120        return Vec::new();
121    };
122
123    let rendered_by_page = rendered_chars_by_page(rendered_markdown, first_page);
124
125    (0..stripped.len())
126        .map(|i| {
127            let page = i + 1;
128            PageRecovery {
129                page,
130                extracted_chars: extracted_content_chars(std::slice::from_ref(&stripped[i])),
131                rendered_chars: rendered_by_page.get(&page).copied().unwrap_or(0),
132            }
133        })
134        .collect()
135}
136
137/// Splits the rendered Markdown on `<!-- page N -->` anchors and returns the
138/// non-whitespace content-character count per page number.
139///
140/// The segment *before* the first anchor belongs to `first_page` (the page the
141/// document's first block starts on — no anchor precedes the first page); each
142/// segment after an anchor belongs to that anchor's page. Every segment is run
143/// through [`strip_rendered_markdown_syntax`] before counting, exactly like the
144/// document-wide figure, so the per-page counts sum back to it.
145fn rendered_chars_by_page(markdown: &str, first_page: usize) -> BTreeMap<usize, usize> {
146    let mut by_page: BTreeMap<usize, usize> = BTreeMap::new();
147    let mut current_page = first_page;
148    let mut segment = String::new();
149
150    let mut flush = |page: usize, segment: &mut String| {
151        let chars = content_char_count(&strip_rendered_markdown_syntax(segment));
152        *by_page.entry(page).or_insert(0) += chars;
153        segment.clear();
154    };
155
156    for line in markdown.lines() {
157        if let Some(page) = parse_page_anchor(line.trim()) {
158            flush(current_page, &mut segment);
159            current_page = page;
160        } else {
161            segment.push_str(line);
162            segment.push('\n');
163        }
164    }
165    flush(current_page, &mut segment);
166
167    by_page
168}
169
170/// Parses a `<!-- page N -->` page-boundary anchor line, returning `N`.
171///
172/// The literal form is **duplicated** from `kopitiam-markdown`'s renderer
173/// (`page_anchor`), the same way [`FIGURE_PLACEHOLDER`] is: `kopitiam-document`
174/// does not depend on `kopitiam-markdown` (dependencies flow the other way), so
175/// the two are kept in sync by tests on both sides rather than a shared
176/// constant (`kopitiam_token_max.md` §2.3). If the renderer's anchor wording
177/// changes, this must change with it.
178fn parse_page_anchor(line: &str) -> Option<usize> {
179    line.strip_prefix("<!-- page ")?
180        .strip_suffix(" -->")?
181        .parse()
182        .ok()
183}
184
185fn word_count(text: &str) -> usize {
186    text.split_whitespace().count()
187}
188
189fn content_char_count(text: &str) -> usize {
190    text.chars().filter(|c| !c.is_whitespace()).count()
191}
192
193/// Sums non-whitespace characters across every extracted `TextSpan`, on
194/// every page, treating a soft line-wrap hyphen (see [`is_wrap_hyphen`]) as
195/// not-content so it does not count against recovery once reconstruction
196/// repairs it away.
197///
198/// Non-whitespace characters are used, rather than whitespace-delimited
199/// words, because *how* the text is tokenized (one span per word, one span
200/// per OCR glyph run, one span per table cell, ...) is an artifact of PDF
201/// extraction and has nothing to do with whether any content was lost.
202/// Concatenating spans with or without a separating space never changes a
203/// non-whitespace character count, so this signal is naturally immune to
204/// re-tokenization -- which is exactly the failure mode that made the old
205/// word-count ratio unreliable (see kopitiam-wwr).
206fn extracted_content_chars(pages: &[Page]) -> usize {
207    let mut total = 0;
208    for page in pages {
209        let spans = &page.spans;
210        for (i, span) in spans.iter().enumerate() {
211            let text = span.text.as_str();
212            let drop_trailing_hyphen = spans
213                .get(i + 1)
214                .is_some_and(|next| is_wrap_hyphen(span, next));
215            let counted = if drop_trailing_hyphen {
216                &text[..text.len() - 1]
217            } else {
218                text
219            };
220            total += content_char_count(counted);
221        }
222    }
223    total
224}
225
226/// True when `current`'s trailing hyphen is a soft line-wrap artifact
227/// rather than real content: `next` starts a new visual line -- its `y`
228/// differs from `current`'s by more than "same line" tolerance -- and
229/// begins with a lowercase letter.
230///
231/// This mirrors the rule `reconstruction::paragraphs::append_line` uses to
232/// repair the same hyphen when assembling prose: a hyphen immediately
233/// before a capitalized word (e.g. "Anglo-Saxon") is a real compound and is
234/// left as content on both sides of the comparison, while a hyphen at a
235/// justified line's right margin followed by the wrapped word's remainder
236/// ("develop-" / "ment") is not -- reconstruction deletes that hyphen when
237/// it rejoins the word, so counting it on the extracted side would be an
238/// artifact mismatch, not lost content.
239///
240/// The two rules are independent implementations of the same idea rather
241/// than shared code: reconstruction operates on already-grouped `Line`s,
242/// while this operates directly on raw `TextSpan`s (validation must stay
243/// usable even if reconstruction's internal grouping changes). Kept in sync
244/// by the hyphenation unit tests below and in `reconstruction::paragraphs`.
245fn is_wrap_hyphen(current: &TextSpan, next: &TextSpan) -> bool {
246    let ends_with_hyphen = current.text.ends_with('-');
247    let same_line_tolerance = current.font_size.max(next.font_size) * 0.4;
248    let different_line = (current.y - next.y).abs() > same_line_tolerance;
249    let continues_lowercase = next.text.chars().next().is_some_and(char::is_lowercase);
250    ends_with_hyphen && different_line && continues_lowercase
251}
252
253fn rendered_content_chars(markdown: &str) -> usize {
254    content_char_count(&strip_rendered_markdown_syntax(markdown))
255}
256
257/// Strips Markdown scaffolding syntax that `kopitiam-markdown`'s renderer
258/// adds -- heading hashes, list markers, table pipes and separator rows,
259/// blockquote markers, code fences, and the figure-omitted placeholder --
260/// before counting rendered content.
261///
262/// This matters in both directions. If scaffolding were left in, a
263/// document with many short table cells could push `recovery_ratio` above
264/// 100% (every `|` and `---` the renderer adds counts as "recovered"
265/// content that never existed in the source PDF), which would mask real
266/// content loss elsewhere in the same document -- the opposite failure
267/// mode from the old metric's false FAILs, but just as untrustworthy.
268///
269/// This is line-oriented, regex-free text surgery rather than a full
270/// Markdown parser: it recognizes exactly the small, fixed vocabulary of
271/// syntax `kopitiam-markdown`'s renderer (`renderer.rs`) is known to
272/// produce, not arbitrary Markdown. It does not attempt to reparse or
273/// validate the rendered output.
274fn strip_rendered_markdown_syntax(markdown: &str) -> String {
275    let mut out = String::with_capacity(markdown.len());
276    for line in markdown.lines() {
277        let trimmed = line.trim();
278
279        if trimmed.starts_with("```") {
280            continue; // code fence delimiter, not content
281        }
282        if trimmed == FIGURE_PLACEHOLDER {
283            continue; // renderer boilerplate, never present in the source PDF
284        }
285        if parse_page_anchor(trimmed).is_some() {
286            // Page-boundary anchor (`<!-- page N -->`, I-B). The renderer adds it
287            // for navigation/citation; it is not content from the source PDF, so
288            // it must not inflate `rendered_chars` and push the ratio above 1.0
289            // (`kopitiam_token_max.md` §2.1). Stripping it here also makes the
290            // per-page split (`rendered_chars_by_page`) sum back to the
291            // document-wide total, since the anchor lines count as zero.
292            continue;
293        }
294        if is_table_separator_line(trimmed) {
295            continue; // e.g. "| --- | --- |"
296        }
297
298        let content = strip_heading_hashes(trimmed);
299        let content = content
300            .strip_prefix("> ")
301            .or_else(|| content.strip_prefix('>'))
302            .unwrap_or(content);
303        let content = strip_list_marker(content);
304        let content = strip_table_pipes(content);
305
306        out.push_str(&content);
307        out.push('\n');
308    }
309    out
310}
311
312/// Strips a leading `#`..`######` heading marker (`Heading::render` always
313/// emits `"{hashes} {text}"`).
314fn strip_heading_hashes(line: &str) -> &str {
315    let hashes = line.chars().take_while(|&c| c == '#').count();
316    if (1..=6).contains(&hashes) && line.as_bytes().get(hashes) == Some(&b' ') {
317        &line[hashes + 1..]
318    } else {
319        line
320    }
321}
322
323/// Strips a leading unordered (`"- "`) or ordered (`"1. "`) list marker
324/// (`List::render`'s two branches). A plain-prose line that coincidentally
325/// starts the same way (a sentence beginning "12. " or a paragraph opening
326/// with an en-dash rendered as `"- "`) is stripped too; this is a known,
327/// deliberately conservative false-positive -- it can only ever remove a
328/// few characters from the *rendered* side, which pushes the ratio down,
329/// never up, so it cannot turn real content loss into a false PASS.
330fn strip_list_marker(line: &str) -> &str {
331    if let Some(rest) = line.strip_prefix("- ") {
332        return rest;
333    }
334    let digits = line.chars().take_while(|c| c.is_ascii_digit()).count();
335    if digits > 0
336        && let Some(rest) = line[digits..].strip_prefix(". ")
337    {
338        return rest;
339    }
340    line
341}
342
343/// Strips the leading `"| "` / trailing `" |"` a table row (`render_row`)
344/// adds, splits cells on the `" | "` separator it joins them with, and
345/// unescapes `"\|"` back to a literal `|` (the escaping `escape_cell`
346/// applies to a cell containing a real pipe character, so unescaping keeps
347/// that character counted as content rather than discarding it as syntax).
348fn strip_table_pipes(line: &str) -> String {
349    let Some(inner) = line.strip_prefix("| ").and_then(|s| s.strip_suffix(" |")) else {
350        return line.to_string();
351    };
352    inner
353        .split(" | ")
354        .map(|cell| cell.replace("\\|", "|"))
355        .collect::<Vec<_>>()
356        .join(" ")
357}
358
359/// True for a table separator row (`render_separator`'s `"| --- | --- |"`):
360/// a line made up of only `|`, `-`, `:`, and spaces, containing at least one
361/// dash. Real content never renders as a bare line of dashes and pipes, so
362/// this cannot misfire on prose.
363fn is_table_separator_line(line: &str) -> bool {
364    line.starts_with('|')
365        && line.ends_with('|')
366        && line.contains('-')
367        && line.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '))
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::{Heading, Metadata, Paragraph};
374
375    fn span(text: &str, x: f32, y: f32, width: f32, font_size: f32) -> TextSpan {
376        TextSpan {
377            text: text.to_string(),
378            x,
379            y,
380            width,
381            height: font_size,
382            font_size,
383            font_name: None,
384            ..TextSpan::default()
385        }
386    }
387
388    fn page(spans: Vec<TextSpan>) -> Page {
389        page_n(1, spans)
390    }
391
392    fn page_n(number: usize, spans: Vec<TextSpan>) -> Page {
393        Page {
394            number,
395            width: 600.0,
396            height: 800.0,
397            spans,
398        }
399    }
400
401    fn empty_document(blocks: Vec<Block>) -> Document {
402        Document {
403            title: None,
404            metadata: Metadata { source_pages: 1 },
405            block_pages: vec![1; blocks.len()],
406            blocks,
407            citations: Vec::new(),
408        }
409    }
410
411    // -- headline signal: content genuinely dropped => low ratio => FAIL --
412
413    #[test]
414    fn dropped_content_fails() {
415        let pages = vec![page(vec![span(
416            "This paragraph has plenty of words that never make it into the output.",
417            50.0,
418            700.0,
419            500.0,
420            10.0,
421        )])];
422        let document = empty_document(vec![Block::Paragraph(Paragraph {
423            text: "This paragraph has".to_string(),
424        })]);
425        let report = validate(&pages, &document, "This paragraph has\n");
426
427        assert!(
428            report.recovery_ratio() < 0.5,
429            "expected a low ratio for dropped content, got {}",
430            report.recovery_ratio()
431        );
432        assert!(!report.passes(), "truncated content must not PASS");
433    }
434
435    // -- hyphenation repaired across a line break => still ~100% => PASS --
436
437    #[test]
438    fn repaired_hyphenation_still_passes() {
439        // Two spans on different lines simulate a justified paragraph where
440        // "development" wraps as "develop-" / "ment"; reconstruction joins
441        // them back into one word and drops the hyphen.
442        let pages = vec![page(vec![
443            span("develop-", 50.0, 700.0, 60.0, 10.0),
444            span("ment continues steadily.", 50.0, 688.0, 150.0, 10.0),
445        ])];
446        let document = empty_document(vec![Block::Paragraph(Paragraph {
447            text: "development continues steadily.".to_string(),
448        })]);
449        let report = validate(&pages, &document, "development continues steadily.\n");
450
451        assert!(
452            report.recovery_ratio() >= 0.99,
453            "hyphenation repair must not be penalized, got {}",
454            report.recovery_ratio()
455        );
456        assert!(report.passes());
457    }
458
459    #[test]
460    fn a_real_compound_hyphen_is_not_stripped_from_either_side() {
461        // "Anglo-" / "Saxon" is a genuine compound, not a line-wrap; both
462        // reconstruction and this metric must leave the hyphen as content.
463        let pages = vec![page(vec![
464            span("Anglo-", 50.0, 700.0, 40.0, 10.0),
465            span("Saxon history.", 50.0, 688.0, 90.0, 10.0),
466        ])];
467        let document = empty_document(vec![Block::Paragraph(Paragraph {
468            text: "Anglo-Saxon history.".to_string(),
469        })]);
470        let report = validate(&pages, &document, "Anglo-Saxon history.\n");
471
472        assert!(
473            report.recovery_ratio() >= 0.99,
474            "expected ~100%, got {}",
475            report.recovery_ratio()
476        );
477    }
478
479    // -- a table rendered with pipe syntax => pipes don't inflate ratio => PASS --
480
481    #[test]
482    fn table_pipe_syntax_does_not_inflate_recovery() {
483        let pages = vec![page(vec![
484            span("Metric", 50.0, 700.0, 60.0, 10.0),
485            span("Value", 200.0, 700.0, 60.0, 10.0),
486            span("Speed", 50.0, 688.0, 60.0, 10.0),
487            span("42", 200.0, 688.0, 60.0, 10.0),
488        ])];
489        let document = empty_document(vec![Block::Table(crate::Table {
490            headers: vec!["Metric".to_string(), "Value".to_string()],
491            rows: vec![vec!["Speed".to_string(), "42".to_string()]],
492        })]);
493        let rendered = "| Metric | Value |\n| --- | --- |\n| Speed | 42 |\n";
494        let report = validate(&pages, &document, rendered);
495
496        assert!(
497            report.recovery_ratio() <= 1.0 + 1e-9,
498            "table scaffolding must not push recovery above 100%, got {}",
499            report.recovery_ratio()
500        );
501        assert!(
502            report.recovery_ratio() >= 0.99,
503            "expected ~100% once pipes/separator are stripped, got {}",
504            report.recovery_ratio()
505        );
506        assert!(report.passes());
507    }
508
509    // -- OCR word-gap merge ("hel lo" -> "hello") => still PASS --
510
511    #[test]
512    fn ocr_word_gap_merge_still_passes() {
513        // "Boo" and "k" simulate an OCR text layer that split one word
514        // into two spans (see reconstruction::group_lines); reconstruction
515        // reads them back as "Book" with no space.
516        let pages = vec![page(vec![
517            span("Boo", 50.0, 700.0, 18.0, 10.0),
518            span("k", 68.2, 700.0, 6.0, 10.0),
519            span("Reviews", 78.0, 700.0, 50.0, 10.0),
520        ])];
521        let document = empty_document(vec![Block::Heading(Heading {
522            level: 1,
523            text: "Book Reviews".to_string(),
524        })]);
525        let report = validate(&pages, &document, "# Book Reviews\n");
526
527        assert!(
528            report.recovery_ratio() >= 0.99,
529            "word-gap re-tokenization must not be penalized, got {}",
530            report.recovery_ratio()
531        );
532        assert!(report.passes());
533    }
534
535    // -- normalization building blocks --
536
537    #[test]
538    fn stripped_running_head_keeps_recovery_ratio_honest() {
539        // The recovery-ratio trap (kopitiam_token_max.md §2.1). A 4-page doc
540        // (enough to engage signature stripping) with a running head in the top
541        // zone on every page. Reconstruction drops that head, so the rendered
542        // Markdown never contains it. If `validate` still counted the head on
543        // the extracted side, the ratio would fall below 0.98 and the doc would
544        // FAIL purely for having a running head. Because `validate` reruns the
545        // same `strip_marginalia`, the head is discounted on both sides and the
546        // ratio stays honestly in [0.98, 1.0].
547        let head = "Confidential Draft Running Header";
548        let body = "Body sentence carrying the real content of the page.";
549        let pages: Vec<Page> = (1..=4)
550            .map(|n| Page {
551                number: n,
552                width: 600.0,
553                height: 800.0,
554                spans: vec![
555                    // y = 770 / 800 -> top 10% zone.
556                    span(head, 50.0, 770.0, 300.0, 10.0),
557                    span(body, 50.0, 400.0, 400.0, 10.0),
558                ],
559            })
560            .collect();
561
562        // Rendered output is the body only (the head was stripped before render).
563        let rendered = format!("{body}\n").repeat(4);
564        let document = empty_document(vec![Block::Paragraph(Paragraph {
565            text: body.to_string(),
566        })]);
567        let report = validate(&pages, &document, &rendered);
568
569        assert!(
570            report.recovery_ratio() <= 1.0 + 1e-9,
571            "ratio must not exceed 1.0, got {}",
572            report.recovery_ratio()
573        );
574        assert!(
575            report.recovery_ratio() >= 0.98,
576            "discounting the stripped head on the extracted side must keep the \
577             ratio within PASS range, got {}",
578            report.recovery_ratio()
579        );
580        assert!(report.passes(), "a doc whose only loss is a running head must PASS");
581
582        // Sanity: the head really was removed from the extracted count. Had it
583        // been left in, `extracted_chars` would carry the head's characters
584        // (4 x 28 non-whitespace) on top of the body, dropping the ratio well
585        // below 0.98.
586        let body_chars = content_char_count(body) * 4;
587        assert_eq!(
588            report.extracted_chars, body_chars,
589            "extracted side must count body only, not the stripped head"
590        );
591    }
592
593    #[test]
594    fn collapsed_figure_labels_keep_recovery_ratio_honest() {
595        // The recovery-ratio trap (kopitiam_token_max.md §2.1) for I-D. A page
596        // with a scattered cloud of short diagram labels anchored to a `Fig. 1`
597        // caption. Reconstruction drops the labels and keeps only the caption,
598        // so the rendered Markdown is the caption alone. If `validate` still
599        // counted the dropped labels on the extracted side, the ratio would
600        // crater far below 0.98 and the doc would FAIL purely for containing a
601        // diagram. Because `validate` reruns the same `collapse_figure_regions`
602        // (after the same `strip_marginalia`), the labels are discounted on both
603        // sides and the ratio stays honestly in [0.98, 1.0].
604        let caption = "Fig. 1 System architecture of the platform";
605        let labels = [
606            "Sensor Array", "Data Ingestion", "Message Queue", "Stream Processor",
607            "Control Unit", "PID Controller", "Actuator Bank", "Feedback Loop",
608            "State Store", "Cache Layer", "Load Balancer", "API Gateway",
609        ];
610        let mut spans = Vec::new();
611        for (i, label) in labels.iter().enumerate() {
612            let x = 50.0 + ((i * 97) % 450) as f32;
613            let y = 720.0 - (i as f32) * 12.0;
614            spans.push(span(label, x, y, 80.0, 10.0));
615        }
616        spans.push(span(caption, 50.0, 340.0, 400.0, 10.0));
617        let pages = vec![page(spans)];
618
619        // Rendered output is the caption only (labels were collapsed away, and a
620        // captioned figure now renders just its caption).
621        let rendered = format!("{caption}\n");
622        let document = empty_document(vec![Block::Figure(crate::Figure {
623            caption: Some(caption.to_string()),
624            image_path: None,
625        })]);
626        let report = validate(&pages, &document, &rendered);
627
628        assert!(
629            report.recovery_ratio() <= 1.0 + 1e-9,
630            "ratio must not exceed 1.0, got {}",
631            report.recovery_ratio()
632        );
633        assert!(
634            report.recovery_ratio() >= 0.98,
635            "discounting collapsed labels on the extracted side must keep the \
636             ratio in PASS range, got {}",
637            report.recovery_ratio()
638        );
639        assert!(report.passes(), "a doc whose only loss is diagram labels must PASS");
640
641        // Sanity: the labels really were removed from the extracted count -- only
642        // the caption's characters remain.
643        assert_eq!(
644            report.extracted_chars,
645            content_char_count(caption),
646            "extracted side must count the caption only, not the collapsed labels"
647        );
648    }
649
650    #[test]
651    fn strip_rendered_markdown_syntax_removes_all_known_scaffolding() {
652        let markdown = "# Title\n\n\
653             Body paragraph.\n\n\
654             - First item\n\
655             1. Ordered item\n\n\
656             | A | B |\n\
657             | --- | --- |\n\
658             | 1 | 2 |\n\n\
659             > Quoted line\n\n\
660             ```rust\n\
661             fn main() {}\n\
662             ```\n\n\
663             Caption text.\n\n\
664             [figure]\n\n\
665             <!-- page 2 -->\n";
666        let stripped = strip_rendered_markdown_syntax(markdown);
667
668        assert!(!stripped.contains('#'));
669        assert!(!stripped.contains('|'));
670        assert!(!stripped.contains('>'));
671        assert!(!stripped.contains("```"));
672        // The page-boundary anchor (I-B, §2.1) is renderer scaffolding, not
673        // source content, and must be stripped before counting.
674        assert!(!stripped.contains("<!-- page"));
675        // The caption-less figure marker (I-D shortened it to "[figure]", §2.3)
676        // is renderer boilerplate and must be stripped before counting.
677        assert!(!stripped.contains("[figure]"));
678        assert!(stripped.contains("Title"));
679        assert!(stripped.contains("Body paragraph."));
680        assert!(stripped.contains("First item"));
681        assert!(stripped.contains("Ordered item"));
682        assert!(stripped.contains("Quoted line"));
683        assert!(stripped.contains("fn main() {}"));
684        assert!(stripped.contains("Caption text."));
685    }
686
687    #[test]
688    fn table_pipes_are_stripped_but_a_literal_pipe_in_a_cell_survives_unescaped() {
689        assert_eq!(strip_table_pipes("| A | B |"), "A B");
690        assert_eq!(strip_table_pipes("| A\\|B | C |"), "A|B C");
691    }
692
693    #[test]
694    fn empty_extraction_reports_full_recovery_by_convention() {
695        let report = validate(&[], &empty_document(vec![]), "");
696        assert_eq!(report.recovery_ratio(), 1.0);
697        assert!(report.passes());
698    }
699
700    // -- I-B: page anchors + per-page recovery --
701
702    #[test]
703    fn page_anchor_scaffolding_does_not_inflate_recovery() {
704        // The §2.1 recovery-ratio trap for I-B: the `<!-- page N -->` anchors the
705        // renderer now emits are NOT source content. If they counted toward
706        // `rendered_chars`, a two-page doc would report >100% recovery and mask
707        // real content loss. Because `strip_rendered_markdown_syntax` drops the
708        // anchor lines, the ratio stays honestly in [0.98, 1.0]. NEVER weaken 0.98.
709        let body1 = "First page body sentence carrying the real content.";
710        let body2 = "Second page body sentence carrying the real content.";
711        let pages = vec![
712            page_n(1, vec![span(body1, 50.0, 400.0, 400.0, 10.0)]),
713            page_n(2, vec![span(body2, 50.0, 400.0, 400.0, 10.0)]),
714        ];
715        let document = Document {
716            title: None,
717            metadata: Metadata { source_pages: 2 },
718            block_pages: vec![1, 2],
719            blocks: vec![
720                Block::Paragraph(Paragraph { text: body1.to_string() }),
721                Block::Paragraph(Paragraph { text: body2.to_string() }),
722            ],
723            citations: Vec::new(),
724        };
725        // Exactly what `render_document` produces: one anchor at the boundary.
726        let rendered = format!("{body1}\n\n<!-- page 2 -->\n\n{body2}\n");
727        let report = validate(&pages, &document, &rendered);
728
729        assert!(
730            report.recovery_ratio() <= 1.0 + 1e-9,
731            "page anchors must not push recovery above 100%, got {}",
732            report.recovery_ratio()
733        );
734        assert!(
735            report.recovery_ratio() >= 0.98,
736            "expected ~100% once anchors are stripped, got {}",
737            report.recovery_ratio()
738        );
739        assert!(report.passes());
740    }
741
742    #[test]
743    fn per_page_recovery_is_reported_and_sums_to_the_document_wide_figure() {
744        // Two full pages plus a third page whose body was truncated in the
745        // rendered output (content loss confined to page 3). The per-page split
746        // localizes the loss to page 3 while pages 1 and 2 stay ~100%, and the
747        // per-page char totals partition the document-wide totals exactly.
748        let body1 = "Alpha content on the very first page here.";
749        let body2 = "Beta content on the second page as well now.";
750        let body3_full = "Gamma content on the third page that is mostly dropped downstream.";
751        let body3_rendered = "Gamma content"; // truncated: real loss on page 3
752        let pages = vec![
753            page_n(1, vec![span(body1, 50.0, 400.0, 400.0, 10.0)]),
754            page_n(2, vec![span(body2, 50.0, 400.0, 400.0, 10.0)]),
755            page_n(3, vec![span(body3_full, 50.0, 400.0, 400.0, 10.0)]),
756        ];
757        let document = Document {
758            title: None,
759            metadata: Metadata { source_pages: 3 },
760            block_pages: vec![1, 2, 3],
761            blocks: vec![
762                Block::Paragraph(Paragraph { text: body1.to_string() }),
763                Block::Paragraph(Paragraph { text: body2.to_string() }),
764                Block::Paragraph(Paragraph { text: body3_rendered.to_string() }),
765            ],
766            citations: Vec::new(),
767        };
768        let rendered =
769            format!("{body1}\n\n<!-- page 2 -->\n\n{body2}\n\n<!-- page 3 -->\n\n{body3_rendered}\n");
770        let report = validate(&pages, &document, &rendered);
771
772        // One entry per source page, in page order.
773        assert_eq!(report.per_page.len(), 3);
774        assert_eq!(
775            report.per_page.iter().map(|p| p.page).collect::<Vec<_>>(),
776            vec![1, 2, 3]
777        );
778
779        // Pages 1 and 2 are fully recovered; page 3 is not.
780        assert!(report.per_page[0].recovery_ratio() >= 0.99);
781        assert!(report.per_page[1].recovery_ratio() >= 0.99);
782        assert!(
783            report.per_page[2].recovery_ratio() < 0.5,
784            "the truncated page 3 must show a low per-page ratio, got {}",
785            report.per_page[2].recovery_ratio()
786        );
787
788        // Consistency: the per-page figures partition the document-wide totals.
789        let sum_extracted: usize = report.per_page.iter().map(|p| p.extracted_chars).sum();
790        let sum_rendered: usize = report.per_page.iter().map(|p| p.rendered_chars).sum();
791        assert_eq!(sum_extracted, report.extracted_chars);
792        assert_eq!(sum_rendered, report.rendered_chars);
793
794        // The document-wide ratio lies within the span of the per-page ratios
795        // (page 3 drags it down without the per-page view, we would not know
796        // *which* page).
797        let doc_ratio = report.recovery_ratio();
798        assert!(doc_ratio < report.per_page[0].recovery_ratio());
799        assert!(doc_ratio > report.per_page[2].recovery_ratio());
800    }
801
802    #[test]
803    fn a_specific_page_anchor_is_literal_and_greppable_in_the_rendered_output() {
804        // Acceptance: `rg -n "<!-- page 2 -->"` (conceptually) locates page 2.
805        // The anchor is a plain literal string, not an escaped/encoded token.
806        let rendered = "Page one.\n\n<!-- page 2 -->\n\nPage two.\n";
807        assert!(rendered.contains("<!-- page 2 -->"));
808        assert_eq!(parse_page_anchor("<!-- page 2 -->"), Some(2));
809        assert_eq!(parse_page_anchor("<!-- page 717 -->"), Some(717));
810        // Not every comment-shaped line is an anchor.
811        assert_eq!(parse_page_anchor("<!-- note -->"), None);
812        assert_eq!(parse_page_anchor("plain text"), None);
813    }
814
815    #[test]
816    fn a_document_without_page_provenance_reports_no_per_page_breakdown() {
817        // No `block_pages` -> no honest page attribution -> empty per_page.
818        let document = Document {
819            title: None,
820            metadata: Metadata::default(),
821            block_pages: Vec::new(),
822            blocks: vec![Block::Paragraph(Paragraph { text: "Body.".to_string() })],
823            citations: Vec::new(),
824        };
825        let pages = vec![page(vec![span("Body.", 50.0, 400.0, 100.0, 10.0)])];
826        let report = validate(&pages, &document, "Body.\n");
827        assert!(report.per_page.is_empty());
828    }
829}