mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Merge track r3-t06
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged)
|
||||
@@ -209,8 +210,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
Synthetic regressions check every input fragment survives exactly once in Word, HTML, RTF and PowerPoint, while ordinary superscripts stay in their table cells.
|
||||
- **2026-10-23 (M10, image export):** Soft-mask Decode intervals preserve partial opacity in Export all images and Save Image As.
|
||||
Synthetic regressions cover direct and indirect ranges, inverted/subrange alpha, packed masks, JPEG masks and unchanged image component Decode.
|
||||
- **2026-10-23 (M10, print resources):** Vector printing preserves original page artwork when existing XObjects use annotation resource names.
|
||||
The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data.
|
||||
Estimates unchanged.
|
||||
- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged.
|
||||
- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen.
|
||||
- **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged.
|
||||
- **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged.
|
||||
- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged.
|
||||
|
||||
@@ -672,6 +677,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
| 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: covered table slots retain their text in paragraph flow across Office exporters; emitted superscripts remain in their cells. Synthetic source and CLI regressions; estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: Export all images and Save Image As apply soft-mask Decode intervals to PNG alpha before resizing, including inverted/subrange, indirect, packed and JPEG masks. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: annotation appearances use unoccupied XObject names, preserving original page artwork and distinct appearances through inherited resources and print/reopen. Estimates unchanged. |
|
||||
| 2026-10-22 | minor | M4, #3916 partial: document edits and Undo/Redo cancel Organize drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. |
|
||||
| 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. |
|
||||
| 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. |
|
||||
|
||||
@@ -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;
|
||||
@@ -700,7 +760,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)?;
|
||||
@@ -806,7 +866,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());
|
||||
}
|
||||
@@ -989,9 +1049,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);
|
||||
@@ -1057,7 +1117,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();
|
||||
|
||||
@@ -27,3 +27,89 @@ fn non_edit_commands_reject_unknown_options_before_io() {
|
||||
assert!(stderr.contains("unknown option") && stderr.contains(unknown), "{args:?}: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
/// photocraft#6944: an option took the next token whatever it was, so a line with a value missing
|
||||
/// in the middle of it did something else entirely — `extract in.pdf --pages 1 --out --password`
|
||||
/// wrote the PDF to a file called `--password` and exited 0. The unknown option hidden in a value
|
||||
/// position passed the check above too, since the walk skipped it along with the option.
|
||||
///
|
||||
/// Only the options whose value is a file or folder refuse one: a password or a title can be any
|
||||
/// string, `--page` included, which `text`'s own tests pin and this does not change.
|
||||
#[test]
|
||||
fn a_file_option_refuses_a_value_that_is_another_option() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-values-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
for (option, args) in [
|
||||
("--out", vec!["extract", "missing.pdf", "--pages", "1", "--out", "--password"]),
|
||||
("--out", vec!["combine", "a.pdf", "b.pdf", "--out", "--nonsense"]),
|
||||
("--out", vec!["render", "missing.pdf", "--out", "--dpi", "96"]),
|
||||
("--out-dir", vec!["split", "missing.pdf", "--every", "2", "--out-dir", "--password"]),
|
||||
("--json", vec!["check", "missing.pdf", "--json", "--dpi"]),
|
||||
("--control", vec!["ui", "--control", "--out", "state"]),
|
||||
// `edit` parses its own options, in the order given, and had the same gap.
|
||||
("--out", vec!["edit", "missing.pdf", "--out", "--full"]),
|
||||
] {
|
||||
let out = Command::new(BIN).current_dir(&dir).args(&args).output().expect("run cli");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "{args:?} unexpectedly succeeded");
|
||||
assert!(stderr.contains(&format!("{option} needs a value")), "{args:?}: {stderr}");
|
||||
assert!(stderr.contains("another option"), "{args:?}: {stderr}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).expect("read dir").count(), 0, "nothing was written");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// And a file whose name really does start with `-` is still reachable, the way the shell reaches
|
||||
/// one and `--root` already documented: `./--name`. The error is then about the input file, which
|
||||
/// is as far as these lines get here.
|
||||
#[test]
|
||||
fn a_file_whose_name_starts_with_dashes_is_still_reachable() {
|
||||
// The error is about the file the command could not open, so the test asks which file it
|
||||
// names -- not how the OS words "not found", which differs on Windows (#3849 review).
|
||||
for (names, args) in [
|
||||
("missing.pdf", vec!["extract", "missing.pdf", "--pages", "1", "--out", "./--dash.pdf"]),
|
||||
("missing.pdf", vec!["edit", "missing.pdf", "--out", "./--dash.pdf"]),
|
||||
("./--dash.pdf", vec!["info", "./--dash.pdf"]),
|
||||
] {
|
||||
let out = Command::new(BIN).args(&args).output().expect("run cli");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "{args:?}: no such file either way");
|
||||
assert!(stderr.contains(names), "{args:?}: the error is about {names}: {stderr}");
|
||||
// Not a usage error: the `./--dash.pdf` reached the command as a file name.
|
||||
assert!(!stderr.contains("needs a value") && !stderr.contains("unknown option"), "{args:?}: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The token after an option that takes no value is not its value: `check-one --edit file.pdf`
|
||||
/// reported "check-one: missing file", the file having been read as `--edit`'s operand.
|
||||
#[test]
|
||||
fn a_flag_that_takes_no_value_does_not_swallow_the_file() {
|
||||
for args in [vec!["check-one", "--edit", "missing.pdf"], vec!["check-one", "missing.pdf", "--edit"]] {
|
||||
let out = Command::new(BIN).args(&args).output().expect("run cli");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "{args:?}");
|
||||
// Which file the error is about, not how the OS says it is missing (#3849 review).
|
||||
assert!(stderr.contains("missing.pdf"), "{args:?}: the error is about the file: {stderr}");
|
||||
assert!(!stderr.contains("missing file"), "the file was found, and then not found: {stderr}");
|
||||
assert!(!stderr.contains("needs a value"), "{args:?}: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
/// photocraft#1108: an argument that is not UTF-8 panicked in `std::env::args` ("called
|
||||
/// `Result::unwrap()` on an `Err` value"), while collecting the arguments — so it panicked even
|
||||
/// for `--help`. Such a name cannot be used here, where paths are `str`, so it is a usage error.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_argument_that_is_not_utf8_is_an_error_not_a_panic() {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
for args in [vec![OsStr::new("info"), OsStr::from_bytes(b"/tmp/caf\xe9.pdf")], vec![OsStr::new("--help"), OsStr::from_bytes(b"caf\xe9")]] {
|
||||
let out = Command::new(BIN).args(&args).output().expect("run cli");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "an error, not a panic (101): {stderr}");
|
||||
assert!(stderr.contains("not valid UTF-8"), "{stderr}");
|
||||
assert!(!stderr.contains("panicked"), "{stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,9 +612,9 @@ impl Automation {
|
||||
for p in paths {
|
||||
let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into());
|
||||
let result =
|
||||
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())).and_then(|bytes| {
|
||||
pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true)
|
||||
});
|
||||
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then(
|
||||
|bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true),
|
||||
);
|
||||
out.push(match result {
|
||||
Ok(r) => {
|
||||
let target = child(&folder, &name);
|
||||
@@ -714,7 +714,7 @@ impl Automation {
|
||||
let mut files = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
let path = self.resolve(p, false)?;
|
||||
files.push((p.to_string(), std::fs::read(&path).map_err(|e| failed(format!("{p}: {e}")))?));
|
||||
files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?));
|
||||
}
|
||||
let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?;
|
||||
let target = self.resolve(a.str("path")?, true)?;
|
||||
@@ -859,7 +859,7 @@ impl Automation {
|
||||
let result = self
|
||||
.resolve(p, false)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|src| std::fs::read(&src).map_err(|e| e.to_string()))
|
||||
.and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string()))
|
||||
.and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {}));
|
||||
out.push(match result {
|
||||
Ok(r) => {
|
||||
|
||||
@@ -382,7 +382,7 @@ impl Automation {
|
||||
"attachment" => {
|
||||
let [x, y] = a.need::<2>("at", "an attachment (its icon's top-left)")?;
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let data = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let data = crate::read_input(&path)?;
|
||||
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let icon = match a.opt_str("icon")? {
|
||||
Some(n) => pdfcraft_engine::AttachIcon::from_name(n)
|
||||
@@ -698,7 +698,7 @@ impl Automation {
|
||||
}
|
||||
};
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = crate::read_input(&path)?;
|
||||
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned()));
|
||||
let edit = Edit::AddCustomStamp {
|
||||
|
||||
@@ -125,7 +125,7 @@ impl Automation {
|
||||
let page = self.page(a)?;
|
||||
let ph = self.doc(a)?.info.pages[page].height as f64;
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = crate::read_input(&path)?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let rect = rect_arg(a, "rect")?.map(|r| to_display(r, ph));
|
||||
let mut out = self.apply(a, Edit::AddImage { page, rect, name, bytes: std::sync::Arc::new(bytes) })?;
|
||||
@@ -195,7 +195,7 @@ impl Automation {
|
||||
}
|
||||
if let Some(p) = a.opt_str("image")? {
|
||||
let path = self.resolve(p, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = crate::read_input(&path)?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
// Box and transforms first, then the new picture.
|
||||
self.apply(a, Edit::UpdateContent { page, index, content: content.clone() })?;
|
||||
|
||||
@@ -345,7 +345,8 @@ mod tests {
|
||||
// Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its
|
||||
// inputs and out_dir, walks only inside them (skipping links) and resolves every
|
||||
// discovered file again before `process` reads it or `publish` writes it.
|
||||
const RESOLVED_BY_CALLER: &[&str] = &["write_atomic", "write_atomic_with", "read_review_sidecar", "process", "linked", "walk", "publish"];
|
||||
const RESOLVED_BY_CALLER: &[&str] =
|
||||
&["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"];
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut offenders = vec![];
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
@@ -365,7 +366,8 @@ mod tests {
|
||||
|| !(trimmed.contains("std::fs")
|
||||
|| trimmed.contains("File::open")
|
||||
|| trimmed.contains("File::create")
|
||||
|| trimmed.contains("OpenOptions"))
|
||||
|| trimmed.contains("OpenOptions")
|
||||
|| trimmed.contains("read_input"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -701,7 +701,7 @@ impl Automation {
|
||||
|
||||
pub(crate) fn doc_import_data(&mut self, a: &Args) -> Result<Value> {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = crate::read_input(&path)?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let before = (self.doc(a)?.info.annotations.len(), self.doc(a)?.form.iter().filter(|f| !f.value.is_empty()).count());
|
||||
let mut out = self.apply(a, Edit::ImportData { name, bytes: std::sync::Arc::new(bytes) })?;
|
||||
|
||||
@@ -424,7 +424,7 @@ impl Automation {
|
||||
"page_replace" => {
|
||||
let pages = self.pages(&a, "pages")?;
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?);
|
||||
let bytes = Arc::new(read_input(&path)?);
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let src_pages = match a.opt_ints("from_pages")? {
|
||||
Some(p) => one_based(&p)?,
|
||||
@@ -573,7 +573,7 @@ impl Automation {
|
||||
"delete" => ImageEdit::Delete,
|
||||
"replace" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = read_input(&path)?;
|
||||
ImageEdit::Replace {
|
||||
name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
bytes: Arc::new(bytes),
|
||||
@@ -803,7 +803,7 @@ impl Automation {
|
||||
"form_fill" => self.form_fill(&a)?,
|
||||
"form_set_image" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = read_input(&path)?;
|
||||
self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })?
|
||||
}
|
||||
"form_reset" => self.form_reset(&a)?,
|
||||
@@ -1038,7 +1038,7 @@ impl Automation {
|
||||
|
||||
fn doc_open(&mut self, a: &Args) -> Result<Value> {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = read_input(&path)?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
|
||||
let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?;
|
||||
@@ -1330,7 +1330,7 @@ impl Automation {
|
||||
fn mark_file(&self, a: &Args) -> Result<Option<pdfcraft_engine::MarkFile>> {
|
||||
let Some(p) = a.opt_str("file")? else { return Ok(None) };
|
||||
let path = self.resolve(p, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = read_input(&path)?;
|
||||
let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1;
|
||||
Ok(Some(pdfcraft_engine::MarkFile {
|
||||
name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
@@ -1504,7 +1504,7 @@ impl Automation {
|
||||
let mut images = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
let path = self.resolve(p, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = read_input(&path)?;
|
||||
images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes));
|
||||
}
|
||||
let name = if images.len() == 1 {
|
||||
@@ -1519,7 +1519,7 @@ impl Automation {
|
||||
(Some(t), _) => ("Text".to_string(), t.to_string()),
|
||||
(None, Some(p)) => {
|
||||
let path = self.resolve(p, false)?;
|
||||
let t = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let t = read_input(&path)?;
|
||||
(path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t))
|
||||
}
|
||||
(None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())),
|
||||
@@ -1694,7 +1694,7 @@ impl Automation {
|
||||
fn insert_file(&mut self, a: &Args) -> Result<Value> {
|
||||
let at = self.position(a, "at")?;
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = read_input(&path)?;
|
||||
let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename })
|
||||
@@ -1737,7 +1737,7 @@ impl Automation {
|
||||
// Each file is read, converted and copied in turn, so a long list holds one file at a time.
|
||||
let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result<pdfcraft_engine::CombineSource, ToolError> {
|
||||
let path = self.resolve(p, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = read_input(&path)?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?;
|
||||
let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
@@ -1754,7 +1754,7 @@ impl Automation {
|
||||
let mut out = Vec::new();
|
||||
for &p in paths {
|
||||
let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| {
|
||||
let bytes = std::fs::read(&src).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
let bytes = read_input_bytes(&src).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into());
|
||||
let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?;
|
||||
@@ -1858,7 +1858,7 @@ impl Automation {
|
||||
if let Some(g) = group {
|
||||
firsts.push((g, path.clone(), sources.len()));
|
||||
}
|
||||
let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?);
|
||||
let bytes = Arc::new(read_input(&path)?);
|
||||
// Images and text files are made into PDFs first (#569), as in the Combine files tab.
|
||||
let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
self.session.as_pdf(&file_name, &bytes).map_err(failed)?
|
||||
@@ -2772,6 +2772,28 @@ fn unused(dir: &Path, stem: &str) -> PathBuf {
|
||||
(2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first)
|
||||
}
|
||||
|
||||
/// Largest input file a tool reads into memory (512 MiB, matching `doc_batch`).
|
||||
pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// Read a resolved input file of at most [`MAX_INPUT`] bytes without buffering past the cap.
|
||||
pub(crate) fn read_input_bytes(path: &Path) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::Read as _;
|
||||
let file = std::fs::File::open(path)?;
|
||||
if file.metadata().is_ok_and(|m| m.is_file() && m.len() > MAX_INPUT) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_INPUT + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() as u64 > MAX_INPUT {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn read_input(path: &Path) -> Result<Vec<u8>> {
|
||||
read_input_bytes(path).map_err(|e| failed(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Write and sync a temporary file in the same directory, then rename over the target.
|
||||
/// This flushes staged contents; it does not promise parent-directory durability after a
|
||||
/// power loss. Filesystems that refuse sync cause the save to fail before publication.
|
||||
@@ -3329,4 +3351,35 @@ mod tests {
|
||||
assert!(a.session.get(id).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_file_reads_refuse_inputs_exceeding_512_mib() {
|
||||
let dir = staging_dir("capped-read");
|
||||
let huge = dir.join("huge.bin");
|
||||
let file = std::fs::File::create(&huge).unwrap();
|
||||
file.set_len(MAX_INPUT + 1).unwrap();
|
||||
drop(file);
|
||||
let huge_str = huge.to_string_lossy().into_owned();
|
||||
|
||||
let mut a = Automation::new();
|
||||
let id = a.session.open("a.pdf", None, Arc::new(numbered_pages(2)), None).unwrap().0;
|
||||
for (tool, args) in [
|
||||
("doc_open", json!({ "path": huge_str })),
|
||||
("page_insert_file", json!({ "doc": id, "at": 1, "path": huge_str })),
|
||||
("page_replace", json!({ "doc": id, "pages": [1], "path": huge_str })),
|
||||
("page_add_image", json!({ "doc": id, "page": 1, "path": huge_str })),
|
||||
("doc_import_data", json!({ "doc": id, "path": huge_str })),
|
||||
("doc_create", json!({ "from": "text", "path": huge_str })),
|
||||
("doc_create", json!({ "from": "images", "paths": [huge_str] })),
|
||||
("doc_combine", json!({ "paths": [huge_str, huge_str] })),
|
||||
("form_merge_data", json!({ "paths": [huge_str], "path": dir.join("out.csv").to_string_lossy() })),
|
||||
("comment_add", json!({ "doc": id, "page": 1, "type": "attachment", "at": [72.0, 72.0], "path": huge_str })),
|
||||
("stamp_custom", json!({ "doc": id, "page": 1, "at": [72.0, 72.0], "path": huge_str })),
|
||||
("sign_trust", json!({ "paths": [huge_str] })),
|
||||
] {
|
||||
let err = a.call(tool, &args).unwrap_err().to_string();
|
||||
assert!(err.contains("input exceeds 512 MiB"), "{tool}: {err}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ const PARSE_ERROR: i64 = -32700;
|
||||
const INVALID_REQUEST: i64 = -32600;
|
||||
const METHOD_NOT_FOUND: i64 = -32601;
|
||||
const INVALID_PARAMS: i64 = -32602;
|
||||
const INTERNAL_ERROR: i64 = -32603;
|
||||
|
||||
/// Longest JSON-RPC request line [`McpServer::serve`] buffers into memory (16 MiB).
|
||||
pub const MAX_LINE_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
const INSTRUCTIONS: &str = "PdfCraft edits PDFs. Open a file with doc_open to get a document id, then inspect \
|
||||
(doc_info, text_extract, text_find, page_render) or edit it (page_*, doc_set_info). Edits are undoable \
|
||||
@@ -83,18 +87,36 @@ impl McpServer {
|
||||
}
|
||||
|
||||
/// Serve until `input` reaches end of file.
|
||||
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
||||
for line in input.lines() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match read_line_limited(&mut input, &mut buf, MAX_LINE_BYTES)? {
|
||||
LineRead::End => return Ok(()),
|
||||
LineRead::TooLong => {
|
||||
drain_line(&mut input)?;
|
||||
buf.clear();
|
||||
buf.shrink_to(64 * 1024);
|
||||
let reply = error(Value::Null, INVALID_REQUEST, &format!("request line exceeds {MAX_LINE_BYTES} bytes"));
|
||||
writeln!(output, "{reply}")?;
|
||||
output.flush()?;
|
||||
continue;
|
||||
}
|
||||
LineRead::Line => {}
|
||||
}
|
||||
if let Some(reply) = self.handle_line(&line) {
|
||||
let reply = match std::str::from_utf8(&buf) {
|
||||
Ok(line) if line.trim().is_empty() => None,
|
||||
Ok(line) => self.handle_line(line),
|
||||
Err(e) => Some(error(Value::Null, PARSE_ERROR, &format!("parse error: {e}")).to_string()),
|
||||
};
|
||||
if buf.capacity() > 64 * 1024 {
|
||||
buf.clear();
|
||||
buf.shrink_to(64 * 1024);
|
||||
}
|
||||
if let Some(reply) = reply {
|
||||
writeln!(output, "{reply}")?;
|
||||
output.flush()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle one JSON-RPC message; returns the serialized reply, if one is due.
|
||||
@@ -116,7 +138,7 @@ impl McpServer {
|
||||
};
|
||||
let params = obj.get("params").cloned().unwrap_or(Value::Null);
|
||||
let id = id?; // notifications/initialized, notifications/cancelled, …: nothing to answer
|
||||
let result = if method == "tools/call" { guarded_tool(|| self.dispatch(method, ¶ms)) } else { self.dispatch(method, ¶ms) };
|
||||
let result = guarded_dispatch(method, || self.dispatch(method, ¶ms));
|
||||
Some(match result {
|
||||
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
||||
Err((code, message)) => error(id, code, &message),
|
||||
@@ -323,11 +345,67 @@ fn unwrap_tool_call(args: &Value) -> Result<(String, Value), String> {
|
||||
Ok((name.to_owned(), inner))
|
||||
}
|
||||
|
||||
/// What [`read_line_limited`] found.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum LineRead {
|
||||
/// A line, now in the buffer (newline and a trailing `\r` stripped).
|
||||
Line,
|
||||
/// End of stream.
|
||||
End,
|
||||
/// The line is longer than the limit; at most `max + 1` bytes of it were read.
|
||||
TooLong,
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf`.
|
||||
fn read_line_limited(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> std::io::Result<LineRead> {
|
||||
use std::io::Read as _;
|
||||
buf.clear();
|
||||
// `take` bounds what is buffered for one line: never more than `max + 1` bytes.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
if reader.take(limit).read_until(b'\n', buf)? == 0 {
|
||||
return Ok(LineRead::End);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(LineRead::Line);
|
||||
}
|
||||
// No newline: either the limit was passed, or this is a last line before end of stream.
|
||||
Ok(if buf.len() > max { LineRead::TooLong } else { LineRead::Line })
|
||||
}
|
||||
|
||||
/// Discard the rest of an overlong line up to `\n` or end of stream without buffering it.
|
||||
fn drain_line(reader: &mut impl BufRead) -> std::io::Result<()> {
|
||||
loop {
|
||||
let chunk = reader.fill_buf()?;
|
||||
if chunk.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(pos) = chunk.iter().position(|&b| b == b'\n') {
|
||||
reader.consume(pos + 1);
|
||||
return Ok(());
|
||||
}
|
||||
let len = chunk.len();
|
||||
reader.consume(len);
|
||||
}
|
||||
}
|
||||
|
||||
/// Last-resort boundary shared by every tools/call route, including compact dispatch.
|
||||
fn guarded_tool(f: impl FnOnce() -> Result<Value, (i64, String)>) -> Result<Value, (i64, String)> {
|
||||
pdfcraft_engine::guard(f).unwrap_or_else(|message| Ok(tool_error(&format!("internal error: {message}"))))
|
||||
}
|
||||
|
||||
/// Last-resort boundary for every JSON-RPC method: tool calls report panics in the tool result
|
||||
/// (`isError: true`), while resource and protocol methods report a JSON-RPC internal error.
|
||||
fn guarded_dispatch(method: &str, f: impl FnOnce() -> Result<Value, (i64, String)>) -> Result<Value, (i64, String)> {
|
||||
if method == "tools/call" {
|
||||
return guarded_tool(f);
|
||||
}
|
||||
pdfcraft_engine::guard(f).unwrap_or_else(|message| Err((INTERNAL_ERROR, format!("internal error: {message}"))))
|
||||
}
|
||||
|
||||
fn tool_content(name: &str, content: Vec<Content>) -> Value {
|
||||
let failed = matches!(name, "command_batch" | "doc_batch")
|
||||
&& content.iter().any(|c| match c {
|
||||
@@ -509,7 +587,12 @@ fn call_result(content: Vec<Content>) -> Value {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Resource, first_sentence, parse_uri};
|
||||
use std::io::{BufReader, Read as _};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{McpServer, Resource, first_sentence, parse_uri};
|
||||
use crate::Automation;
|
||||
|
||||
#[test]
|
||||
fn conventions_panic_is_a_tool_error() {
|
||||
@@ -520,6 +603,59 @@ mod tests {
|
||||
assert_eq!(next["ok"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guarded_dispatch_catches_resource_and_protocol_panics() {
|
||||
for method in ["resources/read", "resources/list", "resources/templates/list", "initialize"] {
|
||||
let (code, msg) = super::guarded_dispatch(method, || panic!("synthetic {method} failure")).unwrap_err();
|
||||
assert_eq!(code, -32603, "{method}");
|
||||
assert!(msg.contains(&format!("synthetic {method} failure")), "{msg}");
|
||||
}
|
||||
let ok = super::guarded_dispatch("resources/list", || Ok(json!({"resources": []}))).unwrap();
|
||||
assert_eq!(ok["resources"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_line_limited_and_drain_line_respect_boundaries() {
|
||||
use super::{LineRead, drain_line, read_line_limited};
|
||||
let mut buf = Vec::new();
|
||||
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
|
||||
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line);
|
||||
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
|
||||
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line);
|
||||
assert_eq!(buf, b"bb", "CRLF is stripped");
|
||||
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line);
|
||||
assert_eq!(buf, b"cc", "a last line without a newline is still read");
|
||||
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::End);
|
||||
|
||||
let mut overlong: &[u8] = b"0123456789abcdef\nafter\n";
|
||||
assert_eq!(read_line_limited(&mut overlong, &mut buf, 10).unwrap(), LineRead::TooLong);
|
||||
assert_eq!(buf.len(), 11, "at most max + 1 bytes are buffered");
|
||||
drain_line(&mut overlong).unwrap();
|
||||
assert_eq!(read_line_limited(&mut overlong, &mut buf, 10).unwrap(), LineRead::Line);
|
||||
assert_eq!(buf, b"after", "the next line after an overlong line is read cleanly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_answers_non_utf8_and_overlong_lines_and_keeps_serving() {
|
||||
let init = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "0" } }
|
||||
})
|
||||
.to_string();
|
||||
let tail = format!("\n{init}\n");
|
||||
let stream = (&b"\xff\n"[..]).chain(std::io::repeat(b'a').take(64 * 1024 * 1024)).chain(tail.as_bytes());
|
||||
let mut out = Vec::new();
|
||||
McpServer::new(Automation::new()).serve(BufReader::new(stream), &mut out).unwrap();
|
||||
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 3, "{replies:?}");
|
||||
assert_eq!(replies[0]["error"]["code"], -32700);
|
||||
assert_eq!(replies[1]["error"]["code"], -32600);
|
||||
assert_eq!(replies[2]["id"], 1);
|
||||
assert_eq!(replies[2]["result"]["serverInfo"]["name"], "pdfcraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_sentence_skips_abbreviations() {
|
||||
assert_eq!(first_sentence("Rotate pages. Undoable."), "Rotate pages.");
|
||||
|
||||
@@ -22,11 +22,72 @@ fn duplex(value: &str, cut_stack: bool) -> Result<Option<spool::Duplex>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// [`duplex`] for a job laid out as `layout`: the name is checked either way, and a booklet
|
||||
/// decides two-sided itself (#2707), as the Print dialog does (`print::booklet_duplex`).
|
||||
fn job_duplex(value: &str, layout: &Layout) -> Result<Option<spool::Duplex>> {
|
||||
let chosen = duplex(value, matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?;
|
||||
Ok(match layout {
|
||||
Layout::Booklet { subset, .. } => Some(print::booklet_duplex(*subset)),
|
||||
_ => chosen,
|
||||
})
|
||||
}
|
||||
|
||||
/// An invalid-arguments error saying `m`.
|
||||
fn bad(m: impl Into<String>) -> ToolError {
|
||||
ToolError::InvalidArgs(m.into())
|
||||
}
|
||||
|
||||
/// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS`
|
||||
/// index and size; US Letter when none is given.
|
||||
fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> {
|
||||
let mut papers = PAPERS.iter().enumerate();
|
||||
let found = match name {
|
||||
None => papers.next(),
|
||||
Some(p) => papers.find(|(_, (n, _))| {
|
||||
n.eq_ignore_ascii_case(p)
|
||||
|| n.replace("US ", "").eq_ignore_ascii_case(p)
|
||||
|| n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p))
|
||||
}),
|
||||
};
|
||||
found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| {
|
||||
bad(format!(
|
||||
"unknown paper {:?}; choose one of {}",
|
||||
name.unwrap_or_default(),
|
||||
PAPERS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on
|
||||
/// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default
|
||||
/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752).
|
||||
fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duplex>, paper: usize) -> Result<spool::Job> {
|
||||
let options = match a.get("options") {
|
||||
None => Vec::new(),
|
||||
Some(Value::Object(m)) => m
|
||||
.iter()
|
||||
.map(|(k, v)| match v.as_str() {
|
||||
Some(v) => Ok((k.clone(), v.to_string())),
|
||||
None => Err(bad(format!("options.{k} must be a string (a choice from printer_options)"))),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
Some(_) => return Err(bad("options must be an object of option keys and choices from printer_options")),
|
||||
};
|
||||
Ok(spool::Job {
|
||||
printer: if printer == "default" { None } else { Some(printer.to_string()) },
|
||||
copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32,
|
||||
collate: a.opt_bool("collate")?.unwrap_or(true),
|
||||
duplex,
|
||||
scaling: spool::Scaling::Exact,
|
||||
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
|
||||
title,
|
||||
options: options.into_iter().chain(print::media_option(paper)).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// `printers`: the system's printers, the default marked, and each one's two-sided support
|
||||
/// where the spooler reports it.
|
||||
pub(crate) fn printers(&self) -> Result<Value> {
|
||||
let list: Vec<Value> = spool::printers()
|
||||
.into_iter()
|
||||
@@ -53,6 +114,8 @@ impl Automation {
|
||||
Ok(json!({ "printer": printer, "count": list.len(), "options": list }))
|
||||
}
|
||||
|
||||
/// `doc_print`: lay the document out with the Print dialog's options, then save the
|
||||
/// print-ready PDF to `path` or send it to `printer` with the chosen paper.
|
||||
pub(crate) fn doc_print(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let (id, count) = (doc.id, doc.info.pages.len());
|
||||
@@ -108,7 +171,7 @@ impl Automation {
|
||||
},
|
||||
l => return Err(bad(format!("unknown layout {l:?}"))),
|
||||
};
|
||||
let duplex = duplex(a.opt_str("duplex")?.unwrap_or("off"), matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?;
|
||||
let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?;
|
||||
if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) {
|
||||
return Err(bad("cut-stack order needs layout multiple"));
|
||||
}
|
||||
@@ -125,20 +188,7 @@ impl Automation {
|
||||
"form-fields-only" => Content::FormFieldsOnly,
|
||||
c => return Err(bad(format!("unknown comments_forms {c:?}"))),
|
||||
};
|
||||
let paper = match a.opt_str("paper")? {
|
||||
None => PAPERS[0].1,
|
||||
Some(p) => PAPERS
|
||||
.iter()
|
||||
.find(|(n, _)| {
|
||||
n.eq_ignore_ascii_case(p)
|
||||
|| n.replace("US ", "").eq_ignore_ascii_case(p)
|
||||
|| n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p))
|
||||
})
|
||||
.map(|x| x.1)
|
||||
.ok_or_else(|| {
|
||||
bad(format!("unknown paper {p:?}; choose one of {}", PAPERS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")))
|
||||
})?,
|
||||
};
|
||||
let (paper_index, paper) = paper_named(a.opt_str("paper")?)?;
|
||||
// Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when
|
||||
// `grayscale` is also asked for.
|
||||
let raster = if a.opt_bool("as_image")?.unwrap_or(false) {
|
||||
@@ -162,27 +212,7 @@ impl Automation {
|
||||
out["path"] = json!(path.to_string_lossy());
|
||||
}
|
||||
(None, Some(printer)) => {
|
||||
let name = self.doc(a)?.name.clone();
|
||||
let job = spool::Job {
|
||||
printer: if printer == "default" { None } else { Some(printer.to_string()) },
|
||||
copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32,
|
||||
collate: a.opt_bool("collate")?.unwrap_or(true),
|
||||
duplex,
|
||||
scaling: spool::Scaling::Exact,
|
||||
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
|
||||
title: name,
|
||||
options: match a.get("options") {
|
||||
None => Vec::new(),
|
||||
Some(Value::Object(m)) => m
|
||||
.iter()
|
||||
.map(|(k, v)| match v.as_str() {
|
||||
Some(v) => Ok((k.clone(), v.to_string())),
|
||||
None => Err(bad(format!("options.{k} must be a string (a choice from printer_options)"))),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
Some(_) => return Err(bad("options must be an object of option keys and choices from printer_options")),
|
||||
},
|
||||
};
|
||||
let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?;
|
||||
out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?);
|
||||
}
|
||||
_ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")),
|
||||
@@ -195,6 +225,19 @@ impl Automation {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #2707: a booklet sets two-sided itself whatever `duplex` asks; a bad name is still an error.
|
||||
#[test]
|
||||
fn a_booklet_decides_two_sided_itself() {
|
||||
let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left };
|
||||
for asked in ["printer", "off", "long-edge", "short-edge"] {
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}");
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}");
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::BackOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}");
|
||||
}
|
||||
assert!(job_duplex("sideways", &booklet(BookletSubset::BothSides)).is_err());
|
||||
assert_eq!(job_duplex("long-edge", &Layout::Size(SizeMode::Fit)).ok(), Some(Some(spool::Duplex::LongEdge)), "other layouts keep it");
|
||||
}
|
||||
|
||||
/// `printer` leaves two-sided to the printer's own default (#2422); cut and stack still asks
|
||||
/// for single-sided sheets, and refuses an explicit edge.
|
||||
#[test]
|
||||
@@ -207,4 +250,30 @@ mod tests {
|
||||
assert!(duplex("long-edge", true).is_err());
|
||||
assert!(duplex("sideways", false).is_err());
|
||||
}
|
||||
|
||||
/// #3752: `doc_print` to a printer sent no paper, so the queue's default paper stood in for
|
||||
/// the one the sheets were laid out on (A4 under Letter sheets on the report's queue).
|
||||
#[test]
|
||||
fn printing_to_a_printer_feeds_the_laid_out_paper() {
|
||||
let job = |args: Value, paper_name: Option<&str>| {
|
||||
let (index, _) = paper_named(paper_name).unwrap();
|
||||
spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap()
|
||||
};
|
||||
let media = |m: &str| ("media".to_string(), m.to_string());
|
||||
// US Letter when no paper is asked for, after the driver's own options.
|
||||
let letter = job(json!({ "options": { "InputSlot": "Tray3" } }), None);
|
||||
assert_eq!(letter.options, [("InputSlot".to_string(), "Tray3".to_string()), media("Letter")]);
|
||||
assert_eq!((letter.printer.as_deref(), letter.title.as_str()), (Some("Office"), "a.pdf"));
|
||||
// Every paper, by its full name, goes out as its own media name.
|
||||
for (i, (name, size)) in PAPERS.iter().enumerate() {
|
||||
assert_eq!(paper_named(Some(name)).ok(), Some((i, *size)), "{name}");
|
||||
assert_eq!(job(json!({}), Some(name)).options, print::media_option(i).into_iter().collect::<Vec<_>>(), "{name}");
|
||||
}
|
||||
// Short forms find the same paper.
|
||||
assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]);
|
||||
assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]);
|
||||
assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter")));
|
||||
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None));
|
||||
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ impl Automation {
|
||||
return Err(failed(format!("windows:{r}: Windows certificate store identities are only available on Windows")));
|
||||
}
|
||||
let path = self.resolve(a.str("id")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = crate::read_input(&path)?;
|
||||
sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(|e| match e {
|
||||
sign::SignError::WrongPassword => bad("the digital ID password is incorrect"),
|
||||
e => failed(e),
|
||||
@@ -320,7 +320,7 @@ impl Automation {
|
||||
};
|
||||
for p in paths {
|
||||
let path = self.resolve(&p, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let bytes = crate::read_input(&path)?;
|
||||
let lower = p.to_ascii_lowercase();
|
||||
let found = if lower.ends_with(".p12") || lower.ends_with(".pfx") {
|
||||
let id = sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(failed)?;
|
||||
|
||||
@@ -543,7 +543,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"doc_print",
|
||||
"Print",
|
||||
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
|
||||
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
|
||||
+123
-16
@@ -34,6 +34,37 @@ const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"];
|
||||
/// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800).
|
||||
const MAX_COPY_DEPTH: usize = 256;
|
||||
|
||||
/// Keys that stay on the widget when a combined field/widget is split (#1810): the annotation's
|
||||
/// appearance, placement and page. Field state (name, value, flags, default appearance) moves to
|
||||
/// the new parent field, where the widgets inherit it.
|
||||
const WIDGET_KEYS: &[&[u8]] = &[
|
||||
b"Type",
|
||||
b"Subtype",
|
||||
b"Rect",
|
||||
b"P",
|
||||
b"AP",
|
||||
b"AS",
|
||||
b"MK",
|
||||
b"BS",
|
||||
b"Border",
|
||||
b"C",
|
||||
b"CA",
|
||||
b"F",
|
||||
b"H",
|
||||
b"R",
|
||||
b"NM",
|
||||
b"M",
|
||||
b"StructParent",
|
||||
b"StructParents",
|
||||
b"OC",
|
||||
b"Tabs",
|
||||
b"A",
|
||||
b"Contents",
|
||||
b"BM",
|
||||
b"Lang",
|
||||
b"AF",
|
||||
];
|
||||
|
||||
struct Copier<'a> {
|
||||
src: &'a Document,
|
||||
/// Current recursion depth, bounded by [`MAX_COPY_DEPTH`].
|
||||
@@ -404,27 +435,33 @@ fn import_pages_mapped(
|
||||
let r = dst.add(Object::Null);
|
||||
let c = copier.copy_value(dst, &resolved, Some(r))?;
|
||||
dst.set(r, c);
|
||||
// `/Kids` may be an array object of its own: add to it there, so the
|
||||
// field keeps its other widgets.
|
||||
match dst.get(copied).as_dict().and_then(|f| f.get(b"Kids").cloned()) {
|
||||
Some(Object::Ref(list)) => {
|
||||
let mut kids = dst.get(list).as_array().cloned().unwrap_or_default();
|
||||
kids.push(Object::Ref(r));
|
||||
dst.set(list, Object::Array(kids));
|
||||
}
|
||||
_ => dst.update_dict(copied, |f| {
|
||||
let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default();
|
||||
kids.push(Object::Ref(r));
|
||||
f.set(b"Kids".to_vec(), Object::Array(kids));
|
||||
})?,
|
||||
}
|
||||
add_kid(dst, copied, r)?;
|
||||
annot_pages.insert(r, new);
|
||||
out.push(Object::Ref(r));
|
||||
continue;
|
||||
}
|
||||
// A widget that is its own field (no parent to hang a second widget on) stays
|
||||
// the one object, shown on both pages, so the field stays one.
|
||||
// A widget that is its own field (no parent to hang a second widget on): on
|
||||
// its first copy it is split into a parent field and a widget child (#1810);
|
||||
// every later copy of the page gets one more widget child of that field, so
|
||||
// the pages no longer share one widget whose `/P` names only the last page.
|
||||
let own_field = resolved.as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent"));
|
||||
if own_field
|
||||
&& let Some(first) = a.as_ref().and_then(|r| copier.map.get(&r).copied())
|
||||
&& let Some(parent) = dst.get(first).as_dict().and_then(|d| d.reference(b"Parent"))
|
||||
{
|
||||
let r = dst.add(Object::Null);
|
||||
let c = copier.copy_value(dst, &resolved, Some(r))?;
|
||||
let Object::Dict(d) = c else { continue };
|
||||
let (_, mut d) = field_and_widget(dst, &d);
|
||||
d.set(b"Parent".to_vec(), Object::Ref(parent));
|
||||
// The new widget is not a field root of its own.
|
||||
copier.fields.retain(|f| *f != r);
|
||||
dst.set(r, Object::Dict(d));
|
||||
add_kid(dst, parent, r)?;
|
||||
annot_pages.insert(r, new);
|
||||
out.push(Object::Ref(r));
|
||||
continue;
|
||||
}
|
||||
let annots = if own_field { &mut copier } else { own.as_mut().unwrap_or(&mut copier) };
|
||||
// Direct (inline) annotation dictionaries become indirect objects, so every
|
||||
// copied annotation can be fixed up the same way.
|
||||
@@ -438,6 +475,9 @@ fn import_pages_mapped(
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if own_field && copier.fields.contains(&r) {
|
||||
split_own_field(dst, &mut copier.fields, r);
|
||||
}
|
||||
annot_pages.insert(r, new);
|
||||
out.push(Object::Ref(r));
|
||||
}
|
||||
@@ -834,6 +874,73 @@ fn qualified_optional(prefix: &str, name: &str) -> String {
|
||||
if name.is_empty() { prefix.to_string() } else { qualified(prefix, name) }
|
||||
}
|
||||
|
||||
/// Add `kid` to `field`'s `/Kids`, in the array object itself when `/Kids` is one, so the field
|
||||
/// keeps its other widgets.
|
||||
fn add_kid(dst: &mut Document, field: ObjRef, kid: ObjRef) -> Result<(), OrganizeError> {
|
||||
match dst.get(field).as_dict().and_then(|f| f.get(b"Kids").cloned()) {
|
||||
Some(Object::Ref(list)) => {
|
||||
let mut kids = dst.get(list).as_array().cloned().unwrap_or_default();
|
||||
kids.push(Object::Ref(kid));
|
||||
dst.set(list, Object::Array(kids));
|
||||
}
|
||||
_ => dst.update_dict(field, |f| {
|
||||
let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default();
|
||||
kids.push(Object::Ref(kid));
|
||||
f.set(b"Kids".to_vec(), Object::Array(kids));
|
||||
})?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PdfCraft patch (#1810): split a widget that doubles as its own field into a parent field
|
||||
/// holding the field keys (name, value, flags…) and a widget child holding the annotation keys
|
||||
/// (rectangle, appearance, page). The parent takes the widget's place among the top-level fields;
|
||||
/// later copies of the page add widget children of it, so every page has its own widget and the
|
||||
/// field stays one.
|
||||
fn split_own_field(dst: &mut Document, fields: &mut [ObjRef], widget: ObjRef) {
|
||||
let Some(d) = dst.get(widget).as_dict().cloned() else { return };
|
||||
let (mut field, mut child) = field_and_widget(dst, &d);
|
||||
let parent = dst.add(Object::Null);
|
||||
field.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(widget)]));
|
||||
child.set(b"Parent".to_vec(), Object::Ref(parent));
|
||||
dst.set(parent, Object::Dict(field));
|
||||
dst.set(widget, Object::Dict(child));
|
||||
if let Some(entry) = fields.iter_mut().find(|f| **f == widget) {
|
||||
*entry = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/// A combined field/widget has two additional-action namespaces (ISO 32000-1 tables 194/196).
|
||||
/// Partition them as well as the ordinary entries, including when copying a later widget.
|
||||
fn field_and_widget(doc: &Document, d: &Dict) -> (Dict, Dict) {
|
||||
let (mut field, mut child) = (Dict::new(), Dict::new());
|
||||
for (k, v) in d.iter() {
|
||||
if k.as_slice() == b"AA"
|
||||
&& let Some(actions) = doc.resolve(v).as_dict().cloned()
|
||||
{
|
||||
let (mut field_actions, mut widget_actions) = (Dict::new(), Dict::new());
|
||||
for (trigger, action) in actions.iter() {
|
||||
if [b"E".as_slice(), b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"].contains(&trigger.as_slice()) {
|
||||
widget_actions.set(trigger.clone(), action.clone());
|
||||
} else {
|
||||
field_actions.set(trigger.clone(), action.clone());
|
||||
}
|
||||
}
|
||||
if field_actions.iter().next().is_some() {
|
||||
field.set(k.clone(), Object::Dict(field_actions));
|
||||
}
|
||||
if widget_actions.iter().next().is_some() {
|
||||
child.set(k.clone(), Object::Dict(widget_actions));
|
||||
}
|
||||
} else if WIDGET_KEYS.contains(&k.as_slice()) {
|
||||
child.set(k.clone(), v.clone());
|
||||
} else {
|
||||
field.set(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
(field, child)
|
||||
}
|
||||
|
||||
/// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed).
|
||||
fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> {
|
||||
if fields.is_empty() {
|
||||
|
||||
+119
-13
@@ -931,9 +931,111 @@ fn links_are_rewired_to_copied_pages_or_dropped() {
|
||||
assert!(!link.contains(b"Dest") && !link.contains(b"A"));
|
||||
}
|
||||
|
||||
/// A widget annotation that is its own form field (no `/Parent`).
|
||||
fn own_field(doc: &Document, r: ObjRef) -> bool {
|
||||
doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent"))
|
||||
/// Issue #1810: a widget that is its own field was shared between both copies of a repeated
|
||||
/// page, so only the last page could identify it (its `/P`), and per-page annotation edits were
|
||||
/// impossible. Each copy must get its own widget sharing one parent field (name and value).
|
||||
#[test]
|
||||
fn repeated_page_splits_a_combined_field_widget() {
|
||||
let out = full_roundtrip(&extract_pages(&doc_a(), &[1, 1]).unwrap());
|
||||
let ps = pages(&out).unwrap();
|
||||
let widget = |i: usize| -> ObjRef {
|
||||
let list = page_dict(&out, i).get(b"Annots").map(|a| out.resolve(a).as_array().cloned().unwrap_or_default()).unwrap_or_default();
|
||||
list.iter()
|
||||
.filter_map(|a| a.as_ref())
|
||||
.find(|r| out.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget")))
|
||||
.expect("a widget annotation")
|
||||
};
|
||||
let (w1, w2) = (widget(0), widget(1));
|
||||
assert_ne!(w1, w2, "each copy of the page has its own widget");
|
||||
for (i, w) in [(0, w1), (1, w2)] {
|
||||
let d = out.get(w).as_dict().unwrap().clone();
|
||||
for key in [b"T".as_slice(), b"V", b"FT"] {
|
||||
assert!(!d.contains(key), "a widget inherits field entries: {key:?}");
|
||||
}
|
||||
assert_eq!(d.reference(b"P"), Some(ps[i].obj), "/P names the page holding the widget");
|
||||
let parent = d.reference(b"Parent").expect("the widget has a parent field");
|
||||
let f = out.get(parent).as_dict().unwrap().clone();
|
||||
assert_eq!(f.get(b"T").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("name"));
|
||||
assert_eq!(f.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("Ada"));
|
||||
let kids: Vec<ObjRef> =
|
||||
f.get(b"Kids").and_then(|k| out.resolve(k).as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect();
|
||||
assert!(kids.contains(&w1) && kids.contains(&w2), "one field holds both widgets: {kids:?}");
|
||||
let fields: Vec<ObjRef> = catalog(&out)
|
||||
.get(b"AcroForm")
|
||||
.map(|f| out.resolve(f))
|
||||
.and_then(|f| f.as_dict().cloned())
|
||||
.and_then(|f| f.get(b"Fields").map(|x| out.resolve(x)))
|
||||
.and_then(|f| f.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|f| f.as_ref())
|
||||
.collect();
|
||||
assert_eq!(fields, [parent], "the parent is the one top-level field");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_a_combined_widget_keeps_annotation_actions_and_field_triggers() {
|
||||
let mut src = doc_a();
|
||||
let page = pages(&src).unwrap()[1].obj;
|
||||
let list = page_dict(&src, 1).get(b"Annots").and_then(|a| a.as_array()).cloned().unwrap();
|
||||
let widget =
|
||||
list.iter().filter_map(Object::as_ref).find(|r| src.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))).unwrap();
|
||||
let mut action = Dict::new();
|
||||
action.set(b"S".to_vec(), Object::name("GoTo"));
|
||||
action.set(b"D".to_vec(), Object::Array(vec![Object::Ref(page), Object::name("Fit")]));
|
||||
let mut named = Dict::new();
|
||||
named.set(b"S".to_vec(), Object::name("Named"));
|
||||
named.set(b"N".to_vec(), Object::name("NextPage"));
|
||||
let mut aa = Dict::new();
|
||||
aa.set(b"E".to_vec(), Object::Dict(named.clone()));
|
||||
aa.set(b"K".to_vec(), Object::Dict(named));
|
||||
let aa = src.add(Object::Dict(aa));
|
||||
src.update_dict(widget, |d| {
|
||||
d.set(b"A".to_vec(), Object::Dict(action));
|
||||
d.set(b"AA".to_vec(), Object::Ref(aa));
|
||||
})
|
||||
.unwrap();
|
||||
let out = full_roundtrip(&extract_pages(&src, &[1, 1]).unwrap());
|
||||
let ps = pages(&out).unwrap();
|
||||
for i in 0..2 {
|
||||
let w = annots(&out, i).into_iter().find(|d| d.name(b"Subtype") == Some(b"Widget")).unwrap();
|
||||
let action = out.resolve(w.get(b"A").expect("the widget keeps its action"));
|
||||
let dest = action.as_dict().unwrap().get(b"D").and_then(|a| a.as_array()).unwrap()[0].as_ref().unwrap();
|
||||
assert!(ps.iter().any(|p| p.obj == dest), "the action targets a copied page");
|
||||
let waa = out.resolve(w.get(b"AA").expect("widget triggers"));
|
||||
assert!(waa.as_dict().unwrap().contains(b"E"));
|
||||
assert!(!waa.as_dict().unwrap().contains(b"K"));
|
||||
let parent = out.get(w.reference(b"Parent").unwrap()).as_dict().unwrap().clone();
|
||||
assert!(!parent.contains(b"A"));
|
||||
let faa = out.resolve(parent.get(b"AA").expect("field triggers"));
|
||||
assert!(faa.as_dict().unwrap().contains(b"K"));
|
||||
assert!(!faa.as_dict().unwrap().contains(b"E"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_a_combined_widget_keeps_its_annotation_only_keys() {
|
||||
let mut src = doc_a();
|
||||
let list = page_dict(&src, 1).get(b"Annots").and_then(|a| a.as_array()).cloned().unwrap();
|
||||
let widget =
|
||||
list.iter().filter_map(Object::as_ref).find(|r| src.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))).unwrap();
|
||||
src.update_dict(widget, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::String(PdfString::text("alt text")));
|
||||
d.set(b"BM".to_vec(), Object::name("Multiply"));
|
||||
d.set(b"Lang".to_vec(), Object::String(PdfString::text("en-US")));
|
||||
d.set(b"AF".to_vec(), Object::Array(Vec::new()));
|
||||
})
|
||||
.unwrap();
|
||||
let out = full_roundtrip(&extract_pages(&src, &[1, 1]).unwrap());
|
||||
for i in 0..2 {
|
||||
let w = annots(&out, i).into_iter().find(|d| d.name(b"Subtype") == Some(b"Widget")).unwrap();
|
||||
let parent = out.get(w.reference(b"Parent").unwrap()).as_dict().unwrap().clone();
|
||||
for key in [b"Contents".as_slice(), b"BM", b"Lang", b"AF"] {
|
||||
assert!(w.contains(key), "the widget keeps {key:?}");
|
||||
assert!(!parent.contains(key), "the parent field does not take {key:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -949,15 +1051,11 @@ fn repeated_page_gets_independent_annotations() {
|
||||
assert!(!refs[i].is_empty());
|
||||
assert_eq!(refs[i].len(), refs[i + 1].len());
|
||||
for (a, b) in refs[i].iter().zip(&refs[i + 1]) {
|
||||
// A widget that is its own field stays one object on both pages, so the field stays
|
||||
// one (#790); every other annotation is copied.
|
||||
if !own_field(&out, *a) {
|
||||
assert_ne!(a, b, "each copy of the page has its own annotation objects");
|
||||
}
|
||||
assert_ne!(a, b, "each copy of the page has its own annotation objects");
|
||||
}
|
||||
}
|
||||
for (i, list) in refs.iter().enumerate() {
|
||||
for a in list.iter().filter(|a| !own_field(&out, **a)) {
|
||||
for a in list.iter() {
|
||||
assert_eq!(out.get(*a).as_dict().unwrap().reference(b"P"), Some(ps[i].obj), "/P names the page holding the copy");
|
||||
}
|
||||
}
|
||||
@@ -978,7 +1076,10 @@ fn form_fields_come_along_and_are_registered() {
|
||||
assert_eq!(fields.len(), 1);
|
||||
let field = out.resolve(&fields[0]).as_dict().cloned().unwrap();
|
||||
assert_eq!(field.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("Ada"));
|
||||
assert_eq!(field.reference(b"P"), Some(pages(&out).unwrap()[0].obj));
|
||||
// The widget child, not the field, names the page (#1810).
|
||||
let kid =
|
||||
field.get(b"Kids").map(|k| out.resolve(k)).and_then(|k| k.as_array().cloned()).and_then(|k| k.first().and_then(|k| k.as_ref())).unwrap();
|
||||
assert_eq!(out.get(kid).as_dict().unwrap().reference(b"P"), Some(pages(&out).unwrap()[0].obj));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2850,8 +2951,9 @@ fn import_renames_a_field_colliding_with_another_incoming_field() {
|
||||
assert_eq!(form_field_names(&dst), ["Other", "Text1", "Text1-1"]);
|
||||
}
|
||||
|
||||
/// A page listed twice shows its widget on both copies — also when the field was renamed: both
|
||||
/// pages' widgets belong to the one renamed field, and no second Text1 appears.
|
||||
/// A page listed twice shows its widget on both copies — also when the field was renamed: each
|
||||
/// copy has its own widget, both widgets belong to the one renamed field (#1810), and no second
|
||||
/// Text1 appears.
|
||||
#[test]
|
||||
fn a_repeated_page_joins_the_renamed_field() {
|
||||
let mut dst = doc_with_field("Text1");
|
||||
@@ -2868,10 +2970,14 @@ fn a_repeated_page_joins_the_renamed_field() {
|
||||
.and_then(|l| l.as_array().cloned())
|
||||
.unwrap();
|
||||
let renamed = fields[1].clone();
|
||||
let mut widgets = Vec::new();
|
||||
for page in [1, 2] {
|
||||
let list = page_dict(&dst, page).get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap();
|
||||
assert!(list.contains(&renamed), "page {page}'s widget belongs to the renamed field");
|
||||
let widget = list.iter().find_map(|a| a.as_ref()).expect("a widget on the page");
|
||||
widgets.push(widget);
|
||||
assert_eq!(dst.get(widget).as_dict().unwrap().reference(b"Parent"), renamed.as_ref(), "page {page}'s widget belongs to the renamed field");
|
||||
}
|
||||
assert_ne!(widgets[0], widgets[1], "each copy has its own widget");
|
||||
}
|
||||
|
||||
/// Combining forms keeps same-named fields separate — the second file's colliding field is
|
||||
|
||||
@@ -35,6 +35,7 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default()
|
||||
Each source page becomes a Form XObject (its content wrapped in q/Q, plus the printable
|
||||
annotations); sheets place them with a clip. The result is a fresh, unencrypted,
|
||||
garbage-collected file (callers check the print permission).
|
||||
Annotation appearances receive unused XObject names, preserving existing page resources even when they use the same naming prefix.
|
||||
|
||||
The GUI Print dialog remembers its last selected paper size across application sessions,
|
||||
including when Print is cancelled. The preference stores a canonical name such as `A4`;
|
||||
|
||||
+61
-16
@@ -149,6 +149,19 @@ pub enum BookletSubset {
|
||||
BackOnly,
|
||||
}
|
||||
|
||||
/// How a booklet goes through the printer (#2707): it decides two-sided printing itself, as
|
||||
/// Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro,
|
||||
/// Print dialog box options: "Acrobat automatically enables duplex printing, if available, for
|
||||
/// booklet printing"). Both sides flip on the short edge, the fold of a 2-up sheet turned
|
||||
/// sideways; front side only or back side only is one pass of a manual duplex and prints
|
||||
/// single-sided.
|
||||
pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex {
|
||||
match subset {
|
||||
BookletSubset::BothSides => spool::Duplex::ShortEdge,
|
||||
BookletSubset::FrontOnly | BookletSubset::BackOnly => spool::Duplex::Off,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Binding {
|
||||
#[default]
|
||||
@@ -307,20 +320,45 @@ pub const PAPERS: [(&str, (f64, f64)); 21] = [
|
||||
pub const LABEL_PAPER: usize = 19;
|
||||
|
||||
/// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out
|
||||
/// on rather than the queue's default (PPD keywords; `w288h432` is the usual 4×6 label, `Env10`
|
||||
/// the US #10 envelope).
|
||||
/// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes
|
||||
/// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope).
|
||||
/// The rest use PWG 5101.1 names (ARCH E1 is its `wide-format` size), spelled exactly as the
|
||||
/// spec and the IANA registry have them: a driverless printer may take only the names it
|
||||
/// advertises. They carry their dimensions, so CUPS feeds the PPD size of those dimensions
|
||||
/// whatever the driver calls it (`ARCHD`, `AnsiD`) (#3752).
|
||||
pub fn cups_media(index: usize) -> Option<&'static str> {
|
||||
match PAPERS.get(index)?.0 {
|
||||
"US Letter" => Some("Letter"),
|
||||
"US Legal" => Some("Legal"),
|
||||
"Tabloid" => Some("Tabloid"),
|
||||
"A3" => Some("A3"),
|
||||
"A4" => Some("A4"),
|
||||
"A5" => Some("A5"),
|
||||
"US #10 Envelope (4.125 x 9.5 in)" => Some("Env10"),
|
||||
_ if index == LABEL_PAPER => Some("w288h432"),
|
||||
_ => None,
|
||||
}
|
||||
Some(match PAPERS.get(index)?.0 {
|
||||
"US Letter" => "Letter",
|
||||
"US Legal" => "Legal",
|
||||
"Tabloid" => "Tabloid",
|
||||
"A3" => "A3",
|
||||
"A4" => "A4",
|
||||
"A5" => "A5",
|
||||
"A6" => "iso_a6_105x148mm",
|
||||
"ARCH A (9 x 12 in)" => "na_arch-a_9x12in",
|
||||
"ARCH B (12 x 18 in)" => "na_arch-b_12x18in",
|
||||
"ARCH C (18 x 24 in)" => "na_arch-c_18x24in",
|
||||
"ARCH D (24 x 36 in)" => "na_arch-d_24x36in",
|
||||
"ARCH E1 (30 x 42 in)" => "na_wide-format_30x42in",
|
||||
"ARCH E (36 x 48 in)" => "na_arch-e_36x48in",
|
||||
"ANSI C (17 x 22 in)" => "na_c_17x22in",
|
||||
"ANSI D (22 x 34 in)" => "na_d_22x34in",
|
||||
"ANSI E (34 x 44 in)" => "na_e_34x44in",
|
||||
"A2" => "iso_a2_420x594mm",
|
||||
"A1" => "iso_a1_594x841mm",
|
||||
"A0" => "iso_a0_841x1189mm",
|
||||
"US #10 Envelope (4.125 x 9.5 in)" => "Env10",
|
||||
_ if index == LABEL_PAPER => "w288h432",
|
||||
// A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's
|
||||
// paper stands, as it did for every size before #1583.
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The job option that feeds `PAPERS[index]` (`media`, see [`cups_media`]), for the Print
|
||||
/// dialog's jobs and `doc_print`'s alike.
|
||||
pub fn media_option(index: usize) -> Option<(String, String)> {
|
||||
cups_media(index).map(|m| ("media".to_string(), m.to_string()))
|
||||
}
|
||||
|
||||
/// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid
|
||||
@@ -839,7 +877,7 @@ fn page_form(
|
||||
}
|
||||
let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default();
|
||||
let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default();
|
||||
let mut k = 0;
|
||||
let mut k = 0usize;
|
||||
for a in annots {
|
||||
let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue };
|
||||
if !prints(doc, &d, content) {
|
||||
@@ -871,8 +909,15 @@ fn page_form(
|
||||
} else {
|
||||
ap
|
||||
};
|
||||
let name = format!("PCPrA{k}");
|
||||
k += 1;
|
||||
// Keep the page's own XObjects, including names previous print operations generated.
|
||||
// Check the growing map so each appearance also keeps the name just assigned to it.
|
||||
let name = loop {
|
||||
let name = format!("PCPrA{k}");
|
||||
k = k.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation appearance resources".into()))?;
|
||||
if !xobjects.contains(name.as_bytes()) {
|
||||
break name;
|
||||
}
|
||||
};
|
||||
let [a0, b0, c0, d0, e0, f0] = m.0;
|
||||
body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes());
|
||||
xobjects.set(name.into_bytes(), Object::Ref(ap));
|
||||
|
||||
@@ -70,7 +70,7 @@ pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset
|
||||
Some(_) if labels.iter().any(|l| l == part) => pages.push(page_of(part, count, labels)?),
|
||||
Some((a, b)) => {
|
||||
let from = if a.trim().is_empty() { 0 } else { page_of(a, count, labels)? };
|
||||
let to = if b.trim().is_empty() { count.saturating_sub(1) } else { page_of(b, count, labels)? };
|
||||
let to = if b.trim().is_empty() { count.checked_sub(1).ok_or(PrintError::NoPages)? } else { page_of(b, count, labels)? };
|
||||
if from <= to {
|
||||
pages.extend(from..=to);
|
||||
} else {
|
||||
@@ -115,3 +115,18 @@ fn narrow(pages: Vec<usize>, subset: Subset, reverse: bool) -> Result<Vec<usize>
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_ended_range_of_empty_document_is_an_error() {
|
||||
assert!(select_pages(0, Some("-"), &[], Subset::All, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_ended_range_covers_all_pages() {
|
||||
assert_eq!(select_pages(5, Some("-"), &[], Subset::All, false).unwrap(), vec![0, 1, 2, 3, 4]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,6 +674,8 @@ fn architectural_sheets_preserve_full_scale() {
|
||||
assert!(fit[0].placed[0].matrix.0[0] < 1.0, "Fit adds margins and must not masquerade as full scale");
|
||||
}
|
||||
|
||||
/// A sheet matches a preset either way round and rounded to whole points, but not half a point off;
|
||||
/// empty, negative, infinite, NaN and custom sizes match none.
|
||||
#[test]
|
||||
fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() {
|
||||
for (i, (_, (w, h))) in PAPERS.iter().enumerate() {
|
||||
@@ -687,9 +689,69 @@ fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes()
|
||||
}
|
||||
}
|
||||
|
||||
/// #3752: 13 of the 21 sizes had no media name, so their jobs went out on the queue's default
|
||||
/// paper (A4 on the report's queue) whatever the sheets were laid out on.
|
||||
#[test]
|
||||
fn every_paper_reaches_the_spooler_by_a_name_of_its_size() {
|
||||
let ppd_keywords = ["Letter", "Legal", "Tabloid", "A3", "A4", "A5", "w288h432", "Env10"];
|
||||
for (i, (name, (w, h))) in PAPERS.iter().enumerate() {
|
||||
let media = cups_media(i).unwrap_or_else(|| panic!("{name} has no media name"));
|
||||
assert_eq!(media_option(i), Some(("media".to_string(), media.to_string())));
|
||||
let args = lp_args(&Job { options: media_option(i).into_iter().collect(), ..Job::default() });
|
||||
assert!(args.contains(&format!("media={media}")), "{name}: lp is given it: {args:?}");
|
||||
if ppd_keywords.contains(&media) {
|
||||
continue;
|
||||
}
|
||||
// The rest are PWG 5101.1 names, which end in their dimensions: CUPS feeds the driver's
|
||||
// size of those dimensions, so they must be the paper's.
|
||||
let dims = media.rsplit('_').next().unwrap_or_default();
|
||||
let (dims, points_per_unit) = match (dims.strip_suffix("mm"), dims.strip_suffix("in")) {
|
||||
(Some(mm), _) => (mm, 72.0 / 25.4),
|
||||
(_, Some(inches)) => (inches, 72.0),
|
||||
_ => panic!("{name}: {media} is not a PWG name"),
|
||||
};
|
||||
let (pw, ph) = dims.split_once('x').unwrap_or_else(|| panic!("{name}: {media} has no dimensions"));
|
||||
let (pw, ph) = (pw.parse::<f64>().unwrap() * points_per_unit, ph.parse::<f64>().unwrap() * points_per_unit);
|
||||
assert!((pw - w).abs() < 0.5 && (ph - h).abs() < 0.5, "{name}: {media} is {pw:.2} x {ph:.2} pt, not {w} x {h}");
|
||||
}
|
||||
assert_eq!((cups_media(PAPERS.len()), media_option(PAPERS.len())), (None, None));
|
||||
// The names themselves, spelled as PWG 5101.1 has them: a driverless printer may take only the
|
||||
// names it advertises, so one CUPS can merely parse (ARCH A was `arch_a_9x12in`) is not enough.
|
||||
// ARCH E1 is 5101.1's `wide-format` size: `na_arch-e1_30x42in` was asked for once and never
|
||||
// registered.
|
||||
let names: Vec<(&str, &str)> = PAPERS.iter().enumerate().map(|(i, (name, _))| (*name, cups_media(i).unwrap_or_default())).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
("US Letter", "Letter"),
|
||||
("US Legal", "Legal"),
|
||||
("Tabloid", "Tabloid"),
|
||||
("A3", "A3"),
|
||||
("A4", "A4"),
|
||||
("A5", "A5"),
|
||||
("A6", "iso_a6_105x148mm"),
|
||||
("ARCH A (9 x 12 in)", "na_arch-a_9x12in"),
|
||||
("ARCH B (12 x 18 in)", "na_arch-b_12x18in"),
|
||||
("ARCH C (18 x 24 in)", "na_arch-c_18x24in"),
|
||||
("ARCH D (24 x 36 in)", "na_arch-d_24x36in"),
|
||||
("ARCH E1 (30 x 42 in)", "na_wide-format_30x42in"),
|
||||
("ARCH E (36 x 48 in)", "na_arch-e_36x48in"),
|
||||
("ANSI C (17 x 22 in)", "na_c_17x22in"),
|
||||
("ANSI D (22 x 34 in)", "na_d_22x34in"),
|
||||
("ANSI E (34 x 44 in)", "na_e_34x44in"),
|
||||
("A2", "iso_a2_420x594mm"),
|
||||
("A1", "iso_a1_594x841mm"),
|
||||
("A0", "iso_a0_841x1189mm"),
|
||||
("4 x 6 in label (101.6 x 152.4 mm)", "w288h432"),
|
||||
("US #10 Envelope (4.125 x 9.5 in)", "Env10"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// #2734: the US #10 envelope (4.125 × 9.5 in) is offered, fed by its PPD name and laid out at
|
||||
/// its own size.
|
||||
#[test]
|
||||
fn us_number_10_envelope_is_offered_and_fed_by_name() {
|
||||
// 4.125 × 9.5 in (#2734).
|
||||
let index = matching_paper((684.0, 297.0)).unwrap();
|
||||
assert_eq!(PAPERS[index].0, "US #10 Envelope (4.125 x 9.5 in)");
|
||||
assert_eq!(PAPERS[index].1, (297.0, 684.0));
|
||||
@@ -989,3 +1051,11 @@ fn invisible_annotations_of_unknown_types_do_not_print() {
|
||||
indirect.set(b"Subtype".to_vec(), Object::Ref(square));
|
||||
assert!(prints(&doc, &indirect, all), "an indirect standard Subtype");
|
||||
}
|
||||
|
||||
/// #2707: a booklet's two-sided setting follows its subset.
|
||||
#[test]
|
||||
fn a_booklet_prints_both_sides_on_the_short_edge() {
|
||||
assert_eq!(booklet_duplex(BookletSubset::BothSides), spool::Duplex::ShortEdge);
|
||||
assert_eq!(booklet_duplex(BookletSubset::FrontOnly), spool::Duplex::Off);
|
||||
assert_eq!(booklet_duplex(BookletSubset::BackOnly), spool::Duplex::Off);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,68 @@ fn xobject(doc: &Document, owner: &Dict, name: &[u8]) -> ObjRef {
|
||||
objects.as_dict().unwrap().get(name).unwrap().as_ref().unwrap()
|
||||
}
|
||||
|
||||
/// Printing keeps occupied page resource names and distinct annotation appearances intact,
|
||||
/// whether the page resources are direct or inherited from the page tree.
|
||||
#[test]
|
||||
fn annotation_appearances_do_not_replace_page_xobjects() {
|
||||
for inherited in [false, true] {
|
||||
let (mut doc, ap, widget) = widget_fixture(Some(Object::name("Form")), None);
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
let mut xobjects = Dict::new();
|
||||
for (name, color) in [("PCPrA0", "1 0 0"), ("PCPrA1", "0 0 1"), ("PCPrA3", "1 0 0")] {
|
||||
let dict = Dict::from_iter([
|
||||
(b"Type".to_vec(), Object::name("XObject")),
|
||||
(b"Subtype".to_vec(), Object::name("Form")),
|
||||
(b"BBox".to_vec(), numbers(&[0.0, 0.0, 10.0, 10.0])),
|
||||
]);
|
||||
let form = doc.add(Object::Stream(Stream::flate(dict, format!("{color} rg 0 0 10 10 re f").as_bytes())));
|
||||
xobjects.set(name.as_bytes().to_vec(), Object::Ref(form));
|
||||
}
|
||||
let xobjects = doc.add(Object::Dict(xobjects));
|
||||
let resources = doc.add(Object::Dict(Dict::from_iter([(b"XObject".to_vec(), Object::Ref(xobjects))])));
|
||||
let contents = doc.add(Object::Stream(Stream::flate(
|
||||
Dict::new(),
|
||||
b"q 1 0 0 1 60 20 cm /PCPrA0 Do Q\nq 1 0 0 1 70 20 cm /PCPrA1 Do Q\nq 1 0 0 1 80 20 cm /PCPrA3 Do Q",
|
||||
)));
|
||||
let second_ap = doc.add(Object::Stream(Stream::flate(doc.get(ap).as_dict().unwrap().clone(), b"/Ink cs 1 1 0 scn 0 0 20 10 re f")));
|
||||
let mut second_widget = doc.get(widget).as_dict().unwrap().clone();
|
||||
second_widget.set(b"Rect".to_vec(), numbers(&[10.0, 50.0, 50.0, 70.0]));
|
||||
second_widget.set(b"AP".to_vec(), Object::Dict(Dict::from_iter([(b"N".to_vec(), Object::Ref(second_ap))])));
|
||||
let second_widget = doc.add(Object::Dict(second_widget));
|
||||
doc.update_dict(page, |d| {
|
||||
d.set(b"Resources".to_vec(), Object::Ref(resources));
|
||||
d.set(b"Contents".to_vec(), Object::Ref(contents));
|
||||
d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(widget), Object::Ref(second_widget)]));
|
||||
})
|
||||
.unwrap();
|
||||
if inherited {
|
||||
let parent = doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap();
|
||||
doc.update_dict(parent, |d| d.set(b"Resources".to_vec(), Object::Ref(resources))).unwrap();
|
||||
doc.update_dict(page, |d| {
|
||||
d.remove(b"Resources");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let before = saved(&doc);
|
||||
let source = render(before.clone());
|
||||
for (x, color) in [(65, [255, 0, 0, 255]), (75, [0, 0, 255, 255]), (85, [255, 0, 0, 255])] {
|
||||
assert_eq!(pixel(&source, x, 75), color, "source page artwork");
|
||||
}
|
||||
for (y, color) in [(40, [255, 255, 0, 255]), (70, [0, 255, 0, 255])] {
|
||||
assert_eq!(pixel(&source, 30, y), color, "source widget appearance");
|
||||
}
|
||||
let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap());
|
||||
for (x, color) in [(65, [255, 0, 0, 255]), (75, [0, 0, 255, 255]), (85, [255, 0, 0, 255])] {
|
||||
assert_eq!(pixel(&printed, x, 75), color, "printed page artwork, inherited={inherited}");
|
||||
}
|
||||
for (y, color) in [(40, [255, 255, 0, 255]), (70, [0, 255, 0, 255])] {
|
||||
assert_eq!(pixel(&printed, 30, y), color, "printed widget appearance, inherited={inherited}");
|
||||
}
|
||||
assert_eq!(printed.rgba, source.rgba, "printing must retain page artwork and both appearances, inherited={inherited}");
|
||||
assert_eq!(saved(&doc), before, "printing must leave the source resources unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_subtype_appearance_survives_vector_print() {
|
||||
let (control, _, _) = widget_fixture(Some(Object::name("Form")), None);
|
||||
|
||||
+40
-1
@@ -1357,6 +1357,11 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
||||
// attacker-written version carries (relabelling a signed page /Type /Sig, or dropping its
|
||||
// /Type and adding /ModDate, must not reclassify it away from page content).
|
||||
let signed_pages: HashSet<ObjRef> = pdfcraft_annot::page_refs(&old).unwrap_or_default().into_iter().collect();
|
||||
// The signed pages' /Annots arrays that are objects of their own. Adding an annotation updates
|
||||
// such an array in place (the page dictionary does not change), so it is judged like a
|
||||
// page's inline /Annots: by the annotations it gained.
|
||||
let signed_annots: HashSet<ObjRef> =
|
||||
signed_pages.iter().filter_map(|pg| old.get(*pg).as_dict().and_then(|d| d.get(b"Annots").and_then(Object::as_ref))).collect();
|
||||
// What each signed page draws from, by the role it held when signed (#1787).
|
||||
let signed_resources = signed_page_resources(&old, &signed_pages);
|
||||
let old_info = old.trailer().get(b"Info").and_then(Object::as_ref);
|
||||
@@ -1425,6 +1430,24 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
||||
(Some(k), _) => k,
|
||||
// The Document Security Store's own arrays and dictionaries, when they only grew.
|
||||
(_, _) if dss.contains(&num) => kind::SECURITY_STORE,
|
||||
// A signed page's indirect /Annots array, still an array: what it lost, then what it
|
||||
// gained. Dropping a signed annotation is never hidden behind an added signature.
|
||||
(_, Object::Array(now)) if signed_annots.contains(&r) && before.as_deref().is_some_and(|b| b.as_array().is_some()) => {
|
||||
let was: HashSet<ObjRef> =
|
||||
before.as_deref().and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_ref).collect()).unwrap_or_default();
|
||||
let kept: HashSet<ObjRef> = now.iter().filter_map(Object::as_ref).collect();
|
||||
let removed: Vec<ObjRef> = was.iter().filter(|r| !kept.contains(r)).copied().collect();
|
||||
if removed.is_empty() {
|
||||
let added: Vec<ObjRef> = now.iter().filter_map(Object::as_ref).filter(|r| !was.contains(r)).collect();
|
||||
added_annots(doc, &added)
|
||||
} else if removed.iter().any(|r| old.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))) {
|
||||
// A form field or signature widget taken off the page: no permission covers it.
|
||||
kind::OTHER
|
||||
} else {
|
||||
// Deleting comments is a comment change (permitted at P 3 only).
|
||||
kind::COMMENTS
|
||||
}
|
||||
}
|
||||
(_, Object::Dict(d)) => {
|
||||
let was = before.as_deref().and_then(Object::as_dict);
|
||||
if signed_pages.contains(&r) {
|
||||
@@ -1794,6 +1817,18 @@ fn page_change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Di
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the catalog `d` keeps every developer extension the signed catalog `b` declared, each
|
||||
/// with the same value: it may only declare more.
|
||||
fn extensions_kept(doc: &Document, old: &Document, d: &Dict, b: &Dict) -> bool {
|
||||
let now = d.get(b"Extensions").map(|e| doc.resolve(e)).and_then(|e| e.as_dict().cloned());
|
||||
let was = b.get(b"Extensions").map(|e| old.resolve(e)).and_then(|e| e.as_dict().cloned());
|
||||
match (was, now) {
|
||||
(None, _) => true,
|
||||
(Some(was), Some(now)) => was.iter().all(|(k, v)| now.get(k) == Some(v)),
|
||||
(Some(_), None) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) -> &'static str {
|
||||
let ty = d.name(b"Type");
|
||||
// A later revision cannot make an existing object a signature by relabelling it: a signature
|
||||
@@ -1812,9 +1847,13 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>)
|
||||
// Only a new signature value or policy: rewriting a signed one is not a signature (#1788).
|
||||
Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") if before.is_none() => return kind::SIGNATURE,
|
||||
Some(b"Catalog") => {
|
||||
let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering"];
|
||||
let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering", b"Extensions"];
|
||||
return match before {
|
||||
Some(b) if without(d, keys) != without(b, keys) => kind::STRUCTURE,
|
||||
// Developer extensions (ISO 32000-2 §7.12) only declare what the file uses. A
|
||||
// writer adding a /DSS also declares the ESIC extension it belongs to, so a
|
||||
// declaration may be added, but none that was signed may change or go.
|
||||
Some(b) if !extensions_kept(doc, old, d, b) => kind::STRUCTURE,
|
||||
Some(b) => {
|
||||
if d.get(b"AcroForm") != b.get(b"AcroForm") {
|
||||
// Compare the forms themselves, inline or not.
|
||||
|
||||
@@ -144,6 +144,71 @@ fn signing_an_existing_field_and_counter_signing() {
|
||||
));
|
||||
}
|
||||
|
||||
/// A page whose /Annots is an object of its own: signing and commenting update that array in
|
||||
/// place, not the page. A second signature is a signature change for the first, as with an
|
||||
/// inline /Annots, and a comment is still a comment (refused under a form-fill certification).
|
||||
#[test]
|
||||
fn an_indirect_annots_array_is_judged_by_the_annotations_it_gains() {
|
||||
let page = pdfcraft_cos::ObjRef { num: 3, generation: 0 };
|
||||
let unsigned = edit_after(&fixture(), |doc| {
|
||||
let annots = doc.add(Object::Array(vec![Object::Ref(pdfcraft_cos::ObjRef { num: 6, generation: 0 })]));
|
||||
doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Ref(annots))).unwrap();
|
||||
});
|
||||
let annots_ref = |bytes: &[u8]| open(bytes).get(page).as_dict().and_then(|d| d.get(b"Annots").and_then(Object::as_ref));
|
||||
let annots = annots_ref(&unsigned).unwrap();
|
||||
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||||
let trust = TrustStore { certs: vec![id.certificate.clone()] };
|
||||
let first = pdfcraft_sign::sign(&open(&unsigned), &id, &opts()).unwrap();
|
||||
let second = pdfcraft_sign::sign(&open(&first), &id, &SignOptions { rect: Some([20.0, 160.0, 220.0, 210.0]), ..opts() }).unwrap();
|
||||
assert_eq!(annots_ref(&second), Some(annots), "the signer kept the page's array indirect");
|
||||
let doc = open(&second);
|
||||
let sigs = signatures(&doc, &second, &trust);
|
||||
let s = sigs.iter().find(|s| s.field == "Signature1").unwrap();
|
||||
assert_eq!(s.modification, Modification::Allowed(vec![kind::SIGNATURE]), "{:?}", s.details);
|
||||
assert_eq!(s.status, Status::Valid);
|
||||
// A comment appended to the indirect array.
|
||||
let comment = |doc: &mut Document| {
|
||||
let mut d = pdfcraft_cos::Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Text"));
|
||||
d.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(10), Object::Int(10), Object::Int(30), Object::Int(30)]));
|
||||
let r = doc.add(Object::Dict(d));
|
||||
let mut a = doc.get(annots).as_array().cloned().unwrap();
|
||||
a.push(Object::Ref(r));
|
||||
doc.set(annots, Object::Array(a));
|
||||
};
|
||||
let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
let s = check(&edit_after(&first, comment));
|
||||
assert_eq!((s.status, s.modification), (Status::Valid, Modification::Allowed(vec![kind::COMMENTS])));
|
||||
let certified = pdfcraft_sign::sign(&open(&unsigned), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap();
|
||||
let s = check(&edit_after(&certified, comment));
|
||||
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::COMMENTS])));
|
||||
// Under that certification, a signed comment swapped for a new signature widget is still a
|
||||
// deleted comment, and a form field taken off the page is permitted by no level.
|
||||
let commented = pdfcraft_sign::sign(&open(&edit_after(&unsigned, comment)), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap();
|
||||
let swap = |drop: &dyn Fn(&Document, &Object) -> bool| {
|
||||
edit_after(&commented, |doc| {
|
||||
let mut w = pdfcraft_cos::Dict::new();
|
||||
w.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
w.set(b"Subtype".to_vec(), Object::name("Widget"));
|
||||
w.set(b"FT".to_vec(), Object::name("Sig"));
|
||||
w.set(b"T".to_vec(), PdfString::text("Later"));
|
||||
w.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0); 4]));
|
||||
let w = doc.add(Object::Dict(w));
|
||||
let mut a = doc.get(annots).as_array().cloned().unwrap();
|
||||
a.retain(|o| !drop(doc, o));
|
||||
a.push(Object::Ref(w));
|
||||
doc.set(annots, Object::Array(a));
|
||||
})
|
||||
};
|
||||
let subtype =
|
||||
|doc: &Document, o: &Object, t: &[u8]| o.as_ref().is_some_and(|r| doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(t)));
|
||||
let s = check(&swap(&|doc, o| subtype(doc, o, b"Text")));
|
||||
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::COMMENTS])));
|
||||
let s = check(&swap(&|doc, o| subtype(doc, o, b"Widget") && o.as_ref().is_some_and(|r| r.num == 6)));
|
||||
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::OTHER])));
|
||||
}
|
||||
|
||||
/// Issue #1328: a signature field whose /V is null, directly or through a reference, is unsigned:
|
||||
/// it is listed as unsigned and can be signed by name.
|
||||
#[test]
|
||||
@@ -666,6 +731,35 @@ fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() {
|
||||
assert_eq!(check(&doc, &tampered, &trusted).status, Status::Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ltv_writers_may_declare_the_esic_extension_but_not_change_a_signed_one() {
|
||||
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||||
let trust = TrustStore { certs: vec![id.certificate.clone()] };
|
||||
let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
let ext = |base: &[u8], level: i64| {
|
||||
let mut esic = pdfcraft_cos::Dict::new();
|
||||
esic.set(b"BaseVersion".to_vec(), Object::name("1.7"));
|
||||
esic.set(b"ExtensionLevel".to_vec(), Object::Int(level));
|
||||
let mut e = pdfcraft_cos::Dict::new();
|
||||
e.set(b"ESIC".to_vec(), Object::Dict(esic));
|
||||
edit_after(base, |doc| {
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(e))).unwrap();
|
||||
})
|
||||
};
|
||||
// As PAdES LTV writers save it: the store and the ESIC declaration in one revision.
|
||||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
|
||||
let evidence = pdfcraft_sign::dss::Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() };
|
||||
let ltv = pdfcraft_sign::dss::embed(&open(&ext(&signed, 2)), &evidence).unwrap();
|
||||
let s = check(<v);
|
||||
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
|
||||
assert!(matches!(&s.modification, Modification::Allowed(k) if !k.contains(&kind::STRUCTURE)), "{:?}", s.modification);
|
||||
// A declaration that was signed may not change afterwards.
|
||||
let signed_with = pdfcraft_sign::sign(&open(&ext(&fixture(), 2)), &id, &opts()).unwrap();
|
||||
let s = check(&ext(&signed_with, 3));
|
||||
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::STRUCTURE])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid() {
|
||||
use pdfcraft_sign::dss::{self, Evidence};
|
||||
|
||||
@@ -665,6 +665,8 @@ pub enum ViewAction {
|
||||
Save,
|
||||
Extract,
|
||||
Split,
|
||||
/// Split before each selected page: opens Split on that mode.
|
||||
SplitBeforeSelected,
|
||||
/// Copy the selected pages (Cut also deletes them).
|
||||
CopyPages {
|
||||
cut: bool,
|
||||
@@ -5556,6 +5558,10 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
|
||||
view.pending_action = Some(ViewAction::Extract);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Split document…")).clicked() {
|
||||
view.pending_action = Some(ViewAction::SplitBeforeSelected);
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(editable, egui::Button::new(tl!("Replace pages…"))).clicked() {
|
||||
view.pending_action = Some(ViewAction::ReplacePages);
|
||||
ui.close();
|
||||
|
||||
@@ -669,6 +669,9 @@ impl PdfCraftApp {
|
||||
}
|
||||
"page.crop" => {
|
||||
self.quick_tool = crate::QuickTool::Crop;
|
||||
if let Some(v) = active.and_then(|i| self.views.get_mut(i)) {
|
||||
v.organize = false;
|
||||
}
|
||||
self.notify_tr("Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes");
|
||||
}
|
||||
"page.boxes" => {
|
||||
|
||||
@@ -594,15 +594,31 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
Dialog::Extract => {
|
||||
let count = app.active_ids().map_or(0, |(i, _)| app.views[i].target_pages().len());
|
||||
let (selected, total) = app.active_ids().map_or((Vec::new(), 0), |(i, _)| (app.views[i].target_pages(), app.views[i].page_count()));
|
||||
ui.label(egui::RichText::new(tl!("Extract pages")).font(theme::semibold(18.0)));
|
||||
ui.add_space(8.0);
|
||||
let text = if count == 1 {
|
||||
tl!("1 page selected.").to_string()
|
||||
} else {
|
||||
crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &count.to_string())])
|
||||
// Page ranges to take instead of the selection (#3593); the hint is the selection.
|
||||
let hint = match crate::combine_ui::range_of(&selected, total) {
|
||||
all if all.is_empty() && total > 1 => format!("1-{total}"),
|
||||
all if all.is_empty() => "1".to_string(),
|
||||
some => some,
|
||||
};
|
||||
ui.label(text);
|
||||
ui.horizontal(|ui| {
|
||||
let l = ui.label(tl!("Pages"));
|
||||
ui.add(egui::TextEdit::singleline(&mut app.extract_draft.pages).hint_text(hint).desired_width(240.0)).labelled_by(l.id);
|
||||
});
|
||||
let chosen = app.extract_draft.pages_to_extract(selected, total);
|
||||
match &chosen {
|
||||
Ok(pages) if pages.len() == 1 => {
|
||||
ui.label(tl!("1 page selected."));
|
||||
}
|
||||
Ok(pages) => {
|
||||
ui.label(crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &pages.len().to_string())]));
|
||||
}
|
||||
Err(e) => {
|
||||
ui.colored_label(ui.visuals().error_fg_color, e);
|
||||
}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
// The name the pages are saved under (#737); separate files add " (page N)".
|
||||
ui.horizontal(|ui| {
|
||||
@@ -613,7 +629,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
ui.add(widgets::checkbox(&mut app.extract_draft.separate, tl!("Extract pages as separate files")));
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if widgets::pill_button(ui, tl!("Extract"), true).clicked() {
|
||||
if ui.add_enabled_ui(chosen.is_ok(), |ui| widgets::pill_button(ui, tl!("Extract"), true)).inner.clicked() {
|
||||
extract_now = true;
|
||||
close = true;
|
||||
}
|
||||
|
||||
@@ -265,6 +265,10 @@ impl PdfCraftApp {
|
||||
}
|
||||
Some(crate::canvas::ViewAction::Extract) => self.open_extract_dialog(),
|
||||
Some(crate::canvas::ViewAction::Split) => self.dialog = Some(crate::Dialog::Split),
|
||||
Some(crate::canvas::ViewAction::SplitBeforeSelected) => {
|
||||
self.split_draft.mode = crate::SplitMode::Selection;
|
||||
self.dialog = Some(crate::Dialog::Split);
|
||||
}
|
||||
Some(crate::canvas::ViewAction::CopyPages { cut }) => self.copy_pages(cut),
|
||||
Some(crate::canvas::ViewAction::PastePages) => self.paste_pages(),
|
||||
Some(crate::canvas::ViewAction::EnhanceCamera) => self.enhance_camera(i),
|
||||
|
||||
@@ -166,6 +166,28 @@ pub struct ExtractDraft {
|
||||
/// the document's name when it opens; empty means that name too. As separate files each
|
||||
/// page is `<name> (page N).pdf`, and a single page the user renamed is `<name>.pdf`.
|
||||
pub name: String,
|
||||
/// The pages to extract as typed ranges, 1-based ("1-5, 8, 11-13") (#3593). Empty means the
|
||||
/// selected pages (or the current one); the dialog opens with it empty.
|
||||
pub pages: String,
|
||||
}
|
||||
|
||||
impl ExtractDraft {
|
||||
/// The pages to extract (0-based, in the order typed, each once) from a document of `count`
|
||||
/// pages: the typed [`Self::pages`] ranges, or `selected` when none are typed. Says what is
|
||||
/// wrong with a range that can't be read or takes no page.
|
||||
pub fn pages_to_extract(&self, selected: Vec<usize>, count: usize) -> Result<Vec<usize>, String> {
|
||||
if self.pages.trim().is_empty() {
|
||||
return Ok(selected);
|
||||
}
|
||||
match pdfcraft_engine::print::select_pages(count, Some(&self.pages), &[], pdfcraft_engine::print::Subset::All, false) {
|
||||
Ok(p) if p.is_empty() => Err(tl!("No pages selected").to_string()),
|
||||
Ok(p) => {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
Ok(p.into_iter().filter(|page| seen.insert(*page)).collect())
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `name` made safe as a file name on every platform: no path separators, reserved or control
|
||||
@@ -605,23 +627,35 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show Extract pages, its file name filled in with the active document's (#737).
|
||||
/// Show Extract pages, its file name filled in with the active document's (#737) and no
|
||||
/// page ranges typed, so it takes the selected pages until some are (#3593).
|
||||
pub fn open_extract_dialog(&mut self) {
|
||||
self.extract_draft.name =
|
||||
self.active_ids().and_then(|(_, id)| self.session.get(id)).map(|d| strip_pdf(&d.name).to_string()).unwrap_or_default();
|
||||
self.extract_draft.pages.clear();
|
||||
self.dialog = Some(crate::Dialog::Extract);
|
||||
}
|
||||
|
||||
/// Copy the selected pages (or the current page) into a new unsaved document tab.
|
||||
/// Copy the pages typed in Extract pages, else the selected pages (or the current page), into
|
||||
/// a new unsaved document tab.
|
||||
pub fn extract_selection(&mut self) {
|
||||
// What's typed in a form field is part of the document (#166).
|
||||
if !self.commit_form_typing() {
|
||||
return;
|
||||
}
|
||||
let Some((i, id)) = self.active_ids() else { return };
|
||||
let pages = self.views[i].target_pages();
|
||||
let stem = self.session.get(id).map(|d| strip_pdf(&d.name).to_string()).unwrap_or_default();
|
||||
let opts = self.extract_draft.clone();
|
||||
let pages = match opts.pages_to_extract(self.views[i].target_pages(), self.views[i].page_count()) {
|
||||
Ok(pages) => pages,
|
||||
Err(e) => {
|
||||
self.notify_fmt("Couldn't extract pages: {e}", &[("e", &e)]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Deleting takes the pages in document order (typed ranges may run backwards).
|
||||
let mut doomed = pages.clone();
|
||||
doomed.sort_unstable();
|
||||
let stem = self.session.get(id).map(|d| strip_pdf(&d.name).to_string()).unwrap_or_default();
|
||||
// The name typed in the dialog (#737), else the document's.
|
||||
let chosen = file_stem_from_user(&opts.name);
|
||||
let renamed = !chosen.is_empty() && chosen != file_stem_from_user(&stem);
|
||||
@@ -651,7 +685,7 @@ impl PdfCraftApp {
|
||||
}
|
||||
let unchanged = app.session.get(id).map(|d| d.edit_generation()) == generation;
|
||||
if unchanged && app.active_ids().map(|(_, active)| active) == Some(id) {
|
||||
app.apply_edit(pdfcraft_engine::Edit::DeletePages { pages });
|
||||
app.apply_edit(pdfcraft_engine::Edit::DeletePages { pages: doomed });
|
||||
} else {
|
||||
app.notify_tr("The pages were extracted but not deleted, because the document changed while you were choosing a folder.");
|
||||
}
|
||||
@@ -677,7 +711,7 @@ impl PdfCraftApp {
|
||||
if opts.delete {
|
||||
// Back on the original document.
|
||||
self.active = Some(i);
|
||||
self.apply_edit(pdfcraft_engine::Edit::DeletePages { pages });
|
||||
self.apply_edit(pdfcraft_engine::Edit::DeletePages { pages: doomed });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1410,6 +1410,13 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
|
||||
view.pending_action = Some(crate::canvas::ViewAction::Extract);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Split document…")).clicked() {
|
||||
if view.selected.is_empty() {
|
||||
view.select_pages(&[i]);
|
||||
}
|
||||
view.pending_action = Some(crate::canvas::ViewAction::SplitBeforeSelected);
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(editable, egui::Button::new(tl!("Replace pages…"))).clicked() {
|
||||
view.pending_action = Some(crate::canvas::ViewAction::ReplacePages);
|
||||
ui.close();
|
||||
|
||||
@@ -223,13 +223,22 @@ impl PrintDraft {
|
||||
self.handling == Handling::Multiple && self.order == PageOrder::CutStack
|
||||
}
|
||||
|
||||
/// The spooler job for this print: the dialog's copies, two-sided and colour settings, the
|
||||
/// driver options changed from the printer's defaults, and the chosen paper.
|
||||
pub fn job(&self, title: &str) -> spool::Job {
|
||||
spool::Job {
|
||||
printer: self.printer.clone(),
|
||||
copies: self.copies.max(1),
|
||||
collate: self.collate,
|
||||
// Cut and stack needs single-sided sheets, whatever the printer's default.
|
||||
duplex: if self.cut_and_stack() { Some(spool::Duplex::Off) } else { self.duplex },
|
||||
// Cut and stack needs single-sided sheets, whatever the printer's default; a booklet
|
||||
// decides two-sided itself (#2707), leaving the Two-sided choice as it was.
|
||||
duplex: if self.cut_and_stack() {
|
||||
Some(spool::Duplex::Off)
|
||||
} else if self.handling == Handling::Booklet {
|
||||
Some(print::booklet_duplex(self.booklet_subset))
|
||||
} else {
|
||||
self.duplex
|
||||
},
|
||||
scaling: spool::Scaling::Exact,
|
||||
grayscale: self.grayscale,
|
||||
title: title.to_string(),
|
||||
@@ -240,7 +249,7 @@ impl PrintDraft {
|
||||
}
|
||||
.into_iter()
|
||||
// The sheet the PDF was laid out on, not the queue's default paper.
|
||||
.chain(print::cups_media(self.paper).map(|m| ("media".to_string(), m.to_string())))
|
||||
.chain(print::media_option(self.paper))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -1017,22 +1026,26 @@ pub(crate) fn body(
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(tl!("Two-sided:"));
|
||||
ui.add_enabled_ui(!browser, |ui| {
|
||||
combo(
|
||||
ui,
|
||||
"duplex",
|
||||
&mut d.duplex,
|
||||
&[
|
||||
(None, tl!("Printer default")),
|
||||
(Some(spool::Duplex::Off), tl!("Off")),
|
||||
(Some(spool::Duplex::LongEdge), tl!("Flip on long edge")),
|
||||
(Some(spool::Duplex::ShortEdge), tl!("Flip on short edge")),
|
||||
],
|
||||
160.0,
|
||||
);
|
||||
});
|
||||
ui.end_row();
|
||||
// A booklet decides two-sided itself (#2707), so the choice is hidden meanwhile, as
|
||||
// in Acrobat's Print dialog; it is kept for the other layouts.
|
||||
if d.handling != Handling::Booklet {
|
||||
ui.label(tl!("Two-sided:"));
|
||||
ui.add_enabled_ui(!browser, |ui| {
|
||||
combo(
|
||||
ui,
|
||||
"duplex",
|
||||
&mut d.duplex,
|
||||
&[
|
||||
(None, tl!("Printer default")),
|
||||
(Some(spool::Duplex::Off), tl!("Off")),
|
||||
(Some(spool::Duplex::LongEdge), tl!("Flip on long edge")),
|
||||
(Some(spool::Duplex::ShortEdge), tl!("Flip on short edge")),
|
||||
],
|
||||
160.0,
|
||||
);
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label(tl!("Paper:"));
|
||||
let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0);
|
||||
let before = d.paper;
|
||||
|
||||
@@ -424,23 +424,51 @@ mod idle_tests {
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl TestDir {
|
||||
/// A new folder of this test's own. The tests run on parallel threads of one process, and
|
||||
/// two can read the same clock time (it moves in microseconds on macOS), so a counter
|
||||
/// tells their folders apart; the time keeps runs of the process apart.
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let n = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-idle-recovery-{}-{nonce}", std::process::id()));
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-idle-recovery-{}-{nonce}-{n}", std::process::id()));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
/// The folder.
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
/// Remove the folder and what the test left in it.
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Folders made at the same moment on parallel threads, as the tests make theirs, are each
|
||||
/// their own: none fails because another test's folder took its name.
|
||||
#[test]
|
||||
fn test_folders_made_at_once_on_parallel_threads_are_each_their_own() {
|
||||
let start = std::sync::Barrier::new(8);
|
||||
let dirs: Vec<PathBuf> = std::thread::scope(|s| {
|
||||
let threads: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
s.spawn(|| {
|
||||
start.wait();
|
||||
(0..25).map(|_| TestDir::new()).collect::<Vec<_>>()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
threads.into_iter().flat_map(|t| t.join().unwrap()).map(|d| d.path().to_path_buf()).collect()
|
||||
});
|
||||
let distinct: std::collections::HashSet<&PathBuf> = dirs.iter().collect();
|
||||
assert_eq!(distinct.len(), 200);
|
||||
}
|
||||
|
||||
fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, now: f64) -> Duration {
|
||||
let mut out = ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| {
|
||||
app.autosave_tick(ui.ctx(), now);
|
||||
|
||||
@@ -55,21 +55,10 @@ fn harness(rotate: u16) -> Harness<'static, PdfCraftApp> {
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
/// Run frames until rendering has finished and the layout holds still.
|
||||
fn settle(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
for _ in 0..200 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a bookmark with ⌘B, as the Bookmarks button does, and say what it goes to.
|
||||
fn new_bookmark(h: &mut Harness<'static, PdfCraftApp>) -> (Option<usize>, DestView) {
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::B);
|
||||
@@ -103,7 +92,7 @@ fn a_new_bookmark_goes_to_the_top_of_the_window_not_the_top_of_the_page() {
|
||||
h.state_mut().apply_edit(Edit::AddBookmark { parent: vec![], index: 0, title: "Mid".into(), page: 1, left: None, top: Some(250.0) });
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Mid").click();
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
let (page, view) = new_bookmark(&mut h);
|
||||
assert_eq!(page, Some(1), "the page at the top of the window");
|
||||
let (left, top) = xyz(view);
|
||||
@@ -151,7 +140,7 @@ fn a_bookmark_made_on_a_turned_page_pins_the_axis_the_window_scrolls_along() {
|
||||
fn rotate_view(h: &mut Harness<'static, PdfCraftApp>, degrees: u16) {
|
||||
h.state_mut().views[0].rotation = degrees;
|
||||
h.run_steps(3);
|
||||
settle(h);
|
||||
crate::support::settle(h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -216,12 +205,12 @@ fn a_bookmark_in_a_spread_of_unequal_pages_goes_to_the_page_crossing_the_top_of_
|
||||
});
|
||||
h.state_mut().views[0].layout = PageLayout::TwoUp;
|
||||
h.run_steps(4);
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
// 20 pt below page 2's top is at the window's top, where page 1 has not started.
|
||||
h.state_mut().apply_edit(Edit::AddBookmark { parent: vec![], index: 0, title: "Right".into(), page: 1, left: None, top: Some(380.0) });
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Right").click();
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
let (page, view) = new_bookmark(&mut h);
|
||||
assert_eq!(page, Some(1), "page 2 crosses the top of the window; page 1 starts below it");
|
||||
close(xyz(view).1, 380.0, "top");
|
||||
|
||||
@@ -40,20 +40,10 @@ fn harness_with_bytes(bytes: Vec<u8>, setup: impl FnOnce(&mut PdfCraftApp) + 'st
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
fn settle(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
for _ in 0..200 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// A point on page 1 in PDF user space → screen (no rotation; MediaBox 300×200).
|
||||
fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 {
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
|
||||
@@ -146,7 +136,7 @@ fn sidebar_comment_centres_the_annotation_at_high_zoom() {
|
||||
&& let Ok(dir) = std::env::var("PDFCRAFT_SHOTS")
|
||||
{
|
||||
let _gpu = crate::gpu();
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
h.render().unwrap().save(format!("{dir}/comment-centred.png")).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -836,7 +826,7 @@ fn a_dragged_text_box_follows_a_rotated_view() {
|
||||
}
|
||||
h.state_mut().set_option("zoom", "150").unwrap();
|
||||
h.run_steps(3);
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
let screen = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| {
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
|
||||
let (u, v) = (x / 300.0, (200.0 - y) / 200.0);
|
||||
@@ -1893,7 +1883,7 @@ fn attachment_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
@@ -2082,7 +2072,7 @@ fn hovering_a_comment_shows_its_author_and_text() {
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
assert!(h.query_by_label_contains("Check this figure").is_none(), "no popup before hovering");
|
||||
let p = at(&h, 100.0, 85.0);
|
||||
h.hover_at(p);
|
||||
@@ -2112,7 +2102,7 @@ fn preference_hides_author_only_highlight_hover_previews() {
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
|
||||
let p = at(&h, 100.0, 85.0);
|
||||
// By default, author-only hover is shown.
|
||||
@@ -2159,7 +2149,7 @@ fn markup_hover_harness(pdf: Vec<u8>, view_rotation: u16) -> Harness<'static, Pd
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
|
||||
@@ -28,13 +28,7 @@ fn harness_bytes_with_step_dt(bytes: Vec<u8>, step_dt: f32) -> Harness<'static,
|
||||
app.set_option("zoom", "150").unwrap();
|
||||
app
|
||||
});
|
||||
for _ in 0..60 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
@@ -161,13 +155,7 @@ fn text_tool_deleting_a_draft_hides_the_old_added_glyphs_and_cancel_restores_the
|
||||
click(&mut h, point);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||||
h.key_press(egui::Key::Backspace);
|
||||
for _ in 0..100 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
assert!(h.state().views[0].content.draft.as_ref().unwrap().text.is_empty());
|
||||
let shot = h.render().unwrap();
|
||||
for x in 105..140 {
|
||||
@@ -865,18 +853,6 @@ fn red(pixels: &image::RgbaImage, r: egui::Rect) -> usize {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Steps until no render is pending.
|
||||
fn settle(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
for _ in 0..200 {
|
||||
h.run_steps(1);
|
||||
if !h.state().render_pending() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
panic!("the page did not finish rendering");
|
||||
}
|
||||
|
||||
/// An added picture resized by a corner is drawn at its new size while dragged and after the
|
||||
/// release until the page is rendered again. It used to stay at its old size under the outline for
|
||||
/// the whole drag, and to show there again after the release.
|
||||
@@ -896,7 +872,7 @@ fn a_resized_added_picture_shows_only_at_its_new_size() {
|
||||
h.state_mut().add_image("red.png".into(), png);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the picture is selected");
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
let rect = added(&h)[0].content.rect();
|
||||
let r = item_on_screen(&h, rect);
|
||||
// The lower right corner to the middle: half the size, the upper left corner kept.
|
||||
@@ -911,7 +887,7 @@ fn a_resized_added_picture_shows_only_at_its_new_size() {
|
||||
h.hover_at(grip + (to - grip) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
let pixels = h.render().unwrap();
|
||||
assert!(red(&pixels, kept) > 100, "while dragged, the picture is drawn at its new size");
|
||||
assert_eq!(red(&pixels, gone), 0, "while dragged, the picture shows at its old size");
|
||||
@@ -926,7 +902,7 @@ fn a_resized_added_picture_shows_only_at_its_new_size() {
|
||||
}
|
||||
assert_ne!(added(&h)[0].content.rect(), rect, "the resize is applied");
|
||||
h.state_mut().views[0].hold_rasters = false;
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
h.run_steps(2);
|
||||
let pixels = h.render().unwrap();
|
||||
assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size");
|
||||
|
||||
@@ -73,13 +73,15 @@ fn harness_pages_at(pages: usize, width: f32, scale: f32) -> (Harness<'static, P
|
||||
/// Send a request and run frames until it is answered.
|
||||
fn call(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Reply {
|
||||
let rx = c.send(method, params);
|
||||
for _ in 0..30 {
|
||||
h.step();
|
||||
if let Ok(r) = rx.try_recv() {
|
||||
return r;
|
||||
}
|
||||
let mut reply = None;
|
||||
let answered = crate::support::step_until(h, |_| {
|
||||
reply = rx.try_recv().ok();
|
||||
reply.is_some()
|
||||
});
|
||||
match reply {
|
||||
Some(r) if answered => r,
|
||||
_ => panic!("{method}: no reply in 30 s"),
|
||||
}
|
||||
panic!("{method}: no reply after 30 frames");
|
||||
}
|
||||
|
||||
fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Value {
|
||||
@@ -1786,18 +1788,13 @@ fn control_open_replies_pending_while_the_worker_opens_and_focuses_once_it_has()
|
||||
// repeated open focus it instead of reporting another pending open. The worker is a thread
|
||||
// of its own, so the wait is bounded by time: a busy runner can draw 30 frames before it has
|
||||
// read the file.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
let mut documents = 0;
|
||||
while std::time::Instant::now() < deadline {
|
||||
h.run_steps(1);
|
||||
let state = ok(&mut h, &c, "ui.state", json!({}));
|
||||
let opened = crate::support::step_until(&mut h, |h| {
|
||||
let state = ok(h, &c, "ui.state", json!({}));
|
||||
documents = state["documents"].as_array().map(|d| d.len()).unwrap_or_default();
|
||||
if documents == 1 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(documents, 1, "the pending open completed into a tab");
|
||||
documents == 1
|
||||
});
|
||||
assert!(opened, "the pending open completed into a tab: {documents} tabs");
|
||||
|
||||
let again = ok(&mut h, &c, "ui.open", json!({ "path": a }));
|
||||
assert_eq!(again["focused"], true, "{again}");
|
||||
|
||||
@@ -2285,6 +2285,88 @@ fn split_before_selected_pages() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_from_organize_grid_context_menu() {
|
||||
let dir = temp_path("split-org-ctx");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut h = organize(4);
|
||||
h.state_mut().export_dir_override = Some(dir.to_string_lossy().into_owned());
|
||||
h.get_by_label("Page 3").click_button(egui::PointerButton::Secondary);
|
||||
h.run_steps(2);
|
||||
h.get_all_by_label("Split document…").last().unwrap().click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split));
|
||||
assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection);
|
||||
h.get_by_label_contains("Creates 2 files from 4 pages");
|
||||
h.get_all_by_label("Split").last().unwrap().click();
|
||||
h.run_steps(3);
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["doc (pages 1-2).pdf", "doc (pages 3-4).pdf"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_from_pages_panel_context_menu() {
|
||||
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
|
||||
let thumb = h.get_all_by_label("Page 3").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap();
|
||||
thumb.click_button(egui::PointerButton::Secondary);
|
||||
h.run_steps(2);
|
||||
h.get_all_by_label("Split document…").last().unwrap().click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split));
|
||||
assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection);
|
||||
h.get_by_label_contains("Creates 2 files from 4 pages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_from_pages_panel_context_menu_on_current_page_without_selection() {
|
||||
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
|
||||
// Navigate to Page 2 (0-indexed 1) so it is current, but keep selected empty
|
||||
h.state_mut().views[0].go_to_page(1);
|
||||
h.state_mut().views[0].selected.clear();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].current, 1);
|
||||
assert!(h.state().views[0].selected.is_empty());
|
||||
let thumb = h.get_all_by_label("Page 2").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap();
|
||||
thumb.click_button(egui::PointerButton::Secondary);
|
||||
h.run_steps(2);
|
||||
h.get_all_by_label("Split document…").last().unwrap().click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split));
|
||||
assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection);
|
||||
h.get_by_label_contains("Creates 2 files from 4 pages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_from_pages_panel_context_menu_on_page_one_falls_back_to_pages() {
|
||||
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
|
||||
let thumb = h.get_all_by_label("Page 1").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap();
|
||||
thumb.click_button(egui::PointerButton::Secondary);
|
||||
h.run_steps(2);
|
||||
h.get_all_by_label("Split document…").last().unwrap().click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split));
|
||||
assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Pages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_from_pages_panel_context_menu_with_multi_selection() {
|
||||
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
|
||||
// Select pages 2 and 4 (indices 1 and 3)
|
||||
h.state_mut().views[0].select_pages(&[1, 3]);
|
||||
h.run_steps(2);
|
||||
let thumb = h.get_all_by_label("Page 2").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap();
|
||||
thumb.click_button(egui::PointerButton::Secondary);
|
||||
h.run_steps(2);
|
||||
h.get_all_by_label("Split document…").last().unwrap().click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split));
|
||||
assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection);
|
||||
h.get_by_label_contains("Creates 3 files from 4 pages");
|
||||
}
|
||||
|
||||
// ── Encrypted documents ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn protected(user: &str, owner: &str, permissions: i32) -> Vec<u8> {
|
||||
@@ -2925,13 +3007,7 @@ fn deleting_a_draft_does_not_show_the_original_text_underneath() {
|
||||
h.drop_at(at);
|
||||
h.run_steps(3);
|
||||
h.state_mut().views[0].line_editor.as_mut().unwrap().text.clear();
|
||||
for _ in 0..100 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
let shot = h.render().unwrap();
|
||||
if let Ok(path) = std::env::var("PDFCRAFT_TEXT_PREVIEW_SHOT") {
|
||||
shot.save(path).unwrap();
|
||||
@@ -3030,13 +3106,7 @@ fn opening_a_paragraph_leaves_the_page_as_it_is() {
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
for _ in 0..100 {
|
||||
h.run_steps(1);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
let page = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
let k = page.width() / 300.0;
|
||||
let block = h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rect;
|
||||
@@ -3107,13 +3177,7 @@ fn a_quick_click_into_another_paragraph_keeps_both_in_place() {
|
||||
};
|
||||
let (top, bottom) = (screen(&blocks[0]), screen(&blocks[1]));
|
||||
click_at(&mut h, top.center());
|
||||
for _ in 0..100 {
|
||||
h.run_steps(1);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("Top"));
|
||||
let ink = |pixels: &image::RgbaImage, r: egui::Rect| {
|
||||
(r.top() as u32..r.bottom() as u32)
|
||||
@@ -4112,14 +4176,13 @@ fn save_paragraph_drag(h: &mut Harness<'static, PdfCraftApp>, name: &str, origin
|
||||
|
||||
fn paragraph_drag_shot(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
|
||||
if let Ok(directory) = std::env::var("PDFCRAFT_PARAGRAPH_DRAG_SHOTS") {
|
||||
for _ in 0..60 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(!h.state().render_pending(), "the screenshot must show the current document generation");
|
||||
assert!(
|
||||
crate::support::step_until(h, |h| {
|
||||
h.run_steps(1);
|
||||
!h.state().render_pending()
|
||||
}),
|
||||
"the screenshot must show the current document generation"
|
||||
);
|
||||
let directory = std::path::Path::new(&directory);
|
||||
std::fs::create_dir_all(directory).unwrap();
|
||||
h.render().unwrap().save(directory.join(name)).unwrap();
|
||||
@@ -5293,14 +5356,13 @@ fn image_pixels_follow_the_frame_while_a_corner_is_dragged() {
|
||||
h.run_steps(4);
|
||||
// The dragged pixels are the page raster's: wait for it, as a slow runner may not have it yet
|
||||
// (the drag then drew no picture at all).
|
||||
for _ in 0..400 {
|
||||
h.run_steps(1);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(!h.state().render_pending(), "the page raster arrives");
|
||||
assert!(
|
||||
crate::support::step_until(&mut h, |h| {
|
||||
h.run_steps(1);
|
||||
!h.state().render_pending()
|
||||
}),
|
||||
"the page raster arrives"
|
||||
);
|
||||
let page = h.state().views[0].page_screen_rect(0).unwrap();
|
||||
let at = |x: f32, y: f32| egui::pos2(page.left() + x / 200.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height());
|
||||
let p = at(95.0, 195.0);
|
||||
@@ -5999,13 +6061,7 @@ fn a_paragraph_running_off_its_page_edits_without_covering_the_next_page() {
|
||||
h.drag_at(p);
|
||||
h.run_steps(1);
|
||||
h.drop_at(p);
|
||||
for _ in 0..60 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
assert!(
|
||||
h.state().views[0].line_editor.as_ref().is_some_and(|e| e.page == 0 && e.text.contains("line 50")),
|
||||
"the paragraph's off-page lines are in it"
|
||||
@@ -6094,15 +6150,13 @@ fn mode_tabs_keep_the_hand_over_page_text_scrolled_under_them() {
|
||||
h.run_steps(4);
|
||||
let top = h.state().views[0].page_screen_rect(0).expect("on screen").top();
|
||||
let mut cursor = egui::CursorIcon::Default;
|
||||
for _ in 0..50 {
|
||||
let text_cursor = crate::support::step_until(&mut h, |h| {
|
||||
h.hover_at(text.center() + egui::vec2(0.0, top));
|
||||
h.run_steps(2);
|
||||
cursor = h.output().platform_output.cursor_icon;
|
||||
if cursor == egui::CursorIcon::Text {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(cursor, egui::CursorIcon::Text);
|
||||
cursor == egui::CursorIcon::Text
|
||||
});
|
||||
assert!(text_cursor, "the I-beam over page text, not {cursor:?}");
|
||||
assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,7 @@ fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
app.set_option("zoom", "150").unwrap();
|
||||
app
|
||||
});
|
||||
for _ in 0..60 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
@@ -1150,13 +1144,7 @@ fn calculated_field_and_script_side_effect_on_another_page_re_render() {
|
||||
event: "calculate".into(),
|
||||
script: Some("event.value = 'from_' + getField('name').value;".into()),
|
||||
}));
|
||||
for _ in 0..60 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
click_field(&mut h, "name", 0);
|
||||
h.event(egui::Event::Text("hello".into()));
|
||||
h.run_steps(2);
|
||||
@@ -1164,13 +1152,7 @@ fn calculated_field_and_script_side_effect_on_another_page_re_render() {
|
||||
h.run_steps(2);
|
||||
assert_eq!(value(&h, "page2_field"), ["from_hello"]);
|
||||
// Wait for all re-renders to complete.
|
||||
for _ in 0..60 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
assert_eq!(value(&h, "name"), ["hello"]);
|
||||
assert_eq!(value(&h, "page2_field"), ["from_hello"]);
|
||||
}
|
||||
@@ -1186,13 +1168,7 @@ fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() {
|
||||
h.key_press(egui::Key::Tab);
|
||||
h.run_steps(2);
|
||||
assert_eq!(value(&h, "name"), ["GoodVal"]);
|
||||
for _ in 0..60 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
assert!(!h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0)));
|
||||
|
||||
// 2. Rejected edit: typing rejected by validate script leaves unbaked clean.
|
||||
@@ -1212,13 +1188,7 @@ fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() {
|
||||
|
||||
// 3. Zoom then edit: zoom to another scale, let tiles settle, then edit.
|
||||
h.state_mut().views[0].zoom_step(true);
|
||||
for _ in 0..60 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
click_field(&mut h, "name", 0);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||||
h.event(egui::Event::Text("ZoomedVal".into()));
|
||||
@@ -1226,13 +1196,7 @@ fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() {
|
||||
h.key_press(egui::Key::Tab);
|
||||
h.run_steps(2);
|
||||
assert_eq!(value(&h, "name"), ["ZoomedVal"]);
|
||||
for _ in 0..60 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
assert!(!h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0)));
|
||||
}
|
||||
|
||||
|
||||
@@ -73,13 +73,7 @@ fn harness_document(clipping: bool, background: bool) -> Harness<'static, PdfCra
|
||||
assert!(app.execute("edit.edit_text"));
|
||||
app
|
||||
});
|
||||
for _ in 0..60 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
|
||||
@@ -133,13 +133,7 @@ fn an_ocr_word_opens_for_editing_and_stays_open() {
|
||||
assert!(app.execute("edit.edit_text"));
|
||||
app
|
||||
});
|
||||
for _ in 0..60 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
let (r, k) = {
|
||||
let app = h.state();
|
||||
let d = app.session.get(app.views[0].id).unwrap();
|
||||
|
||||
@@ -90,3 +90,32 @@ fn duplicate_pages_copies_the_current_page() {
|
||||
let s = h.state();
|
||||
assert_eq!(s.session.get(s.views[0].id).unwrap().info.pages.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_crop_tool_leaves_the_page_grid_so_a_page_can_be_cropped() {
|
||||
let mut h = harness();
|
||||
// The Organize panel's thumbnail grid is on screen for this view: choosing
|
||||
// "Crop pages" from it must leave the grid so the canvas handles a drag on
|
||||
// a page instead of returning early to organize_grid.
|
||||
h.state_mut().views[0].organize = true;
|
||||
assert!(h.state_mut().execute("page.crop"));
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Crop);
|
||||
assert!(!h.state().views[0].organize, "\"page.crop\" must clear view.organize");
|
||||
h.run_steps(2);
|
||||
let r = h.state().views[0].page_screen_rect(0).unwrap();
|
||||
// The middle half of the page in both directions.
|
||||
let (a, b) = (r.min + r.size() * 0.25, r.min + r.size() * 0.75);
|
||||
h.hover_at(a);
|
||||
h.run_steps(1);
|
||||
h.drag_at(a);
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(a + (b - a) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(b);
|
||||
h.run_steps(4);
|
||||
let (w, hh) = size(&h, 0);
|
||||
assert!((w - 150.0).abs() < 2.0 && (hh - 200.0).abs() < 2.0, "{w}×{hh}");
|
||||
assert_eq!(size(&h, 1), (300.0, 400.0));
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
use crate::support;
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_render::PageRenderer;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
/// An `n`-page document with a proper xref table.
|
||||
@@ -115,9 +115,12 @@ fn a_pick_for_a_document_that_is_no_longer_active_is_dropped() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A scratch folder for one test.
|
||||
/// A scratch folder for one test, of its own for each call: the tests share one process and run
|
||||
/// in parallel, so two that picked the same name would delete each other's folder (#4186).
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-pickers-{name}-{}", std::process::id()));
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-pickers-{name}-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
@@ -255,6 +258,58 @@ fn extract_pages_saves_under_the_file_name_typed_in_the_dialog() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3593: Extract pages takes page ranges typed in its Pages field, in the order typed, each page
|
||||
/// once; left empty, it takes the selected pages as before. A range it can't use is refused, with
|
||||
/// the reason in the dialog and the Extract button off.
|
||||
#[test]
|
||||
fn extract_pages_takes_the_page_ranges_typed_in_the_dialog() {
|
||||
let texts = |app: &PdfCraftApp, view: usize| {
|
||||
let doc = app.session.get(app.views[view].id).unwrap();
|
||||
let mut r = PageRenderer::new(doc.bytes.clone(), Default::default());
|
||||
(0..r.page_count())
|
||||
.map(|p| {
|
||||
let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, scale: 1.0, ..Default::default() });
|
||||
out.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut h = harness(&[6]);
|
||||
h.state_mut().views[0].select_pages(&[1]);
|
||||
assert!(h.state_mut().execute("page.extract"));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().extract_draft.pages, "", "opens on the selection");
|
||||
h.get_by_label("Pages");
|
||||
h.get_by_label_contains("1 page selected.");
|
||||
// Ranges, a backward one and a repeat: in that order, each page once.
|
||||
h.state_mut().extract_draft.pages = "5-6, 3-2, 6".into();
|
||||
h.run_steps(2);
|
||||
h.get_by_label_contains("4 pages selected.");
|
||||
h.state_mut().extract_selection();
|
||||
h.run_steps(2);
|
||||
let s = h.state();
|
||||
assert_eq!(s.views.len(), 2, "the pages open in a new tab");
|
||||
assert_eq!(texts(s, 1), ["Page 5", "Page 6", "Page 3", "Page 2"]);
|
||||
// A page that isn't there: the dialog says so, Extract is off and extracts nothing.
|
||||
h.state_mut().active = Some(0);
|
||||
h.state_mut().open_extract_dialog();
|
||||
h.state_mut().extract_draft.pages = "2, 9".into();
|
||||
h.run_steps(2);
|
||||
assert!(h.get_by_label("Extract").accesskit_node().is_disabled(), "Extract is off");
|
||||
h.state_mut().extract_selection();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views.len(), 2, "nothing extracted");
|
||||
// Extract and delete takes the typed pages out of the document.
|
||||
h.state_mut().active = Some(0);
|
||||
h.state_mut().open_extract_dialog();
|
||||
h.state_mut().extract_draft.pages = "6, 1-2".into();
|
||||
h.state_mut().extract_draft.delete = true;
|
||||
h.state_mut().extract_selection();
|
||||
h.run_steps(2);
|
||||
let s = h.state();
|
||||
assert_eq!(texts(s, 2), ["Page 6", "Page 1", "Page 2"]);
|
||||
assert_eq!(texts(s, 0), ["Page 3", "Page 4", "Page 5"]);
|
||||
}
|
||||
|
||||
/// Left as the document's name (or emptied), the names are the ones Extract always used.
|
||||
#[test]
|
||||
fn extract_pages_keeps_the_usual_names_when_the_file_name_is_unchanged() {
|
||||
|
||||
@@ -21,13 +21,7 @@ fn harness_with(name: &'static str, bytes: Vec<u8>) -> Harness<'static, PdfCraft
|
||||
app.set_option("zoom", "150").unwrap();
|
||||
app
|
||||
});
|
||||
for _ in 0..60 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
|
||||
@@ -873,3 +873,31 @@ fn restored_paper_keeps_preview_and_saved_sheet_dimensions() {
|
||||
assert!((size.0 - a4.0).abs() < 0.01 && (size.1 - a4.1).abs() < 0.01, "saved sheet: {size:?}");
|
||||
std::fs::remove_file(out).unwrap();
|
||||
}
|
||||
|
||||
/// #2707: a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided
|
||||
/// while Booklet is chosen): the job flips on the short edge for both sides and prints
|
||||
/// one-sided for the front or back only, and the Two-sided choice is kept for other layouts.
|
||||
#[test]
|
||||
fn a_booklet_decides_two_sided_itself() {
|
||||
use pdfcraft_engine::print::{BookletSubset, spool::Duplex};
|
||||
let mut h = harness();
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
h.run_steps(3);
|
||||
h.state_mut().print_draft.duplex = Some(Duplex::LongEdge);
|
||||
h.run_steps(1);
|
||||
assert!(h.query_by_label("Two-sided:").is_some());
|
||||
h.get_by_label("Booklet").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Two-sided:").is_none(), "hidden while Booklet is chosen");
|
||||
let d = &h.state().print_draft;
|
||||
assert_eq!(d.duplex, Some(Duplex::LongEdge), "the Two-sided choice is kept");
|
||||
assert_eq!(d.job("x").duplex, Some(Duplex::ShortEdge), "both sides flip on the short edge");
|
||||
for subset in [BookletSubset::FrontOnly, BookletSubset::BackOnly] {
|
||||
h.state_mut().print_draft.booklet_subset = subset;
|
||||
assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::Off), "{subset:?} is one pass of a manual duplex");
|
||||
}
|
||||
h.get_by_label("Size").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Two-sided:").is_some());
|
||||
assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::LongEdge), "back to the choice");
|
||||
}
|
||||
|
||||
@@ -14,13 +14,7 @@ fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
app.set_option("zoom", "150").unwrap();
|
||||
app
|
||||
});
|
||||
for _ in 0..60 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
|
||||
@@ -2,21 +2,43 @@
|
||||
use egui_kittest::Harness;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl Fn(&PdfCraftApp) -> bool) {
|
||||
/// Step frames until `ready` holds or 30 seconds pass, and say whether it held. For background work
|
||||
/// (a worker-thread open, page text arriving) that a test then asserts on itself: a frame count is
|
||||
/// not a time, and a busy runner may not have finished the work within one. Sleeps briefly between
|
||||
/// frames so the worker threads get the CPU.
|
||||
#[must_use]
|
||||
pub fn step_until(h: &mut Harness<'static, PdfCraftApp>, mut ready: impl FnMut(&mut Harness<'static, PdfCraftApp>) -> bool) -> bool {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
loop {
|
||||
h.step();
|
||||
if ready(h.state()) {
|
||||
return;
|
||||
if ready(h) {
|
||||
return true;
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for {what}: views={}, active={:?}, password={:?}, toast={:?}",
|
||||
h.state().views.len(),
|
||||
h.state().active,
|
||||
h.state().password_prompt.as_ref().map(|p| (&p.name, &p.error)),
|
||||
h.state().toast
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// Step frames until nothing is waiting to be rendered, and fail the test if that takes over 30
|
||||
/// seconds: what a test checks next is on the rendered page.
|
||||
pub fn settle(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
let settled = step_until(h, |h| {
|
||||
h.run_steps(1);
|
||||
!h.state().render_pending()
|
||||
});
|
||||
assert!(settled, "pages rendered");
|
||||
}
|
||||
|
||||
pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl Fn(&PdfCraftApp) -> bool) {
|
||||
let held = step_until(h, |h| ready(h.state()));
|
||||
assert!(
|
||||
held,
|
||||
"timed out waiting for {what}: views={}, active={:?}, password={:?}, toast={:?}",
|
||||
h.state().views.len(),
|
||||
h.state().active,
|
||||
h.state().password_prompt.as_ref().map(|p| (&p.name, &p.error)),
|
||||
h.state().toast
|
||||
);
|
||||
}
|
||||
|
||||
@@ -451,7 +451,7 @@ fn overflowing_tabs_can_be_reached_by_scrolling() {
|
||||
assert!(h.state().active.is_some_and(|i| i < 19));
|
||||
if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") {
|
||||
let _gpu = crate::gpu();
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
h.render().unwrap().save(format!("{dir}/overflow-tabs.png")).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -565,7 +565,7 @@ fn narrow_tab_strips_accept_vertical_wheel_scrolling() {
|
||||
assert!(h.get_by_label_contains("Display theme:").rect().right() <= width);
|
||||
if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") {
|
||||
let _gpu = crate::gpu();
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
h.render().unwrap().save(format!("{dir}/tabs-{width}.png")).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -612,23 +612,13 @@ endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
fn settle(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
for _ in 0..200 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_counts_matches_across_pages() {
|
||||
let mut h = harness(|app| {
|
||||
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
|
||||
app.set_option("find", "brown").unwrap();
|
||||
});
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
h.get_by_label_contains("of 2");
|
||||
let v = &h.state().views[0];
|
||||
let f = v.find.as_ref().expect("find open");
|
||||
@@ -641,7 +631,7 @@ fn find_reports_no_matches() {
|
||||
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
|
||||
app.set_option("find", "zebra").unwrap();
|
||||
});
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
h.get_by_label("No matches");
|
||||
}
|
||||
|
||||
@@ -651,7 +641,7 @@ fn drag_selects_text_and_copy_returns_it() {
|
||||
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
});
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
// "The quick brown fox": glyph 4 = 'q', glyph 14 = 'n' of "brown".
|
||||
let (a, b) = {
|
||||
let v = &h.state().views[0];
|
||||
@@ -738,7 +728,7 @@ fn zoom_keeps_the_point_under_the_cursor_still() {
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("panel", "none").unwrap();
|
||||
});
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
let before = h.state().views[0].glyph_screen_pos(0, 4).expect("glyph on screen");
|
||||
let z = h.state().views[0].zoom;
|
||||
h.state_mut().views[0].zoom_at(z * 2.0, before);
|
||||
@@ -754,7 +744,7 @@ fn trackpad_pinch_zooms_the_pdf_under_the_pointer() {
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("panel", "none").unwrap();
|
||||
});
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
let before = h.state().views[0].glyph_screen_pos(0, 4).expect("glyph on screen");
|
||||
let zoom = h.state().views[0].zoom;
|
||||
h.hover_at(before);
|
||||
@@ -781,7 +771,7 @@ fn selection_works_in_every_view_rotation() {
|
||||
// Keep the whole rotated page on screen.
|
||||
app.set_option("zoom", "60").unwrap();
|
||||
});
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
let (a, b) = {
|
||||
let v = &h.state().views[0];
|
||||
(v.glyph_screen_pos(0, 4).expect("glyph"), v.glyph_screen_pos(0, 14).expect("glyph"))
|
||||
@@ -1321,7 +1311,7 @@ fn document_tabs_expose_selection_and_activate_from_keyboard() {
|
||||
}
|
||||
if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") {
|
||||
let _gpu = crate::gpu();
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
h.render().unwrap().save(format!("{dir}/focused-tab-{key:?}.png")).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1547,7 +1537,7 @@ fn short_window_rail_buttons_scroll_clear_of_page_navigation() {
|
||||
app.open_bytes("short.pdf", None, FIXTURE.to_vec()).unwrap();
|
||||
app
|
||||
});
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
let rail = |h: &Harness<'static, PdfCraftApp>, label: &str| {
|
||||
h.query_all_by_label(label).map(|n| n.rect()).find(|r| r.left() >= width - 48.0).unwrap_or_else(|| panic!("{label} in the rail"))
|
||||
};
|
||||
|
||||
@@ -1309,13 +1309,7 @@ fn highlight_fields_tints_the_field_area() {
|
||||
h.state_mut().set_option("panel", "none").unwrap();
|
||||
// Highlighting is on by default; start from plain fields.
|
||||
h.state_mut().set_option("fields", "off").unwrap();
|
||||
for _ in 0..100 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
let r = rect(&h, 0).expect("page 1");
|
||||
// The field spans x 50..250, y 300..330 (PDF space, y up) on a 300×400 page; sample inside it.
|
||||
let at = egui::pos2(r.min.x + r.width() * (60.0 / 300.0), r.min.y + r.height() * (1.0 - 305.0 / 400.0));
|
||||
@@ -1452,13 +1446,7 @@ fn required_radio_buttons_get_a_round_red_border() {
|
||||
app.set_option("fields", "on").unwrap();
|
||||
app
|
||||
});
|
||||
for _ in 0..100 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
let r = rect(&h, 0).expect("page 1");
|
||||
let ppp = h.ctx.pixels_per_point();
|
||||
let img = h.render().expect("renders");
|
||||
@@ -1603,13 +1591,7 @@ fn required_fields_get_a_red_border_when_highlighting() {
|
||||
h.state_mut().set_option("fields", "on").unwrap();
|
||||
let props = pdfcraft_engine::FieldProps { required: Some(true), ..Default::default() };
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { name: "fullname".into(), props: Box::new(props) });
|
||||
for _ in 0..100 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
let r = rect(&h, 0).expect("page 1");
|
||||
// Just inside the left edge of the field (x 50, y 300..330 on a 300×400 page).
|
||||
let ppp = h.ctx.pixels_per_point();
|
||||
@@ -1665,13 +1647,7 @@ trailer << /Root 1 0 R >>
|
||||
/// Mean and largest difference between page 1 on screen and the page rendered directly at the
|
||||
/// view's exact device scale, over the lined area of `page_raster_maps_one_to_one_onto_screen_pixels`.
|
||||
fn compare_page_with_raster(h: &mut Harness<'static, PdfCraftApp>, pdf: &[u8], zoom: f32) -> (f64, u8) {
|
||||
for _ in 0..100 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(h);
|
||||
let ppp = h.ctx.pixels_per_point();
|
||||
let r = rect(h, 0).expect("page 1");
|
||||
let img = h.render().expect("renders");
|
||||
@@ -1736,16 +1712,6 @@ fn grid_harness(pdf: Vec<u8>, zoom: &str) -> Harness<'static, PdfCraftApp> {
|
||||
})
|
||||
}
|
||||
|
||||
fn settle(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
for _ in 0..400 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// Detail in the middle of the window: the summed difference between neighbouring pixels.
|
||||
fn centre_detail(h: &mut Harness<'static, PdfCraftApp>) -> u64 {
|
||||
let img = h.render().expect("renders");
|
||||
@@ -1763,7 +1729,7 @@ fn centre_detail(h: &mut Harness<'static, PdfCraftApp>) -> u64 {
|
||||
fn zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive() {
|
||||
let _gpu = gpu();
|
||||
let mut h = grid_harness(fine_grid_pdf(), "300");
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
let sharp = centre_detail(&mut h);
|
||||
// The first frame at a new zoom has no tiles for it yet (they are requested at its end).
|
||||
h.state_mut().set_option("zoom", "310").unwrap();
|
||||
@@ -1779,13 +1745,13 @@ fn zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive() {
|
||||
fn returning_to_a_visited_zoom_reuses_its_cached_tiles() {
|
||||
let _gpu = gpu();
|
||||
let mut h = grid_harness(fine_grid_pdf(), "300");
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
let before = h.state().views[0].tiles_debug(0);
|
||||
assert!(!before.is_empty(), "the 300 % page should be tiled, but it cached no tiles");
|
||||
// Zoom right out, where the 1200 pt page fits in one raster and is not tiled at all, so
|
||||
// none of its tiles is demanded...
|
||||
h.state_mut().set_option("zoom", "50").unwrap();
|
||||
settle(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
// ...then back to the first zoom, which was already rendered sharp.
|
||||
h.state_mut().set_option("zoom", "300").unwrap();
|
||||
h.step();
|
||||
|
||||
@@ -40,13 +40,7 @@ fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
app
|
||||
});
|
||||
// Background rendering and text extraction can take seconds on a loaded CI runner (FreeBSD VM).
|
||||
for _ in 0..3000 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
@@ -1393,7 +1387,7 @@ fn pages_panel_thumbnails_have_a_context_menu() {
|
||||
h.run_steps(2);
|
||||
// The page right-clicked becomes the selection the menu acts on.
|
||||
assert_eq!(h.state().views[0].target_pages(), vec![2]);
|
||||
for item in ["Extract pages", "Cut", "Paste after"] {
|
||||
for item in ["Extract pages", "Split document…", "Cut", "Paste after"] {
|
||||
h.get_by_label(item);
|
||||
}
|
||||
h.get_by_label("Copy").click();
|
||||
@@ -1732,13 +1726,7 @@ fn a_picture_inside_form_artwork_under_text_is_selected_and_copied() {
|
||||
app.open_bytes("scan.pdf", None, pdf).unwrap();
|
||||
app
|
||||
});
|
||||
for _ in 0..40 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
h.state_mut().system_clipboard = false;
|
||||
let i = h.state().active.unwrap();
|
||||
let r = h.state().views[i].page_screen_rect(0).expect("on screen");
|
||||
|
||||
@@ -111,17 +111,6 @@ fn ordinary_forms_and_documents_have_no_xfa_notice() {
|
||||
assert_eq!(xfa(&h), Some(pdfcraft_render::Xfa::Dynamic));
|
||||
}
|
||||
|
||||
/// Run frames until the pages are drawn (fields can be clicked then).
|
||||
fn wait_for_pages(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
for _ in 0..40 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// Click the centre of a field's widget.
|
||||
fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
|
||||
let p = {
|
||||
@@ -141,7 +130,7 @@ fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
|
||||
#[test]
|
||||
fn xfa_buttons_run_their_scripts_in_the_app() {
|
||||
let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
|
||||
wait_for_pages(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
let names = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>()
|
||||
@@ -197,7 +186,7 @@ fn a_form_whose_scripts_were_turned_off_at_open_says_so() {
|
||||
fn scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back() {
|
||||
let tpl = pdfcraft_xfa::fixtures::scripted_template().replace(r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, RUNAWAY);
|
||||
let mut h = open(pdfcraft_xfa::fixtures::shell(&tpl));
|
||||
wait_for_pages(&mut h);
|
||||
crate::support::settle(&mut h);
|
||||
// The form opens with its scripts on, and the user closes the notice bar.
|
||||
let id = h.state().views[0].id;
|
||||
assert!(!h.state().session.get(id).unwrap().xfa_scripts_off());
|
||||
|
||||
+3
-1
@@ -7,6 +7,7 @@
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged)
|
||||
@@ -192,7 +193,7 @@ total matches the parity estimate.
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
|
||||
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. |
|
||||
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
|
||||
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA<br>Reading colours: pending page backgrounds follow the configured colour (#2059). |
|
||||
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
|
||||
@@ -255,6 +256,7 @@ Estimates unchanged.
|
||||
| 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: covered table slots retain their text in paragraph flow across Office exporters; emitted superscripts remain in their cells. Synthetic source and CLI regressions; estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: Export all images and Save Image As apply soft-mask Decode intervals to PNG alpha before resizing, including inverted/subrange, indirect, packed and JPEG masks. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: annotation appearances use unoccupied XObject names, preserving original page artwork and distinct appearances through inherited resources and print/reopen. Estimates unchanged. |
|
||||
| 2026-10-21 | minor | M3: on GNOME/Wayland, a second resize by an edge or move by the tab strip works without a click in between (the compositor kept the button's release from egui). Estimates unchanged. |
|
||||
| 2026-10-22 | minor | M4, #3916 partial: edits and Undo/Redo cancel Organize page drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. |
|
||||
| 2026-10-22 | minor | M14 #3558: rebased macOS association recovery; reuse #3042 shared metadata preservation instead of a duplicate save wrapper. Estimates unchanged. |
|
||||
|
||||
@@ -1286,8 +1286,8 @@ milestone = "M4"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/it/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/it/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"]
|
||||
notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel."
|
||||
evidence = ["crates/ui-egui/tests/it/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/it/editing.rs::split_from_pages_panel_context_menu", "crates/ui-egui/tests/it/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/it/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"]
|
||||
notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Split document…, Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel."
|
||||
|
||||
[[feature]]
|
||||
id = "view.bookmarks-panel"
|
||||
@@ -2811,8 +2811,8 @@ milestone = "M4"
|
||||
status = "shipped"
|
||||
commands = ["page.extract"]
|
||||
tools = ["page_extract"]
|
||||
evidence = ["crates/ui-egui/tests/it/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"]
|
||||
notes = "Carries the PDF/X output intent and identification (#263) in a new XMP packet; the source XMP (dates, other schemas) is not copied."
|
||||
evidence = ["crates/ui-egui/tests/it/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification", "crates/ui-egui/tests/it/pickers.rs::extract_pages_takes_the_page_ranges_typed_in_the_dialog"]
|
||||
notes = "Carries the PDF/X output intent and identification (#263) in a new XMP packet; the source XMP (dates, other schemas) is not copied. The dialog's Pages field takes page ranges (\"1-5, 8, 11-13\", in the order typed, each page once) instead of the selection (#3593); page labels are not accepted there yet."
|
||||
|
||||
[[feature]]
|
||||
id = "organize.extract-separate-files"
|
||||
@@ -2943,7 +2943,7 @@ milestone = "M4"
|
||||
status = "shipped"
|
||||
commands = ["page.split"]
|
||||
tools = ["doc_split"]
|
||||
evidence = ["crates/ui-egui/tests/it/editing.rs::split_before_selected_pages"]
|
||||
evidence = ["crates/ui-egui/tests/it/editing.rs::split_before_selected_pages", "crates/ui-egui/tests/it/editing.rs::split_from_organize_grid_context_menu"]
|
||||
notes = ""
|
||||
|
||||
[[feature]]
|
||||
@@ -8000,8 +8000,8 @@ milestone = "M10"
|
||||
status = "shipped"
|
||||
commands = ["print.dialog"]
|
||||
tools = ["doc_print", "printers"]
|
||||
evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page"]
|
||||
notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name; this change does not alter persistence of other print preferences"
|
||||
evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page", "crates/print/src/tests.rs::every_paper_reaches_the_spooler_by_a_name_of_its_size", "crates/automation/src/printing.rs::printing_to_a_printer_feeds_the_laid_out_paper"]
|
||||
notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name; this change does not alter persistence of other print preferences. CUPS jobs from the dialog and doc_print ask for the chosen paper (media) in every size: PPD keywords for the common ones, PWG 5101.1 names for A6, A0–A2, ARCH and ANSI C–E, checked against a synthetic PPD with cupsfilter, not on a physical large-format printer (#3752). On macOS jobs still take the PPD's default paper tray until craft-print sends Auto Select (#3752)"
|
||||
|
||||
[[feature]]
|
||||
id = "print.printer-selection"
|
||||
@@ -8108,8 +8108,8 @@ milestone = "M10"
|
||||
status = "shipped"
|
||||
commands = ["print.dialog"]
|
||||
tools = ["doc_print"]
|
||||
evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding"]
|
||||
notes = "both/front/back, left/right binding"
|
||||
evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding", "crates/ui-egui/tests/it/print.rs::a_booklet_decides_two_sided_itself", "crates/automation/src/printing.rs::a_booklet_decides_two_sided_itself", "crates/print/src/tests.rs::a_booklet_prints_both_sides_on_the_short_edge"]
|
||||
notes = "both/front/back, left/right binding; a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro: Acrobat automatically enables duplex printing for booklet printing): both sides flip on the short edge, front or back only prints one-sided, and the Two-sided choice is kept for the other layouts (#2707)"
|
||||
|
||||
[[feature]]
|
||||
id = "print.poster"
|
||||
@@ -8144,8 +8144,8 @@ milestone = "M10"
|
||||
status = "shipped"
|
||||
commands = ["print.dialog"]
|
||||
tools = ["doc_print"]
|
||||
evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms"]
|
||||
notes = "Print flag honoured"
|
||||
evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects"]
|
||||
notes = "Print flag honoured; appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources"
|
||||
|
||||
[[feature]]
|
||||
id = "print.print-with-comment-summary"
|
||||
|
||||
Reference in New Issue
Block a user