//! Phase 8 exit criterion: reflow integration tests. //! //! The reflow unit tests in `reflow.rs` build runs by hand. These start //! from a real PDF — parse, interpret, extract, reflow — because a reflow //! that works on hand-built runs but not on a real content stream is not //! a reflow at all. The corpus is the same one the search and selection //! exit tests use, so the whole "text intelligence" phase is exercised //! against one shared set of pages. //! //! See `REVIEWS/adr/0034-pdf-text-search-and-layout.md`. use std::path::PathBuf; use nigig_pdf_document::PdfDocument; use nigig_pdf_graphics::content::parse_content_stream; use nigig_pdf_graphics::recording::RecordingDevice; use nigig_pdf_graphics::reflow::{reflow_from_commands, ReflowOptions}; use nigig_pdf_graphics::text::PageText; fn corpus(relative: &str) -> Vec { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../tests/corpus") .join(relative); std::fs::read(&path).unwrap_or_else(|e| panic!("missing fixture {}: {e}", path.display())) } /// Parse a fixture, render it to commands, and reflow page 0. fn reflowed_text(relative: &str) -> String { let data = corpus(relative); let mut doc = PdfDocument::parse(&data).expect("fixture parses"); let page = doc.page(0).expect("page 0"); let mut device = RecordingDevice::new(); for (name, resource) in &page.fonts { device.register_font( name, nigig_pdf_graphics::font::GlyphWidths::from_standard_font(&resource.base_font), ); } let ops = parse_content_stream(&page.content_data).expect("content parses"); nigig_pdf_graphics::content::interpret_ops(&ops, &mut device).expect("interprets"); let commands = device.into_commands(); // The full-width-command pipeline, as the reader would call it. let options = ReflowOptions::default(); let reflowed = reflow_from_commands(&commands, &options).expect("reflows"); // One paragraph per blank line, one line per newline. reflowed.plain_text() } #[test] fn a_real_page_reflows_to_plain_text() { // `two_columns.pdf` is the layout torture test of this phase: it emits // runs across and down, shares baselines between columns, and splits a // word by kerning. A reflow must still produce readable text, left to // right and top to bottom, wider than it is deep. let text = reflowed_text("basic/two_columns.pdf"); assert!( text.contains("Left one") && text.contains("Right one"), "both columns present in reflow, got {text:?}" ); } #[test] fn reflow_preserves_all_text_from_a_real_page() { // Nothing may be lost in the reflow: the reflowed plain text and the // direct extraction must contain the same tokens (order may differ, // but the vocabulary may not shrink). let options = ReflowOptions::default(); let data = corpus("basic/two_columns.pdf"); let mut doc = PdfDocument::parse(&data).unwrap(); let page = doc.page(0).unwrap(); let mut device = RecordingDevice::new(); for (name, resource) in &page.fonts { device.register_font( name, nigig_pdf_graphics::font::GlyphWidths::from_standard_font(&resource.base_font), ); } let ops = parse_content_stream(&page.content_data).unwrap(); nigig_pdf_graphics::content::interpret_ops(&ops, &mut device).unwrap(); let commands = device.into_commands(); let extracted = PageText::from_commands(&commands).plain_text(); let reflowed = reflow_from_commands(&commands, &options).unwrap(); let reflow_text = reflowed.paragraphs.iter().map(|p| p.text.as_str()).collect::>().join(" "); // The vocabulary may change in *form* — a reflow reconstructs words that // the writer split across runs by kerning ("Hy" + "phen" → "Hyphen") and // re-inserts word spacing — but it may not *lose* any glyph. Compare the // significant characters as a multiset, which is invariant under joining // and splitting words. let sig = |s: &str| -> Vec { let mut v: Vec = s.chars().filter(|c| !c.is_whitespace()).collect(); v.sort_unstable(); v }; let extracted_chars = sig(&extracted); let reflow_chars = sig(&reflow_text); for c in &extracted_chars { assert!( reflow_chars.contains(c), "reflow lost glyph {c:?}; extracted {extracted:?}, reflowed {reflow_text:?}" ); } }