nigig-org/crates/apps/pdf/pdf-graphics/tests/reflow.rs

113 lines
4.4 KiB
Rust

//! 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<u8> {
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::<Vec<_>>()
.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<char> {
let mut v: Vec<char> = 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:?}"
);
}
}