Merge r2 track t16: mixed: automation-mcp, crate:misc, crypt-security, export-convert, optimize, preflight-pdfa, save-io

# Conflicts:
#	ATTRIBUTION.toml
#	contributors/people.toml
#	crates/automation/src/comments.rs
#	crates/edit/src/tests.rs
#	crates/engine/src/tests.rs
#	parity/acrobat-features.toml
This commit is contained in:
aiongg
2026-10-22 02:23:58 +00:00
57 changed files with 3128 additions and 127 deletions
+4
View File
@@ -23,6 +23,10 @@ pdfcraft-automation = { workspace = true }
pdfcraft-platform = { workspace = true }
base64 = "0.22"
[dev-dependencies]
# Decodes `render` output in tests/owner_password.rs; already a dependency of the engine.
png = "0.18"
[target.'cfg(unix)'.dependencies]
# `geteuid` for `ui`'s control-file owner check: safe, so no `unsafe` libc call here.
# Already in Cargo.lock through the GUI crates.
+79 -18
View File
@@ -176,7 +176,12 @@ fn audit(args: &[String]) -> Result<(), CliError> {
let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
// Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions`
// reports them; the page geometry is read only when there is something to place.
let info = if findings.is_empty() { None } else { Some(inspect(bytes, password).map_err(|e| format!("{path}: {e}"))?) };
let info = if findings.is_empty() {
None
} else {
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?)
};
let items: Vec<serde_json::Value> = findings
.into_iter()
.map(|f| {
@@ -299,7 +304,9 @@ fn info(args: &[String]) -> Result<(), CliError> {
let path = *positional(args).first().ok_or("info: missing file")?;
let bytes = read(path)?;
let password = flag(args, "--password");
let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
// The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
let pdf_pages = info.pages.len();
let acroform_fields = info.fields.len();
// Dynamic XFA forms: say what laying the template out gives (what the app shows).
@@ -366,6 +373,9 @@ fn text(args: &[String]) -> Result<(), CliError> {
}
let password = flag(args, "--password");
let mut bytes = read(path)?;
// The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
// Worked out from the file as read: `bytes` may become the session's display copy below.
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
// Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
// permissions password, a document that doesn't allow copying gives no text.
let mut session = pdfcraft_engine::Session::new();
@@ -378,12 +388,12 @@ fn text(args: &[String]) -> Result<(), CliError> {
bytes = doc.display.clone();
}
}
let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() });
let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() });
// A document the renderer could not open (wrong or missing password, unparseable file) has
// no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask
// `inspect` why instead, so a protected file fails the same way `info` does.
if r.page_count() == 0 {
let info = inspect(bytes.clone(), password).map_err(|e| format!("text: {e}"))?;
let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?;
if !info.pages.is_empty() {
return Err("text: the document could not be parsed".into());
}
@@ -478,17 +488,31 @@ fn edit(args: &[String]) -> Result<(), CliError> {
"--password" => password = Some(value),
"--rotate" => {
let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?;
edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees: deg.parse().map_err(|_| "bad degrees")? });
let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?;
// The same rule `rotate_pages` enforces, checked here so a bad angle is named
// with its option before any file is opened.
if degrees % 90 != 0 {
return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into());
}
edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees });
}
"--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }),
"--move" => {
let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?;
let to: usize = to.parse().map_err(|_| "bad target")?;
edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.checked_sub(1).ok_or("--move target must be at least 1")? });
let target: usize = to
.parse()
.ok()
.filter(|target| *target > 0)
.ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?;
edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 });
}
"--insert-blank" => {
let at: usize = value.parse().map_err(|_| "bad position")?;
edits.push(Edit::InsertBlankPage { at: at.checked_sub(1).ok_or("--insert-blank must be at least 1")?, width: 612.0, height: 792.0 });
let position: usize = value
.parse()
.ok()
.filter(|position| *position > 0)
.ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?;
edits.push(Edit::InsertBlankPage { at: position - 1, width: 612.0, height: 792.0 });
}
"--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }),
"--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }),
@@ -570,7 +594,16 @@ fn split(args: &[String]) -> Result<(), CliError> {
use pdfcraft_engine::SplitBy;
let path = *positional(args).first().ok_or("split: missing file")?;
let by = match (flag(args, "--every"), flag(args, "--before")) {
(Some(n), None) => SplitBy::PageCount(n.parse().map_err(|_| "bad --every")?),
(Some(n), None) => {
// A count the splitter cannot hold is refused here, before the file is opened,
// rather than silently clamped to 1 further down.
let count = n
.parse::<usize>()
.ok()
.filter(|count| *count > 0)
.ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?;
SplitBy::PageCount(count)
}
(None, Some(list)) => SplitBy::Before(page_list(list)?),
_ => return Err("split: give either --every N or --before PAGES".into()),
};
@@ -590,12 +623,25 @@ fn split(args: &[String]) -> Result<(), CliError> {
Ok(())
}
/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value
/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own
/// internal message (and in `check`, passed on as text, they would turn every file into a
/// child that printed nothing), so the CLI names the option and the value instead.
fn parse_dpi(value: &str) -> Result<f32, String> {
value
.parse::<f32>()
.ok()
.filter(|dpi| dpi.is_finite() && *dpi > 0.0)
.ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}"))
}
fn render(args: &[String]) -> Result<(), CliError> {
validate_options("render", args, RENDER_OPTIONS)?;
let path = *positional(args).first().ok_or("render: missing file")?;
let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?;
let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?;
let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?;
let page: usize =
flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?;
let page_index = page - 1;
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?;
let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?;
// The file is what its name says (#248): a `.png` used to get a netpbm PAM stream.
let format = match Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
@@ -609,14 +655,18 @@ fn render(args: &[String]) -> Result<(), CliError> {
// must agree with it (and with independent viewers), while keeping the file it reads intact.
let bytes = read(path)?;
let password = flag(args, "--password");
let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
// The renderer takes only the user password of R2–R4 files; `Session::open` and the display
// copy take either.
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
let mut session = pdfcraft_engine::Session::new();
let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
session.get(id).ok_or("render: the document could not be opened")?.display.clone()
} else {
pdfcraft_engine::display_bytes_for(bytes, password)
};
let mut r = PageRenderer::new(display, RenderConfig { password: password.map(Arc::from), reject_oversize: true, ..Default::default() });
let mut r =
PageRenderer::new(display, RenderConfig { password: render_password.as_deref().map(Arc::from), reject_oversize: true, ..Default::default() });
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
if let Some(e) = p.error {
return Err(e.into());
@@ -647,7 +697,7 @@ fn render(args: &[String]) -> Result<(), CliError> {
fn check_one(args: &[String]) -> Result<(), CliError> {
validate_options("check-one", args, CHECK_ONE_OPTIONS)?;
let path = *positional(args).first().ok_or("check-one: missing file")?;
let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?;
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?;
let start = Instant::now();
let bytes = read(path)?;
let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
@@ -739,12 +789,23 @@ fn collect(paths: &[&str]) -> Vec<PathBuf> {
fn check(args: &[String]) -> Result<(), CliError> {
validate_options("check", args, CHECK_OPTIONS)?;
// Every option is checked before the inputs are scanned, so a bad value is one message,
// not one problem per file: a zero timeout used to make every file time out, and a bad
// dpi used to reach each child, which printed nothing and was reported as a crash.
let timeout_value = flag(args, "--timeout").unwrap_or("20");
let timeout = Duration::from_secs(
timeout_value
.parse::<u64>()
.ok()
.filter(|secs| *secs > 0)
.ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?,
);
let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
parse_dpi(&dpi)?;
let files = collect(&positional(args));
if files.is_empty() {
return Err("check: no PDF files found".into());
}
let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("20").parse().map_err(|_| "bad --timeout")?);
let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let mut results = Vec::new();
let mut counts: std::collections::BTreeMap<String, usize> = Default::default();
+20
View File
@@ -1,4 +1,5 @@
//! The robustness harness visits each directory once, including symlink aliases and cycles.
//! Its `--timeout` and `--dpi` options are checked before anything is scanned.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
@@ -109,3 +110,22 @@ fn check_follows_directory_and_file_symlinks() {
assert_eq!(results[0]["status"], "ok");
assert_eq!(results[0]["file"], file_alias.to_string_lossy().as_ref());
}
/// `--timeout` and `--dpi` are checked before any input is scanned: a zero timeout used to
/// make every file time out at once, and a dpi that is not a positive finite number used to
/// reach each child, which printed nothing and was reported as a crash per file.
#[test]
fn a_bad_timeout_or_dpi_is_refused_before_scanning() {
let out = Command::new(BIN).args(["check", "no-such-input", "--timeout", "0"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert_eq!(out.stderr, b"pdfcraft-cli: bad --timeout: expected a positive whole number of seconds, got \"0\"\n");
for value in ["abc", "0", "-3", "NaN"] {
let out = Command::new(BIN).args(["check", "no-such-input", "--dpi", value]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "--dpi {value}");
assert_eq!(
out.stderr,
format!("pdfcraft-cli: bad --dpi: expected a positive number of dots per inch, got {value:?}\n").as_bytes(),
"--dpi {value}"
);
}
}
+30 -3
View File
@@ -141,9 +141,9 @@ mod one_based_arguments {
for destination in ["output.pam", "new.pam", "input.pdf"] {
std::fs::write(dir.0.join("output.pam"), b"keep destination").unwrap();
for (command, option, value, diagnostic) in [
("render", "--page", "0", "--page must be at least 1"),
("edit", "--move", "1:0", "--move target must be at least 1"),
("edit", "--insert-blank", "0", "--insert-blank must be at least 1"),
("render", "--page", "0", "bad --page: expected a positive page number"),
("edit", "--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""),
("edit", "--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""),
] {
let out = Command::new(BIN).current_dir(&dir.0).args([command, input, "--out", destination, option, value]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{command} {input} {option} {value}");
@@ -194,6 +194,33 @@ mod one_based_arguments {
}
}
/// A value the option cannot hold names the option and the value, and fails before the input
/// file is opened: a missing input still gets the option's own message.
#[test]
fn a_value_an_option_cannot_hold_is_named_before_the_file_is_opened() {
let dir = tmp("values", "");
for (option, value, diagnostic) in [
("--rotate", "1:abc", "bad --rotate degrees: expected a whole number of degrees, got \"abc\""),
("--rotate", "1:45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got 45"),
("--rotate", "1:-45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got -45"),
("--move", "1:abc", "bad --move target: expected a page number of at least 1, got \"abc\""),
("--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""),
("--insert-blank", "abc", "bad --insert-blank position: expected a page position of at least 1, got \"abc\""),
("--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""),
] {
let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", option, value]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{option} {value}");
assert_eq!(out.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}");
assert!(out.stdout.is_empty(), "{option} {value}");
assert!(!dir.join("out.pdf").exists(), "{option} {value}: no output may be written");
}
// A negative multiple of 90 is still a rotation the engine accepts: the refusal above is
// the multiple-of-90 rule, not a sign problem (the missing input is the next error).
let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", "--rotate", "1:-90"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(!String::from_utf8_lossy(&out.stderr).contains("--rotate degrees"), "{}", String::from_utf8_lossy(&out.stderr));
}
/// A destination past the end must fail, not silently become an append (#1330).
#[test]
fn a_destination_past_the_end_fails_and_writes_nothing() {
+154
View File
@@ -0,0 +1,154 @@
//! `info`, `text` and `render` take the owner password of revision 2–4 files, as `run doc_open`
//! does (#2670, item 8). The renderer authenticates only user passwords of those revisions, so
//! the CLI must hand it the user password that owner authentication recovers.
use std::process::Command;
use std::sync::Arc;
use pdfcraft_cos::{Algorithm, NewEncryption};
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
/// A one-page 200×300 pt PDF reading "Page 1", drawn by `content`.
fn fixture(content: &str) -> Vec<u8> {
let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
let objs: [&[u8]; 5] = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>",
stream.as_bytes(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
const PAGE: &str = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET";
/// `fixture` protected with user password "user" and owner password "owner", allowing nothing,
/// so copying its text needs the owner password.
fn protected(algorithm: Algorithm) -> Vec<u8> {
protected_with(algorithm, PAGE)
}
fn protected_with(algorithm: Algorithm, content: &str) -> Vec<u8> {
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(content))).unwrap();
doc.set_encryption(&NewEncryption {
algorithm,
user_password: "user",
owner_password: "owner",
permissions: 0,
encrypt_metadata: true,
seed: [3; 32],
})
.unwrap();
pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("pdfcraft-cli-owner-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name)
}
const ALGORITHMS: [Algorithm; 4] = [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256];
#[test]
fn info_accepts_the_owner_password() {
for alg in ALGORITHMS {
let path = tmp(&format!("info-{alg:?}.pdf"));
std::fs::write(&path, protected(alg)).unwrap();
for pw in ["owner", "user"] {
let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", pw]).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(json["pages"], 1, "{alg:?} with {pw:?}");
assert_eq!(json["encrypted"], true, "{alg:?} with {pw:?}");
}
let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", "wrong"]).output().unwrap();
assert!(!out.status.success(), "{alg:?}: a wrong password still fails");
assert!(String::from_utf8_lossy(&out.stderr).contains("incorrect"), "{alg:?}");
}
}
#[test]
fn text_accepts_the_owner_password() {
for alg in ALGORITHMS {
let path = tmp(&format!("text-{alg:?}.pdf"));
std::fs::write(&path, protected(alg)).unwrap();
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "owner"]).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{alg:?}: {stderr}");
assert!(String::from_utf8_lossy(&out.stdout).contains("Page 1"), "{alg:?}");
// The user password opens it, but the permissions still forbid copying.
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "user"]).output().unwrap();
assert!(!out.status.success(), "{alg:?}: copying needs the owner password");
assert!(String::from_utf8_lossy(&out.stderr).contains("don't allow copying"), "{alg:?}");
}
}
/// Asserts that `data` is a PNG of the fixture's 200×300 pt page at `dpi` with the text drawn:
/// a blank page of the right size fails too.
fn assert_rendered_page(data: &[u8], dpi: u32, what: &str) {
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap_or_else(|e| panic!("{what}: {e}"));
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
let frame = reader.next_frame(&mut buf).unwrap();
assert_eq!((frame.width, frame.height), (200 * dpi / 72, 300 * dpi / 72), "{what}");
// `render` writes 8-bit RGBA (`pdfcraft_engine::export::encode_png`).
assert_eq!((frame.color_type, frame.bit_depth), (png::ColorType::Rgba, png::BitDepth::Eight), "{what}");
// Opaque pixels with every colour channel below half intensity: ink, not white paper.
let inked = buf[..frame.buffer_size()].as_chunks::<4>().0.iter().filter(|[r, g, b, a]| [r, g, b].iter().all(|c| **c < 128) && *a > 128).count();
assert!(inked > 100, "{what}: only {inked} inked pixels, the page is blank");
}
#[test]
fn render_accepts_the_owner_password() {
const DPI: u32 = 144;
for alg in ALGORITHMS {
let path = tmp(&format!("render-{alg:?}.pdf"));
std::fs::write(&path, protected(alg)).unwrap();
for pw in ["owner", "user"] {
let png = tmp(&format!("render-{alg:?}-{pw}.png"));
let _ = std::fs::remove_file(&png);
let out = Command::new(BIN)
.args(["render", path.to_str().unwrap(), "--page", "1", "--dpi", &DPI.to_string()])
.args(["--password", pw, "--out", png.to_str().unwrap()])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}"));
}
}
}
#[test]
fn audit_places_findings_with_the_owner_password() {
// A black box over the text: a faux redaction, so `audit` reads the page geometry too.
let covered = format!("{PAGE} 0 g 10 140 120 40 re f");
for alg in ALGORITHMS {
let path = tmp(&format!("audit-{alg:?}.pdf"));
std::fs::write(&path, protected_with(alg, &covered)).unwrap();
let out = Command::new(BIN).args(["audit", path.to_str().unwrap(), "--password", "owner"]).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!stderr.contains("incorrect"), "{alg:?}: {stderr}");
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("{alg:?}: {e}: {stderr}"));
assert_eq!(report["findings"][0]["covered_text"], "Page 1", "{alg:?}");
// Top-left points on the 300 pt high page: the box spans y 140–180 from the bottom.
assert_eq!(report["findings"][0]["rect"], serde_json::json!([10.0, 120.0, 130.0, 160.0]), "{alg:?}");
}
}
+24
View File
@@ -88,3 +88,27 @@ fn an_unknown_extension_fails_and_writes_nothing() {
assert!(stderr.contains(".png, .jpg, .tif or .pam"), "{stderr}");
assert!(!out.exists());
}
/// `--page` and `--dpi` are checked before the file is opened. A page that is not a positive
/// whole number names the same expectation `text` names; a dpi that parses as an `f32` but
/// cannot be rendered (`0`, a negative value, `NaN`) names the option and the value, instead
/// of reaching the renderer as a scale it refuses with its own internal message.
#[test]
fn a_page_or_dpi_the_renderer_cannot_use_is_refused_before_reading_the_file() {
for (option, value, diagnostic) in [
("--page", "abc", "bad --page: expected a positive page number"),
("--page", "0", "bad --page: expected a positive page number"),
("--dpi", "abc", "bad --dpi: expected a positive number of dots per inch, got \"abc\""),
("--dpi", "0", "bad --dpi: expected a positive number of dots per inch, got \"0\""),
("--dpi", "-3", "bad --dpi: expected a positive number of dots per inch, got \"-3\""),
("--dpi", "NaN", "bad --dpi: expected a positive number of dots per inch, got \"NaN\""),
("--dpi", "inf", "bad --dpi: expected a positive number of dots per inch, got \"inf\""),
] {
let out = tmp("refuse", "out.png");
// `missing.pdf` is never read: the option's own message is the error.
let r = Command::new(BIN).args(["render", "missing.pdf", "--out", out.to_str().unwrap(), option, value]).output().unwrap();
assert_eq!(r.status.code(), Some(1), "{option} {value}");
assert_eq!(r.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}");
assert!(!out.exists(), "{option} {value}: no output may be written");
}
}
+20
View File
@@ -1,4 +1,5 @@
//! `split --out-dir DIR` creates DIR when it does not exist yet (#249), and says so when it can't.
//! `--every` must be a positive whole number of pages, checked before the file is opened.
use std::path::PathBuf;
use std::process::{Command, Output};
@@ -74,3 +75,22 @@ fn an_out_dir_that_cannot_be_created_is_reported_as_such() {
assert_eq!(std::fs::read(&not_a_dir).unwrap(), b"in the way");
let _ = std::fs::remove_dir_all(&dir);
}
/// `--every` must be a positive whole number of pages. `0` parses, but the engine used to
/// clamp it to 1 silently; refused here, before the file is opened, it cannot surprise anyone.
#[test]
fn a_non_positive_or_unparseable_every_is_refused_before_the_file_is_opened() {
let dir = workdir("every");
for value in ["0", "abc"] {
let out_dir = dir.join("parts");
let out = Command::new(BIN).args(["split", "missing.pdf", "--every", value, "--out-dir", out_dir.to_str().unwrap()]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "--every {value}");
assert_eq!(
out.stderr,
format!("pdfcraft-cli: bad --every: expected a positive whole number of pages, got {value:?}\n").as_bytes(),
"--every {value}"
);
assert!(!out_dir.exists(), "--every {value}: nothing may be written");
}
let _ = std::fs::remove_dir_all(&dir);
}