Files
pdfcraft/apps/pdfcraft-cli/tests/xfa.rs
T
1952a88801 fix(cli): use laid-out pages for dynamic XFA forms (#2813)
Fixes #1645. For a dynamic XFA form, `info` reported the placeholder
page and field counts, and `text` and `render` read the placeholder
pages, while the app and the automation tools lay the template out.

The CLI now opens dynamic XFA forms through the engine session and uses
its laid-out document for page and field metadata, text and pixels.
`info` keeps the input's file size and also reports the original counts
as `pdf_pages` and `acroform_fields`; `text` checks the copy permission
before extracting. The input file is never written.

Checks run (rustc 1.99.0): cargo fmt --check; cargo clippy -p
pdfcraft-cli --all-targets -- -D warnings; cargo test -p pdfcraft-cli;
cargo xtask layers; cargo xtask assets.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 09:54:00 -04:00

137 lines
6.7 KiB
Rust

//! 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 {
let xdp = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="page"><occur max="-1"/>
<contentArea x="20pt" y="20pt" w="160pt" h="260pt"/>
<medium short="200pt" long="300pt"/>
</pageArea></pageSet>
<draw w="160pt" h="20pt"><value><text>First XFA page</text></value></draw>
<field name="name" w="160pt" h="20pt"><ui><textEdit/></ui><value><text>Ada</text></value></field>
<subform name="last" layout="tb"><breakBefore targetType="pageArea"/>
<draw w="160pt" h="20pt"><value><text>Second XFA page</text></value></draw>
</subform>
</subform></template></xdp:xdp>"#;
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");
}
#[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);
}