feat: PRINT command — send layout to system printer via lp/lpr

The PRINT command renders the current layout to a temporary PDF (reusing
the existing PDF export pipeline) and dispatches it to the OS default
printer:
  - Linux/macOS: `lp` (CUPS) with `lpr` as fallback.
  - Windows: ShellExecute "print" verb (compiled-in, unused on Linux).

PLOT and EXPORT still open the save-file dialog as before.
Status messages are shown in the command line during the async job.

Closes ROADMAP 1.8.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-08 22:21:50 +03:00
commit f515b556c7
5 changed files with 191 additions and 1 deletions

View file

@ -3794,9 +3794,13 @@ impl H7CAD {
}
// ── Plot / Page Setup ──────────────────────────────────────────
"PRINT"|"PLOT"|"EXPORT" => {
"PLOT"|"EXPORT" => {
return Task::done(Message::PlotExport);
}
// PRINT — send current layout to the system default printer.
"PRINT" => {
return Task::done(Message::PrintToPrinter);
}
// PLOTSTYLE — load or clear CTB/STB plot style table
cmd if cmd == "PLOTSTYLE" || cmd.starts_with("PLOTSTYLE ") => {
let sub = cmd.split_once(' ')

View file

@ -402,6 +402,10 @@ pub enum Message {
PlotExport,
/// Callback after the user picks (or cancels) the export path.
PlotExportPath(Option<std::path::PathBuf>),
/// Send current layout to the system printer (via lp / lpr).
PrintToPrinter,
/// Callback from the async printer job.
PrintResult(Result<String, String>),
// ── Plot Style Table ─────────────────────────────────────────────────
/// Open file dialog to load a CTB/STB plot style table.
PlotStyleLoad,

View file

@ -2722,6 +2722,70 @@ impl H7CAD {
Task::none()
}
// ── Print to system printer ───────────────────────────────────────
Message::PrintToPrinter => {
let i = self.active_tab;
let scene = &self.tabs[i].scene;
let layout_name = scene.current_layout.clone();
let wires = scene.entity_wires();
use acadrust::objects::{ObjectType, PlotType};
let ps_snap = scene.document.objects.values().find_map(|obj| {
if let ObjectType::PlotSettings(ps) = obj {
if ps.page_name == layout_name { Some(ps.clone()) } else { None }
} else { None }
});
let (paper_w, paper_h, draw_ox, draw_oy, rotation_deg) =
if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() {
let (pw, ph) = (x1 - x0, y1 - y0);
let use_extents = ps_snap.as_ref()
.map(|ps| matches!(ps.plot_type, PlotType::Extents))
.unwrap_or(false);
let (ox, oy) = if use_extents {
if let Some((mn, _mx)) = scene.model_space_extents() {
(-mn.x as f64, -mn.y as f64)
} else { (-x0, -y0) }
} else { (-x0, -y0) };
let rot = ps_snap.as_ref()
.map(|ps| ps.rotation.to_degrees() as i32)
.unwrap_or(0);
(pw, ph, ox, oy, rot)
} else {
if let Some((mn, mx)) = scene.model_space_extents() {
let margin = 1.05_f64;
let w = ((mx.x - mn.x) as f64 * margin).max(1.0);
let h = ((mx.y - mn.y) as f64 * margin).max(1.0);
let pad_x = (w - (mx.x - mn.x) as f64) * 0.5;
let pad_y = (h - (mx.y - mn.y) as f64) * 0.5;
(w, h, -(mn.x as f64) + pad_x, -(mn.y as f64) + pad_y, 0)
} else { (297.0, 210.0, 0.0, 0.0, 0) }
};
let (eff_w, eff_h) = match rotation_deg {
90 | 270 => (paper_h, paper_w),
_ => (paper_w, paper_h),
};
let plot_style = self.active_plot_style.clone();
self.command_line.push_info("Sending to system printer…");
Task::perform(
async move {
crate::io::print_to_printer::print_wires(
wires, eff_w, eff_h,
draw_ox as f32, draw_oy as f32,
rotation_deg, plot_style,
)
.await
},
Message::PrintResult,
)
}
Message::PrintResult(Ok(printer)) => {
self.command_line.push_info(&format!("Sent to printer: {printer}"));
Task::none()
}
Message::PrintResult(Err(e)) => {
self.command_line.push_error(&format!("Print failed: {e}"));
Task::none()
}
// ── Plot Style Table ──────────────────────────────────────────────
Message::PlotStyleLoad => {
Task::perform(

View file

@ -6,6 +6,7 @@
pub mod obj;
pub mod pdf_export;
pub mod plot_style;
pub mod print_to_printer;
pub mod stl;
pub mod xref;

117
src/io/print_to_printer.rs Normal file
View file

@ -0,0 +1,117 @@
// print_to_printer — send the current layout to the system printer.
//
// Strategy:
// 1. Render the drawing to a temporary PDF (reusing the PDF export pipeline).
// 2. Send that PDF to the system printer with `lp` (Linux/macOS) or
// `ShellExecute PRINT` (Windows).
//
// The function is async so the UI remains responsive while the job is queued.
use crate::io::plot_style::PlotStyleTable;
use crate::io::pdf_export;
use crate::scene::WireModel;
/// Render `wires` to a temp PDF and dispatch it to the default system printer.
///
/// Returns `Ok(printer_name)` on success or `Err(message)` on failure.
pub async fn print_wires(
wires: Vec<WireModel>,
paper_w: f64,
paper_h: f64,
offset_x: f32,
offset_y: f32,
rotation_deg: i32,
plot_style: Option<PlotStyleTable>,
) -> Result<String, String> {
// ── 1. Write to a named temp file ─────────────────────────────────────
let tmp_path = std::env::temp_dir().join("h7cad_print.pdf");
pdf_export::export_pdf(
&wires,
paper_w,
paper_h,
offset_x,
offset_y,
rotation_deg,
&tmp_path,
plot_style.as_ref(),
)?;
// ── 2. Dispatch to system printer ─────────────────────────────────────
dispatch_to_printer(&tmp_path)
}
/// Platform-specific dispatch of a PDF path to the system printer.
fn dispatch_to_printer(path: &std::path::Path) -> Result<String, String> {
#[cfg(target_os = "windows")]
{
// Windows: ShellExecute with "print" verb.
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
let path_wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
let verb: Vec<u16> = OsStr::new("print\0").encode_wide().collect();
let result = unsafe {
windows_sys::Win32::UI::Shell::ShellExecuteW(
std::ptr::null_mut(),
verb.as_ptr(),
path_wide.as_ptr(),
std::ptr::null(),
std::ptr::null(),
windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE,
) as usize
};
if result > 32 {
Ok("default printer".to_string())
} else {
Err(format!("ShellExecute PRINT failed (code {result})"))
}
}
#[cfg(not(target_os = "windows"))]
{
// Linux / macOS: prefer `lp`, fall back to `lpr`.
let path_str = path.to_string_lossy();
// Try `lp` first (CUPS).
let lp = std::process::Command::new("lp")
.arg("--")
.arg(path_str.as_ref())
.output();
match lp {
Ok(out) if out.status.success() => {
// `lp` prints the job ID on stdout, e.g. "request id is lp-42 (1 file(s))"
let msg = String::from_utf8_lossy(&out.stdout);
let printer = msg
.split_whitespace()
.find(|w| w.contains('-'))
.unwrap_or("default")
.to_string();
return Ok(printer);
}
Ok(out) => {
let err = String::from_utf8_lossy(&out.stderr).into_owned();
// Fall through to lpr.
if !err.is_empty() {
// Try lpr as alternative.
}
}
Err(_) => {
// lp not found — try lpr.
}
}
// Fall back to `lpr`.
let lpr = std::process::Command::new("lpr")
.arg(path_str.as_ref())
.output()
.map_err(|e| format!("Could not launch lp or lpr: {e}"))?;
if lpr.status.success() {
Ok("default printer".to_string())
} else {
let err = String::from_utf8_lossy(&lpr.stderr).into_owned();
Err(format!("lpr error: {err}"))
}
}
}