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}