//! CLI page commands must use the same dynamic XFA layout as the app and tools (#1645).
use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::Arc;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
fn fixture(test: &str) -> PathBuf {
fixture_with(test, "")
}
/// The two-page form, with `event` inside its `name` field.
fn fixture_with(test: &str, event: &str) -> PathBuf {
let xdp = r#"
First XFA page
Ada{event}
Second XFA page
"#
.replace("{event}", event);
let objects = [
"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".into(),
"<< /Length 0 >>\nstream\n\nendstream".into(),
format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-xfa-{test}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("form.pdf");
std::fs::write(&path, bytes).unwrap();
path
}
fn success(output: &Output) {
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
}
#[test]
fn info_reports_the_laid_out_pages_and_fields() {
let path = fixture("info");
let original = std::fs::read(&path).unwrap();
let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
success(&output);
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["xfa_layout"]["pages"], 2, "the synthetic form lays out into two pages");
assert_eq!(info["pages"], 2, "top-level page count must agree with the app");
assert_eq!(info["fields"], 1);
assert_eq!(info["pdf_pages"], 1);
assert_eq!(info["acroform_fields"], 0);
assert_eq!(info["first_page_pt"], serde_json::json!([200.0, 300.0]));
assert_eq!(info["file_size"], original.len());
assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input");
}
/// `info` inspects an untrusted file: a form's initialize script, which the app runs on opening
/// and which here would change the field's value, does not run.
#[test]
fn info_runs_none_of_the_forms_scripts() {
let script = r#""#;
let path = fixture_with("info-script", script);
let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
success(&output);
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}");
let warnings = info["warnings"].as_array().unwrap();
assert!(!warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("scripts changed it on opening"))), "{warnings:?}");
assert_eq!(info["repairs"], serde_json::json!([]), "{info}");
}
#[test]
fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() {
let path = fixture("text");
let output = Command::new(BIN).arg("text").arg(&path).output().unwrap();
success(&output);
let text = String::from_utf8(output.stdout).unwrap();
assert!(text.contains("First XFA page") && text.contains("Second XFA page"), "{text:?}");
let output = Command::new(BIN).arg("text").arg(&path).args(["--page", "2"]).output().unwrap();
success(&output);
let text = String::from_utf8(output.stdout).unwrap();
assert!(text.contains("Second XFA page") && !text.contains("First XFA page"), "{text:?}");
}
#[test]
fn render_matches_the_engine_layout_on_the_second_page() {
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
let path = fixture("render");
let original = std::fs::read(&path).unwrap();
let mut session = pdfcraft_engine::Session::new();
let id = session.open("form.pdf", None, Arc::new(original.clone()), None).unwrap();
let doc = session.get(id).unwrap();
assert_eq!(doc.info.pages.len(), 2);
assert!(doc.text_lines(1).iter().any(|line| line.text.contains("Second XFA page")));
let mut renderer = PageRenderer::new(doc.display.clone(), RenderConfig::default());
let expected = renderer.render(RenderRequest { page: 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(expected.error.is_none(), "{:?}", expected.error);
assert!(expected.rgba.as_chunks::<4>().0.iter().filter(|[r, _, _, a]| *r < 128 && *a > 0).count() > 10, "the layout has visible ink");
let out = path.with_extension("pam");
let output = Command::new(BIN).arg("render").arg(&path).args(["--page", "2", "--dpi", "72", "--out"]).arg(&out).output().unwrap();
success(&output);
let actual = std::fs::read(out).unwrap();
let header = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", expected.width, expected.height);
assert!(actual.starts_with(header.as_bytes()));
assert_eq!(&actual[header.len()..], &expected.rgba[..]);
assert_eq!(std::fs::read(path).unwrap(), original);
}
#[test]
fn a_laid_out_form_keeps_the_original_copy_permissions() {
let path = fixture("permissions");
let mut doc = pdfcraft_cos::Document::open(Arc::new(std::fs::read(&path).unwrap())).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "openme",
owner_password: "boss",
permissions: -17,
encrypt_metadata: true,
seed: [17; 32],
})
.unwrap();
let original = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
std::fs::write(&path, &original).unwrap();
let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "openme"]).output().unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("don't allow copying"));
let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "boss", "--page", "2"]).output().unwrap();
success(&output);
assert!(String::from_utf8_lossy(&output.stdout).contains("Second XFA page"));
let output = Command::new(BIN).arg("info").arg(&path).args(["--password", "openme"]).output().unwrap();
success(&output);
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["pages"], 2);
assert_eq!(info["encrypted"], true);
assert_eq!(info["file_size"], original.len());
assert_eq!(std::fs::read(path).unwrap(), original);
}
/// A damaged dynamic XFA form: `info` lists the cross-reference index rebuilt while opening it,
/// as Document Properties does after the form is laid out as one more revision.
#[test]
fn info_lists_the_repairs_of_a_laid_out_form() {
let path = fixture("info-damaged");
let bytes = std::fs::read(&path).unwrap();
let at = bytes.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10;
let mut damaged = bytes[..at].to_vec();
damaged.extend_from_slice(b"20\n%%EOF\n");
std::fs::write(&path, &damaged).unwrap();
let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
success(&output);
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}");
let repairs = info["repairs"].as_array().unwrap();
assert!(repairs.iter().any(|l| l.as_str().is_some_and(|l| l.contains("rebuilt cross-reference index"))), "{repairs:?}");
}