mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 20:04:37 +00:00
* M2: `info` lists the repairs made while opening, as Document Properties does (misc.cli-inspect) `pdfcraft-cli info` now prints a `repairs` array: the notes about damage repaired while opening that the app shows in Document Properties and doc_info reports. It opens the document as the app does, once (the session the dynamic XFA summary already used), so notes the app adds after reading the objects, such as a page tree rebuilt from the pages found by scanning, are listed too. A file the app cannot open for editing falls back to what reading its objects repaired; a sound file prints an empty array. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn * M2: `info` runs none of the document's scripts while inspecting it (misc.cli-inspect) The session `info` opens to read the app's repair notes and the dynamic XFA layout now has JavaScript turned off, so an XFA form's initialize and calculate scripts, which the app runs on opening, do not run during inspection of an untrusted file. Before this branch the session was opened for dynamic XFA forms only, and their scripts ran there too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn * M2: `info` reads the standards and repairs from the session it opens, and a laid-out form keeps its repair notes (misc.cli-inspect) `info` now takes the declared standards (`Document::standards`, as the Standards panel reads them) and the repair notes from the session it opened, and reads the file once more only when the app cannot open it. When opening lays out a dynamic XFA form, or writes any other revision, the notes about damage repaired while reading the file now stay with the re-read document, so Document Properties and `info` both list them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --------- Co-authored-by: mtlcl410 <chuanlin@niar.org.tw> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
8.7 KiB
Rust
176 lines
8.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 {
|
|
fixture_with(test, "")
|
|
}
|
|
|
|
/// The two-page form, with `event` inside its `name` field.
|
|
fn fixture_with(test: &str, event: &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>{event}</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>"#
|
|
.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#"<event activity="initialize"><script contentType="application/x-javascript">this.rawValue = "Changed";</script></event>"#;
|
|
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:?}");
|
|
}
|