Merge pull request #383 from KevinGriffin-new/fix/pdf-export
Never fail silently on PDF export; add EXPORTPDF <path> (#369). The export flow handed off to an async rfd save dialog and mapped a None result straight to Task::none(). On Linux None also means the dialog never opened (broken XDG portal / missing zenity), and rfd reports that only via log, so PDF export could end in total silence. The dialog-None arms now print a message pointing at EXPORTPDF <path>, and EXPORT/EXPORTPDF take an optional path that exports straight to a file with no dialog — the workaround where the native dialog can't open, and a headless entry point. Conflict: the fix (display.rs, file.rs, mod.rs, COMMANDS.md) auto-merged; only the additive test block in automation.rs conflicted. Resolved by keeping main's tests and adding the two #369 tests. # Conflicts: # src/app/automation.rs
This commit is contained in:
commit
5b4a6719aa
5 changed files with 116 additions and 11 deletions
|
|
@ -303,8 +303,8 @@ Status of every standard CAD command in Open CAD Studio:
|
||||||
| `QSAVE` | — | Quick save | ✅ |
|
| `QSAVE` | — | Quick save | ✅ |
|
||||||
| `SAVEALL` | — | Save all open drawings | ✅ |
|
| `SAVEALL` | — | Save all open drawings | ✅ |
|
||||||
| `PLOT` | — | Print / plot | ✅ |
|
| `PLOT` | — | Print / plot | ✅ |
|
||||||
| `EXPORT` | — | Export | ✅ |
|
| `EXPORT [path]` | — | Export to PDF (save dialog, or straight to `path`) | ✅ |
|
||||||
| `EXPORTPDF` | — | Export to PDF | ✅ |
|
| `EXPORTPDF [path]` | — | Export to PDF (save dialog, or straight to `path`) | ✅ |
|
||||||
| `PAGESETUP` | — | Page setup | ✅ |
|
| `PAGESETUP` | — | Page setup | ✅ |
|
||||||
| `PLOTSTYLE` | — | Plot style | ✅ |
|
| `PLOTSTYLE` | — | Plot style | ✅ |
|
||||||
| `PURGE` | PU | Purge unused items | ✅ |
|
| `PURGE` | PU | Purge unused items | ✅ |
|
||||||
|
|
|
||||||
|
|
@ -714,6 +714,56 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exportpdf_with_explicit_path_writes_pdf_without_dialog() {
|
||||||
|
// EXPORTPDF <path> must export straight to the given file — no save
|
||||||
|
// dialog — and confirm on the command line. This is the dialog-free
|
||||||
|
// export path added for #369 (Export to PDF silently did nothing when
|
||||||
|
// the native save dialog could not open).
|
||||||
|
let mut app = OpenCADStudio::new_for_test();
|
||||||
|
app.automation_op(r#"{"op":"new"}"#);
|
||||||
|
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 100,50"}"#);
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(format!("ocs_test_369_{}.pdf", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let start = app.command_line.history.len();
|
||||||
|
let _ = app.run_command_line(&format!("EXPORTPDF {}", path.display()));
|
||||||
|
let out: String = app.command_line.history[start..]
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.text.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert!(out.contains("Exported"), "no export confirmation: {out:?}");
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&path).expect("EXPORTPDF <path> should write the file");
|
||||||
|
assert!(bytes.starts_with(b"%PDF"), "output is not a PDF");
|
||||||
|
assert!(bytes.len() > 200, "suspiciously small PDF: {}", bytes.len());
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelled_pdf_save_dialog_reports_on_command_line() {
|
||||||
|
// The save dialog resolving to None (cancelled, or it never opened —
|
||||||
|
// e.g. a broken XDG portal) must leave a visible message, not silence.
|
||||||
|
// Regression for #369.
|
||||||
|
use crate::app::Message;
|
||||||
|
let mut app = OpenCADStudio::new_for_test();
|
||||||
|
app.automation_op(r#"{"op":"new"}"#);
|
||||||
|
for msg in [Message::PlotExportPath(None), Message::PlotWindowExportPath(None)] {
|
||||||
|
let start = app.command_line.history.len();
|
||||||
|
let _ = app.update(msg);
|
||||||
|
let out: String = app.command_line.history[start..]
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.text.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert!(
|
||||||
|
out.contains("EXPORTPDF"),
|
||||||
|
"dialog-None must mention the EXPORTPDF fallback: {out:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handing_over_an_already_open_drawing_switches_to_its_tab() {
|
fn handing_over_an_already_open_drawing_switches_to_its_tab() {
|
||||||
// Double-clicking a drawing that is already open should land on the tab
|
// Double-clicking a drawing that is already open should land on the tab
|
||||||
|
|
|
||||||
|
|
@ -365,8 +365,40 @@ impl OpenCADStudio {
|
||||||
"PLOT" | "PRINT" => {
|
"PLOT" | "PRINT" => {
|
||||||
return Some(Task::done(Message::PlotDialogOpen));
|
return Some(Task::done(Message::PlotDialogOpen));
|
||||||
}
|
}
|
||||||
"EXPORT" | "EXPORTPDF" => {
|
// With no argument these open the save dialog; with a path they
|
||||||
return Some(Task::done(Message::PlotExport));
|
// export straight to it. The direct form is also the workaround for
|
||||||
|
// systems where the native save dialog cannot open at all (broken
|
||||||
|
// XDG portal / missing zenity on Linux) — there the dialog future
|
||||||
|
// resolves to None and no export would happen. (#369)
|
||||||
|
cmd if cmd == "EXPORT"
|
||||||
|
|| cmd == "EXPORTPDF"
|
||||||
|
|| cmd.starts_with("EXPORT ")
|
||||||
|
|| cmd.starts_with("EXPORTPDF ") =>
|
||||||
|
{
|
||||||
|
// Strip the keyword actually typed — EXPORTPDF first, since
|
||||||
|
// EXPORT is its prefix (see #295 for the failure mode).
|
||||||
|
let arg = cmd
|
||||||
|
.strip_prefix("EXPORTPDF")
|
||||||
|
.or_else(|| cmd.strip_prefix("EXPORT"))
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.trim_matches('"');
|
||||||
|
if arg.is_empty() {
|
||||||
|
return Some(Task::done(Message::PlotExport));
|
||||||
|
}
|
||||||
|
let mut path = std::path::PathBuf::from(arg);
|
||||||
|
if path.extension().is_none() {
|
||||||
|
path.set_extension("pdf");
|
||||||
|
}
|
||||||
|
// A bare filename lands next to the drawing when it has a home.
|
||||||
|
if path.is_relative() {
|
||||||
|
if let Some(dir) =
|
||||||
|
self.tabs[i].current_path.as_deref().and_then(|p| p.parent())
|
||||||
|
{
|
||||||
|
path = dir.join(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Some(self.on_plot_export_path_some(path));
|
||||||
}
|
}
|
||||||
// PLOTSTYLE — load or clear CTB/STB plot style table
|
// PLOTSTYLE — load or clear CTB/STB plot style table
|
||||||
cmd if cmd == "PLOTSTYLE" || cmd.starts_with("PLOTSTYLE ") => {
|
cmd if cmd == "PLOTSTYLE" || cmd.starts_with("PLOTSTYLE ") => {
|
||||||
|
|
|
||||||
|
|
@ -1148,7 +1148,10 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn on_plot_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
|
pub(in crate::app) fn on_plot_export_path_some(
|
||||||
|
&mut self,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
) -> Task<Message> {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
let scene = &self.tabs[i].scene;
|
let scene = &self.tabs[i].scene;
|
||||||
let wires = scene.entity_wires();
|
let wires = scene.entity_wires();
|
||||||
|
|
@ -1251,10 +1254,12 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||||
&path,
|
&path,
|
||||||
self.active_plot_style.as_ref(),
|
self.active_plot_style.as_ref(),
|
||||||
) {
|
) {
|
||||||
Ok(()) => self.command_line.push_info(&format!(
|
// Full path, not just the file name — when the export was
|
||||||
"Exported: {}",
|
// driven by EXPORTPDF <path> the user needs to see where
|
||||||
path.file_name().unwrap_or_default().to_string_lossy()
|
// the file actually landed. (#369)
|
||||||
)),
|
Ok(()) => self
|
||||||
|
.command_line
|
||||||
|
.push_info(&format!("Exported: {}", path.display())),
|
||||||
Err(e) => self.command_line.push_error(&format!("Export failed: {e}")),
|
Err(e) => self.command_line.push_error(&format!("Export failed: {e}")),
|
||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
|
|
|
||||||
|
|
@ -3582,7 +3582,18 @@ impl OpenCADStudio {
|
||||||
Message::PlotExportPath,
|
Message::PlotExportPath,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Message::PlotExportPath(None) => Task::none(),
|
// None means the save dialog was cancelled — or never opened at
|
||||||
|
// all (a broken XDG portal / missing zenity on Linux resolves to
|
||||||
|
// None too, and rfd only reports that through `log`, which is
|
||||||
|
// opt-in). Either way say something instead of silently doing
|
||||||
|
// nothing. (#369)
|
||||||
|
Message::PlotExportPath(None) => {
|
||||||
|
self.command_line.push_info(
|
||||||
|
"PDF export canceled — no file chosen. \
|
||||||
|
If no dialog appeared, use EXPORTPDF <path>.",
|
||||||
|
);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
Message::PlotExportPath(Some(path)) => self.on_plot_export_path_some(path),
|
Message::PlotExportPath(Some(path)) => self.on_plot_export_path_some(path),
|
||||||
|
|
||||||
Message::PlotFormat(f) => {
|
Message::PlotFormat(f) => {
|
||||||
|
|
@ -3606,7 +3617,14 @@ impl OpenCADStudio {
|
||||||
Message::PlotWindowExportPath,
|
Message::PlotWindowExportPath,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Message::PlotWindowExportPath(None) => Task::none(),
|
// Same silent-None trap as PlotExportPath above. (#369)
|
||||||
|
Message::PlotWindowExportPath(None) => {
|
||||||
|
self.command_line.push_info(
|
||||||
|
"PDF export canceled — no file chosen. \
|
||||||
|
If no dialog appeared, use EXPORTPDF <path>.",
|
||||||
|
);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
Message::PlotWindowExportPath(Some(path)) => self.on_plot_window_export_path_some(path),
|
Message::PlotWindowExportPath(Some(path)) => self.on_plot_window_export_path_some(path),
|
||||||
|
|
||||||
// ── Print to system printer ───────────────────────────────────────
|
// ── Print to system printer ───────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue