use lopdf::content::{Content, Operation};
use lopdf::{dictionary, Document, Object, Stream};
use pdf_engine::{PdfDocument, TextSpan};
fn pdf_with_text_matrix(tm: [f64; 6]) -> Vec<u8> {
let ops = vec![
Operation::new("BT", vec![]),
Operation::new("Tf", vec![Object::Name(b"F1".to_vec()), 24.into()]),
Operation::new("Tm", tm.iter().map(|&v| Object::Real(v as f32)).collect()),
Operation::new(
"Tj",
vec![Object::String(b"Hi".to_vec(), lopdf::StringFormat::Literal)],
),
Operation::new("ET", vec![]),
];
let content = Content { operations: ops }
.encode()
.expect("encode content");
let mut doc = Document::with_version("1.7");
let font_id = doc.add_object(Object::Dictionary(dictionary! {
"Type" => Object::Name(b"Font".to_vec()),
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
}));
let resources = dictionary! {
"Font" => Object::Dictionary(dictionary! { "F1" => Object::Reference(font_id) }),
};
let stream_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, content)));
let page = dictionary! {
"Type" => Object::Name(b"Page".to_vec()),
"MediaBox" => Object::Array(vec![
Object::Integer(0), Object::Integer(0),
Object::Integer(612), Object::Integer(792),
]),
"Contents" => Object::Reference(stream_id),
"Resources" => Object::Dictionary(resources),
};
let page_id = doc.add_object(Object::Dictionary(page));
let pages_id = doc.add_object(Object::Dictionary(dictionary! {
"Type" => Object::Name(b"Pages".to_vec()),
"Kids" => Object::Array(vec![Object::Reference(page_id)]),
"Count" => Object::Integer(1),
}));
if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
d.set("Parent", Object::Reference(pages_id));
}
let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
"Type" => Object::Name(b"Catalog".to_vec()),
"Pages" => Object::Reference(pages_id),
}));
doc.trailer.set("Root", Object::Reference(catalog_id));
let mut buf = Vec::new();
doc.save_to(&mut buf).expect("save synthetic pdf");
buf
}
fn spans_of(bytes: Vec<u8>) -> Vec<TextSpan> {
PdfDocument::open(bytes)
.expect("open pdf")
.extract_text_blocks(0)
.expect("extract text blocks")
.into_iter()
.flat_map(|b| b.spans)
.collect()
}
fn corpus_path(name: &str) -> Option<std::path::PathBuf> {
let root = std::env::var("PDFLUENT_TEST_FIXTURES").map_or_else(
|_| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("tests")
},
std::path::PathBuf::from,
);
let path = root.join("corpus").join(name);
let path = if path.exists() { path } else { root.join("corpus-mini").join(name) };
path.exists().then_some(path)
}
#[test]
fn transform_captures_rotation() {
let upright = spans_of(pdf_with_text_matrix([1.0, 0.0, 0.0, 1.0, 100.0, 400.0]));
assert!(!upright.is_empty(), "no text extracted from upright pdf");
let t = upright[0].transform.expect("upright transform present");
assert!(
t[1].abs() < t[0].abs() * 0.1,
"upright x-basis is not horizontal: {t:?}"
);
let rotated = spans_of(pdf_with_text_matrix([0.0, 1.0, -1.0, 0.0, 100.0, 400.0]));
assert!(!rotated.is_empty(), "no text extracted from rotated pdf");
let r = rotated[0].transform.expect("rotated transform present");
assert!(
r[0].abs() < r[1].abs() * 0.1,
"rotated x-basis is not vertical: {r:?}"
);
}
#[test]
fn golf1_fields_consistent_on_real_pdf() {
let Some(path) = corpus_path("simple.pdf") else {
eprintln!("skipping: repository fixture tree not present");
return;
};
let bytes = std::fs::read(path).expect("read simple.pdf");
let spans = spans_of(bytes);
assert!(!spans.is_empty(), "simple.pdf yielded no spans");
for s in &spans {
let t = s.transform.expect("transform populated for a glyph span");
assert!((t[4] - s.x).abs() < 1e-6, "transform.e != span.x");
assert!((t[5] - s.y).abs() < 1e-6, "transform.f != span.y");
let scale = (t[0] * t[0] + t[1] * t[1]).sqrt() * 1000.0;
assert!(
(scale - s.font_size).abs() < 1e-6 * (s.font_size + 1.0),
"transform scale {scale} inconsistent with font_size {}",
s.font_size
);
let rm = s.render_mode.expect("render_mode populated");
assert!(matches!(rm, 0 | 1 | 3), "render_mode {rm} not in {{0,1,3}}");
}
}
fn pct(num: usize, den: usize) -> f64 {
if den == 0 {
0.0
} else {
(num as f64 / den as f64 * 1000.0).round() / 10.0
}
}
#[test]
fn golf1_coverage_scorecard() {
let docs = [
"simple.pdf",
"multi-page.pdf",
"acroform.pdf",
"pdfa-2b.pdf",
"zugferd.pdf",
"signed-rsa.pdf",
];
let mut rows = Vec::new();
let (mut total, mut tf, mut rmode) = (0usize, 0usize, 0usize);
for name in docs {
let Some(path) = corpus_path(name) else {
continue;
};
let Ok(bytes) = std::fs::read(path) else {
continue;
};
let Ok(doc) = PdfDocument::open(bytes) else {
continue;
};
let Ok(blocks) = doc.extract_text_blocks(0) else {
continue;
};
let spans: Vec<_> = blocks.into_iter().flat_map(|b| b.spans).collect();
if spans.is_empty() {
continue;
}
let n = spans.len();
let c = |f: &dyn Fn(&TextSpan) -> bool| spans.iter().filter(|s| f(s)).count();
let with_tf = c(&|s| s.transform.is_some());
let with_rm = c(&|s| s.render_mode.is_some());
total += n;
tf += with_tf;
rmode += with_rm;
rows.push(serde_json::json!({
"doc": name,
"spans": n,
"transform_pct": pct(with_tf, n),
"render_mode_pct": pct(with_rm, n),
"font_weight_pct": pct(c(&|s| s.font_weight.is_some()), n),
"is_serif_pct": pct(c(&|s| s.is_serif.is_some()), n),
"is_monospace_pct": pct(c(&|s| s.is_monospace.is_some()), n),
"font_metrics_pct": pct(c(&|s| s.font_metrics.is_some()), n),
}));
}
let scorecard = serde_json::json!({
"metric": "golf1_field_coverage",
"scope": "page 0 of each fixture",
"docs": rows,
"totals": {
"spans": total,
"transform_pct": pct(tf, total),
"render_mode_pct": pct(rmode, total),
}
});
println!(
"GOLF1_SCORECARD {}",
serde_json::to_string_pretty(&scorecard).unwrap()
);
assert!(total > 0, "no spans extracted across corpus-mini");
assert_eq!(tf, total, "transform must be 100% populated");
assert_eq!(rmode, total, "render_mode must be 100% populated");
}
#[test]
#[ignore]
fn embedded_font_coverage_full_corpus() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus");
let mut pdfs: Vec<_> = std::fs::read_dir(&dir)
.expect("read corpus dir")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pdf"))
.collect();
pdfs.sort();
pdfs.truncate(60);
let (mut total, mut weight, mut serif, mut mono, mut metrics, mut wsome_mnone, mut scanned) =
(0usize, 0, 0, 0, 0, 0, 0);
for p in &pdfs {
let Ok(bytes) = std::fs::read(p) else {
continue;
};
let Ok(doc) = PdfDocument::open(bytes) else {
continue;
};
let Ok(blocks) = doc.extract_text_blocks(0) else {
continue;
};
let spans: Vec<_> = blocks.into_iter().flat_map(|b| b.spans).collect();
if spans.is_empty() {
continue;
}
scanned += 1;
total += spans.len();
weight += spans.iter().filter(|s| s.font_weight.is_some()).count();
serif += spans.iter().filter(|s| s.is_serif.is_some()).count();
mono += spans.iter().filter(|s| s.is_monospace.is_some()).count();
metrics += spans.iter().filter(|s| s.font_metrics.is_some()).count();
wsome_mnone += spans
.iter()
.filter(|s| s.font_weight.is_some() && s.font_metrics.is_none())
.count();
}
println!(
"EMBEDDED_FONT_COVERAGE docs={scanned} spans={total} \
font_weight={:.1}% is_serif={:.1}% is_monospace={:.1}% font_metrics={:.1}% \
weight_some_but_metrics_none={wsome_mnone}",
pct(weight, total),
pct(serif, total),
pct(mono, total),
pct(metrics, total)
);
assert!(total > 0, "no spans across corpus sample");
assert!(
metrics > 0,
"font_metrics never populated across {scanned} docs — skrifa metrics path may be broken"
);
assert!(
weight > 0,
"font_weight never populated across {scanned} docs — embedded-font path may be broken"
);
}