mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 05:28:09 -06:00
Merge PR #3849: cli: the argument bugs photocraft#7071 lists are live here too
Conflicts: apps/pdfcraft-cli/src/main.rs (render, check_one, check): kept main's bodies, positional() takes the option spec.
This commit is contained in:
@@ -84,7 +84,24 @@ help and feedback: https://discord.gg/artcraft"
|
||||
);
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
// `std::env::args` panics on an argument that is not UTF-8 ("called `Result::unwrap()` on an
|
||||
// `Err` value"), which a Latin-1 file name is, and it panics while collecting them: even
|
||||
// `--help` ended that way. Such a name cannot be passed on either, since the paths here are
|
||||
// `str`, so it is a usage error (photocraft#1108).
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
for arg in std::env::args_os().skip(1) {
|
||||
match arg.into_string() {
|
||||
Ok(a) => args.push(a),
|
||||
Err(bad) => {
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"pdfcraft-cli: `{}` is not valid UTF-8, so it cannot be used as an argument",
|
||||
bad.to_string_lossy()
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
// `<command> --help` too; only right after the command, so a later `-h` (a value) still reaches it.
|
||||
let command = match args.get(1).map(String::as_str) {
|
||||
Some("--help" | "-h") => Some("--help"),
|
||||
@@ -169,7 +186,7 @@ fn version() -> Result<(), CliError> {
|
||||
/// found so scripts and CI can gate on it, and when the audit itself cannot run.
|
||||
fn audit(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("audit", args, AUDIT_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("audit: missing file")?;
|
||||
let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?;
|
||||
let bytes = read(path)?;
|
||||
let password = flag(args, "--password");
|
||||
let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?;
|
||||
@@ -208,6 +225,28 @@ struct OptionSpec {
|
||||
takes_value: bool,
|
||||
}
|
||||
|
||||
/// The options whose value is a file or folder. Only these refuse a value that is another
|
||||
/// option: a file named `--password` is never what was meant, while a password can be anything
|
||||
/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title.
|
||||
/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered.
|
||||
const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"];
|
||||
|
||||
/// Whether a token can be the value of a path option. Another option cannot: `--out --password`
|
||||
/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944).
|
||||
fn can_be_path_value(name: &str, value: Option<&str>) -> bool {
|
||||
!PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--"))
|
||||
}
|
||||
|
||||
/// The complaint about a path option whose value is missing or is another option.
|
||||
fn needs_value(command: &str, name: &str, value: Option<&str>) -> String {
|
||||
match value {
|
||||
Some(v) if !v.is_empty() => {
|
||||
format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name")
|
||||
}
|
||||
_ => format!("{command}: {name} needs a value"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse unknown options and malformed confinement flags before any file is read.
|
||||
fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> {
|
||||
if specs.iter().any(|spec| spec.name == "--root") {
|
||||
@@ -230,9 +269,19 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res
|
||||
let Some(spec) = specs.iter().find(|spec| spec.name == arg) else {
|
||||
return Err(format!("{command}: unknown option --{name}").into());
|
||||
};
|
||||
// Other missing or malformed values are left to the command, which reports them in its own
|
||||
// terms (`text` and `extract` explain what a page value must be).
|
||||
i += if spec.takes_value { 2 } else { 1 };
|
||||
// A file's name is not another option, which also closes the gap the walk left for
|
||||
// those: an unknown option in that value position was skipped along with it. Other
|
||||
// values, missing or malformed, are left to the command, which reports them in its
|
||||
// own terms (`text` and `extract` explain what a page value must be).
|
||||
i += if spec.takes_value {
|
||||
let value = args.get(i + 1).map(String::as_str);
|
||||
if !can_be_path_value(spec.name, value) {
|
||||
return Err(needs_value(command, spec.name, value).into());
|
||||
}
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
@@ -278,19 +327,24 @@ const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value:
|
||||
#[cfg(feature = "mcp")]
|
||||
const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }];
|
||||
|
||||
fn positional(args: &[String]) -> Vec<&str> {
|
||||
/// The arguments that are not options or their values. Which tokens are values is the spec's
|
||||
/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing
|
||||
/// file", the file having been read as `--edit`'s value.
|
||||
fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> {
|
||||
let mut out = Vec::new();
|
||||
let mut skip = false;
|
||||
for a in args {
|
||||
if skip {
|
||||
skip = false;
|
||||
continue;
|
||||
let mut i = 0;
|
||||
while let Some(a) = args.get(i) {
|
||||
match specs.iter().find(|spec| spec.name == a.as_str()) {
|
||||
Some(spec) if spec.takes_value => i += 2,
|
||||
// An option that takes no value, and an unknown one, which `validate_options` has
|
||||
// already refused by the time this runs.
|
||||
Some(_) => i += 1,
|
||||
None if a.starts_with("--") => i += 1,
|
||||
None => {
|
||||
out.push(a.as_str());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if a.starts_with("--") {
|
||||
skip = true;
|
||||
continue;
|
||||
}
|
||||
out.push(a.as_str());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -301,7 +355,7 @@ fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
|
||||
|
||||
fn info(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("info", args, INFO_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("info: missing file")?;
|
||||
let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?;
|
||||
let bytes = read(path)?;
|
||||
let password = flag(args, "--password");
|
||||
// The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
|
||||
@@ -354,7 +408,7 @@ fn info(args: &[String]) -> Result<(), CliError> {
|
||||
|
||||
fn text(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("text", args, TEXT_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("text: missing file")?;
|
||||
let path = *positional(args, TEXT_OPTIONS).first().ok_or("text: missing file")?;
|
||||
let mut selected_page = None;
|
||||
// Validate every supplied value before reading, retaining the first valid selection.
|
||||
let mut options = args.iter();
|
||||
@@ -482,7 +536,13 @@ fn edit(args: &[String]) -> Result<(), CliError> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let value = args.get(i + 1).map(String::as_str).ok_or_else(|| format!("edit: {arg} needs a value"))?;
|
||||
// `edit` parses its own options (they run in order), so `--out` is checked here in the
|
||||
// same way: it wrote a PDF to a file called `--full`.
|
||||
let value = args.get(i + 1).map(String::as_str);
|
||||
if !can_be_path_value(arg, value) {
|
||||
return Err(needs_value("edit", arg, value).into());
|
||||
}
|
||||
let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?;
|
||||
match arg {
|
||||
"--out" => out = Some(value),
|
||||
"--password" => password = Some(value),
|
||||
@@ -569,7 +629,7 @@ fn file_stem(path: &str) -> String {
|
||||
fn combine(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("combine", args, COMBINE_OPTIONS)?;
|
||||
let out = flag(args, "--out").ok_or("combine: missing --out")?;
|
||||
let inputs = positional(args);
|
||||
let inputs = positional(args, COMBINE_OPTIONS);
|
||||
if inputs.len() < 2 {
|
||||
return Err("combine: give at least two input files".into());
|
||||
}
|
||||
@@ -580,7 +640,7 @@ fn combine(args: &[String]) -> Result<(), CliError> {
|
||||
|
||||
fn extract(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("extract", args, EXTRACT_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("extract: missing file")?;
|
||||
let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?;
|
||||
let out = flag(args, "--out").ok_or("extract: missing --out")?;
|
||||
let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?;
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
@@ -592,7 +652,7 @@ fn extract(args: &[String]) -> Result<(), CliError> {
|
||||
fn split(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("split", args, SPLIT_OPTIONS)?;
|
||||
use pdfcraft_engine::SplitBy;
|
||||
let path = *positional(args).first().ok_or("split: missing file")?;
|
||||
let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?;
|
||||
let by = match (flag(args, "--every"), flag(args, "--before")) {
|
||||
(Some(n), None) => {
|
||||
// A count the splitter cannot hold is refused here, before the file is opened,
|
||||
@@ -637,7 +697,7 @@ fn parse_dpi(value: &str) -> Result<f32, String> {
|
||||
|
||||
fn render(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("render", args, RENDER_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("render: missing file")?;
|
||||
let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?;
|
||||
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;
|
||||
@@ -696,7 +756,7 @@ fn render(args: &[String]) -> Result<(), CliError> {
|
||||
/// Child-process body for `check`: prints one JSON line.
|
||||
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 path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?;
|
||||
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?;
|
||||
let start = Instant::now();
|
||||
let bytes = read(path)?;
|
||||
@@ -802,7 +862,7 @@ fn check(args: &[String]) -> Result<(), CliError> {
|
||||
);
|
||||
let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
|
||||
parse_dpi(&dpi)?;
|
||||
let files = collect(&positional(args));
|
||||
let files = collect(&positional(args, CHECK_OPTIONS));
|
||||
if files.is_empty() {
|
||||
return Err("check: no PDF files found".into());
|
||||
}
|
||||
@@ -985,9 +1045,9 @@ fn run(args: &[String]) -> Result<(), CliError> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let tool = *positional(args).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?;
|
||||
let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?;
|
||||
let mut obj = serde_json::Map::new();
|
||||
for kv in positional(args).iter().skip(1) {
|
||||
for kv in positional(args, RUN_OPTIONS).iter().skip(1) {
|
||||
let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?;
|
||||
let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()));
|
||||
obj.insert(k.to_string(), value);
|
||||
@@ -1053,7 +1113,7 @@ fn ui(args: &[String]) -> Result<(), CliError> {
|
||||
// Refused rather than truncated, which would quietly reach some other port.
|
||||
let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?;
|
||||
let token = info["token"].as_str().ok_or(format!("{file}: no token"))?;
|
||||
let pos = positional(args);
|
||||
let pos = positional(args, UI_OPTIONS);
|
||||
let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?;
|
||||
let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") };
|
||||
let mut params = serde_json::Map::new();
|
||||
|
||||
Reference in New Issue
Block a user