Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable project changes should be documented here.

## Unreleased

### Added

- Edit PDF: staged output is checked with `qpdf --check` and reopened for page boxes and catalog data before the destination file is replaced. A failed check leaves the original and any existing destination untouched.

## 0.3.0 - 2026-08-15

### Added
Expand Down
96 changes: 89 additions & 7 deletions src-tauri/src/pdf_engine/edit_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

use crate::error::AppError;
use crate::models::{JobHandle, PageGroup};
use crate::pdf_engine::{crop, edit_image};
use crate::utils::process::run_qpdf;
use crate::pdf_engine::validate_output::{
catalog_flags_from_doc, validate_staged_pdf, OutputSnapshot, PageSnapshot,
};
use crate::pdf_engine::{crop, edit_image, qpdf};
use crate::utils::process::{run_qpdf, run_tracked};
use crate::utils::safe_output;
use crate::utils::temp;
use lopdf::{Document, Object};
Expand Down Expand Up @@ -1088,7 +1091,7 @@ pub(crate) fn build_edit_overlay_args(
Ok(args)
}

/// Build the overlay and run qpdf via `run`. Used by the Tauri command and tests.
/// Build the overlay and run qpdf via `run`. Used by tests (system/`"qpdf"`).
pub(crate) fn export_edit_pdf_with_runner<F>(
groups: &[PageGroup],
output: &str,
Expand All @@ -1097,6 +1100,28 @@ pub(crate) fn export_edit_pdf_with_runner<F>(
work: &Path,
unique: &str,
cancel: Option<&AtomicBool>,
run: F,
) -> Result<Vec<String>, AppError>
where
F: FnMut(&[String]) -> Result<(), AppError>,
{
let exe = qpdf::resolve_qpdf_standalone();
export_edit_pdf_with_check_exe(
groups, output, document, font_path, work, unique, cancel, &exe, None, run,
)
}

/// Same as [`export_edit_pdf_with_runner`], with an explicit `qpdf --check` binary.
fn export_edit_pdf_with_check_exe<F>(
groups: &[PageGroup],
output: &str,
document: &EditDocumentIn,
font_path: &Path,
work: &Path,
unique: &str,
cancel: Option<&AtomicBool>,
qpdf_check: &Path,
handle: Option<&Arc<JobHandle>>,
mut run: F,
) -> Result<Vec<String>, AppError>
where
Expand All @@ -1118,6 +1143,7 @@ where
let tmp_str = tmp.to_string_lossy().to_string();
let overlay = work.join("overlay.pdf");
let overlay_str = overlay.to_string_lossy().to_string();
let mut gate_passed = false;
let result = (|| -> Result<Vec<String>, AppError> {
let (geoms, counts) = collect_source_pages(groups)?;
if geoms.is_empty() {
Expand All @@ -1138,17 +1164,70 @@ where
run(&[tmp_str.clone(), cleaned_str.clone()])?;
safe_output::replace_file(&cleaned, &tmp)?;
}
let snapshot = output_snapshot_from_source(&geoms, Path::new(&groups[0].path))?;
validate_staged_pdf(&tmp, &snapshot, cancel, |args| {
run_qpdf_check_argv(qpdf_check, args, handle)
})?;
gate_passed = true;
safe_output::replace_file(&tmp, dest)?;
Ok(vec![output.to_string()])
})();
// On success the temp was renamed away. On failure leave a dest-sibling
// tmp in place so a failed Windows replace still has a recoverable file.
if result.is_ok() && tmp.exists() {
// Keep tmp only if replace_file failed after a passed gate (Windows recover).
// Spawn/validate errors (and leftover success tmp) delete the sibling.
if !(gate_passed && result.is_err()) && tmp.exists() {
let _ = std::fs::remove_file(&tmp);
}
result
}

fn output_snapshot_from_source(
geoms: &[OverlayPageGeom],
primary: &Path,
) -> Result<OutputSnapshot, AppError> {
let doc = Document::load(primary)
.map_err(|e| AppError::engine_failed(format!("Could not read the PDF: {e}")))?;
Ok(OutputSnapshot {
pages: geoms
.iter()
.map(|g| PageSnapshot {
media_box: g.media,
crop_box: g.crop,
trim_box: g.trim,
rotate: g.rotate,
user_unit: g.user_unit,
})
.collect(),
catalog: catalog_flags_from_doc(&doc),
})
}

fn run_qpdf_check_argv(
exe: &Path,
args: &[String],
handle: Option<&Arc<JobHandle>>,
) -> Result<(i32, String), AppError> {
let mut cmd = std::process::Command::new(exe);
cmd.args(args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000);
}
if let Some(h) = handle {
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::piped());
let (status, stderr) = run_tracked(h, cmd)?;
return Ok((status.and_then(|s| s.code()).unwrap_or(1), stderr));
}
let output = cmd
.output()
.map_err(|e| AppError::io("qpdf --check failed to start", e))?;
Ok((
output.status.code().unwrap_or(1),
String::from_utf8_lossy(&output.stderr).into_owned(),
))
}

pub fn edit_pdf_overlays(
app: &tauri::AppHandle,
handle: &Arc<JobHandle>,
Expand Down Expand Up @@ -1183,14 +1262,17 @@ pub fn edit_pdf_overlays(
if handle.is_cancelled() {
return Err(AppError::cancelled());
}
export_edit_pdf_with_runner(
let qpdf_exe = qpdf::resolve_qpdf(app);
export_edit_pdf_with_check_exe(
groups,
output,
document,
&font_path,
&work,
job_id,
Some(&handle.cancelled),
&qpdf_exe,
Some(handle),
|args| run_qpdf(app, handle, job_id, args, "Saving", None),
)
})();
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/pdf_engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub mod qpdf;
pub mod render;
pub mod stamp;
pub mod textexport;
pub mod validate_output;

use crate::error::AppError;
use crate::models::{JobHandle, JobUpdate, PageGroup, PagePick, RotateGroup, SplitMode};
Expand Down
39 changes: 23 additions & 16 deletions src-tauri/src/pdf_engine/qpdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,11 @@ fn exe_name() -> &'static str {
}
}

/// Locate the qpdf binary. Prefers a bundled copy under `binaries/`, falling
/// back to the system PATH (by returning the bare exe name).
pub fn resolve_qpdf(app: &tauri::AppHandle) -> PathBuf {
/// Locate qpdf without a Tauri handle (Edit PDF `--check`, tests).
pub fn resolve_qpdf_standalone() -> PathBuf {
let exe = exe_name();

// 1. Bundled next to app resources.
if let Ok(res) = app.path().resource_dir() {
let candidate = res.join("binaries").join(exe);
if candidate.exists() {
return candidate;
}
}

// 2. Bundled next to the executable.
// Bundled next to the executable.
if let Ok(cur) = std::env::current_exe() {
if let Some(parent) = cur.parent() {
let candidate = parent.join("binaries").join(exe);
Expand All @@ -37,9 +28,9 @@ pub fn resolve_qpdf(app: &tauri::AppHandle) -> PathBuf {
}
}

// 3. Common absolute install locations. A Finder-launched .app does NOT
// inherit the shell PATH (so Homebrew/MacPorts dirs are missing), so we
// probe them explicitly before relying on PATH.
// Common absolute install locations. A Finder-launched .app does NOT
// inherit the shell PATH (so Homebrew/MacPorts dirs are missing), so we
// probe them explicitly before relying on PATH.
#[cfg(not(windows))]
{
for candidate in [
Expand All @@ -55,10 +46,26 @@ pub fn resolve_qpdf(app: &tauri::AppHandle) -> PathBuf {
}
}

// 4. Fall back to PATH (works when launched from a terminal / dev).
// Fall back to PATH (works when launched from a terminal / dev).
PathBuf::from(exe)
}

/// Locate the qpdf binary. Prefers a bundled copy under `binaries/`, falling
/// back to the system PATH (by returning the bare exe name).
pub fn resolve_qpdf(app: &tauri::AppHandle) -> PathBuf {
let exe = exe_name();

// 1. Bundled next to app resources.
if let Ok(res) = app.path().resource_dir() {
let candidate = res.join("binaries").join(exe);
if candidate.exists() {
return candidate;
}
}

resolve_qpdf_standalone()
}

/// Return the number of pages in `input` via `qpdf --show-npages`.
/// Any failure (spawn, non-zero exit, unparseable output) -> `invalid_pdf`.
pub fn npages(app: &tauri::AppHandle, input: &str) -> Result<u32, AppError> {
Expand Down
Loading
Loading