diff --git a/src/document/command.rs b/src/document/command.rs index 7fe0ee1..7f0ab61 100644 --- a/src/document/command.rs +++ b/src/document/command.rs @@ -3,11 +3,13 @@ // // Commands that operate on the active document itself. -/// Commands operating on the active document (zoom, export, annotate, …). +/// Commands operating on the active document (zoom, export, print, …). /// /// The `DocumentManager` dispatches these after resolving which document is /// active – the UI never needs to know the underlying format. #[derive(Debug, Clone)] pub enum DocumentCommand { + /// Print the active document to the system printer. + Print, // Reserved for future use: Rotate, Flip, Zoom, Export, Annotate, … } diff --git a/src/main.rs b/src/main.rs index 0478017..943c93b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,8 @@ use cosmic::iced::{ keyboard::{self, key::Named, Key}, mouse, Alignment, ContentFit, Event, Length, Limits, Subscription, }; +use cosmic::widget::menu::action::MenuAction; +use cosmic::widget::menu::Item as MenuItem; use cosmic::{executor, theme, widget, Application, Element}; use std::collections::hash_map::DefaultHasher; use std::ffi::OsStr; @@ -28,7 +30,7 @@ struct Args { #[arg(value_name = "FILE")] files: Vec, - /// Compatibility with the previous CLI harness. + /// Compatibility with the previous harness. #[arg(long = "open-document", value_name = "PATH")] document: Option, @@ -37,7 +39,7 @@ struct Args { directory: Option, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Copy, Eq, PartialEq)] enum DocumentKind { Raster, Svg, @@ -110,6 +112,30 @@ enum Message { page: usize, result: Result, }, + // Print messages + Print, + PrintTask { + result: Result<(), String>, + }, + About, +} + +/// Menu action type – only the variants that can appear in the menu. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MenuActionType { + Print, + About, +} + +impl MenuAction for MenuActionType { + type Message = Message; + + fn message(&self) -> Self::Message { + match self { + MenuActionType::Print => Message::Print, + MenuActionType::About => Message::About, + } + } } struct NoctuaApp { @@ -118,6 +144,7 @@ struct NoctuaApp { current: usize, zoom: f32, modifiers: keyboard::Modifiers, + printing: bool, } fn main() -> cosmic::iced::Result { @@ -180,6 +207,7 @@ impl Application for NoctuaApp { current, zoom: 1.0, modifiers: keyboard::Modifiers::default(), + printing: false, }; app.refresh_title(); @@ -275,6 +303,51 @@ impl Application for NoctuaApp { } } } + // Print handling + Message::Print => { + if self.printing { + return Task::none(); + } + + let document = match self.current_document() { + Some(doc) => doc, + None => return Task::none(), + }; + let path = document.path.clone(); + let kind = document.kind; + + self.printing = true; + + return Task::perform( + tokio::task::spawn_blocking(move || print_document_sync(&path, kind)), + |result| { + let inner = result + .map_err(|e| format!("Spawn error: {e}")) + .and_then(|r| r); + cosmic::Action::App(Message::PrintTask { result: inner }) + }, + ); + } + Message::PrintTask { result } => { + self.printing = false; + match result { + Ok(()) => { + // Print succeeded – no visual feedback needed, + // the document was sent to the printer. + } + Err(err) => { + if let Some(document) = self.current_document_mut() { + document.error = Some(err); + } + } + } + } + Message::About => { + // Simple about: show info in the document area + if let Some(document) = self.current_document_mut() { + document.error = Some("Noctua – A document and image viewer for the COSMIC desktop\n\nVersion 0.1.0\n© 2025 Wolfgang Morawetz\nLicensed under GPL-3.0-or-later".to_string()); + } + } } self.refresh_title(); @@ -304,6 +377,18 @@ impl Application for NoctuaApp { }) } + fn header_start(&self) -> Vec> { + let doc = self.current_document(); + let has_document = doc.is_some(); + + let menu_bar = widget::menu::bar(vec![widget::menu::Tree::with_children( + cosmic::widget::RcElementWrapper::new(Element::from(widget::menu::root("File"))), + widget::menu::items(&std::collections::HashMap::new(), menu_items(has_document)), + )]); + + vec![Element::from(menu_bar)] + } + fn view(&self) -> Element<'_, Self::Message> { let content = match self.current_document() { Some(document) => self.document_view(document), @@ -320,6 +405,7 @@ impl Application for NoctuaApp { } fn footer(&self) -> Option> { + #[allow(clippy::question_mark)] let Some(document) = self.current_document() else { return None; }; @@ -380,6 +466,31 @@ impl Application for NoctuaApp { } } +fn menu_items(has_document: bool) -> Vec> { + let mut items = Vec::new(); + + // Print button (only when a document is open) + if has_document { + items.push(MenuItem::Button( + "Print", + Some(cosmic::widget::icon::from_name("document-print-symbolic").into()), + MenuActionType::Print, + )); + } + + // Separator + items.push(MenuItem::Divider); + + // About + items.push(MenuItem::Button( + "About Noctua", + Some(cosmic::widget::icon::from_name("help-about").into()), + MenuActionType::About, + )); + + items +} + impl NoctuaApp { fn zoom_in(&mut self) { self.zoom = (self.zoom * 1.2).min(5.0); @@ -732,3 +843,115 @@ fn path_hash(path: &Path) -> u64 { hasher.finish() } + +// ─── Print service ────────────────────────────────────────────────────────── + +/// Print the currently active document to the system printer via CUPS (lp). +/// +/// - **PDF** → sent directly to CUPS via `lp` +/// - **Raster images** → converted to PDF via `img2pdf` (or `convert` as fallback) +/// - **SVG** → converted to PDF via `pdftocairo` → `img2pdf` +fn print_document_sync(path: &Path, kind: DocumentKind) -> Result<(), String> { + let pdf_path = match kind { + DocumentKind::Pdf => path.to_path_buf(), + DocumentKind::Raster => convert_raster_to_pdf(path)?, + DocumentKind::Svg => convert_svg_to_pdf(path)?, + }; + + send_to_printer(&pdf_path) +} + +fn convert_raster_to_pdf(path: &Path) -> Result { + // Try img2pdf first (cleaner, no quality loss) + if let Ok(result) = convert_with_cmd("img2pdf", vec![path.to_string_lossy().into_owned()], path) + { + return Ok(result); + } + + // Fallback: ImageMagick convert + convert_with_cmd("convert", vec![path.to_string_lossy().into_owned()], path) +} + +fn convert_svg_to_pdf(path: &Path) -> Result { + // Render SVG to PNG with pdftocairo, then convert PNG to PDF + let prefix = temp_prefix(path, "svg"); + let status = Command::new("pdftocairo") + .arg("-png") + .arg("-singlefile") + .arg("-f") + .arg("1") + .arg("-l") + .arg("1") + .arg("-scale-to") + .arg("2400") + .arg(path) + .arg(&prefix) + .status() + .map_err(|e| format!("Failed to run pdftocairo: {e}"))?; + + if !status.success() { + return Err("Failed to render SVG with pdftocairo".to_string()); + } + + let png_path = PathBuf::from(format!("{}.png", prefix.display())); + if !png_path.exists() { + return Err("pdftocairo did not produce output".to_string()); + } + + convert_raster_to_pdf(&png_path) +} + +fn convert_with_cmd( + cmd_name: &str, + args: Vec, + source_path: &Path, +) -> Result { + let output_path = temp_prefix(source_path, "print"); + + let mut cmd = Command::new(cmd_name); + if cmd_name == "img2pdf" { + cmd.args(&args).arg("-o").arg(&output_path); + } else { + // For convert, the last arg is the output + if !args.is_empty() { + cmd.args(&args[..args.len().saturating_sub(1)]) + .arg(&output_path); + } + } + + let status = cmd + .status() + .map_err(|e| format!("Failed to run {}: {e}", cmd_name))?; + if !status.success() { + return Err(format!("{} failed", cmd_name)); + } + + if output_path.exists() { + Ok(output_path) + } else { + Err(format!("{} did not produce output", cmd_name)) + } +} + +fn send_to_printer(pdf_path: &Path) -> Result<(), String> { + let status = Command::new("lp") + .arg(pdf_path) + .status() + .map_err(|e| format!("Failed to run lp: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("Failed to send document to printer".to_string()) + } +} + +fn temp_prefix(source_path: &Path, prefix: &str) -> PathBuf { + std::env::temp_dir() + .join(format!( + "noctua-print-{}-{}", + path_hash(source_path), + prefix + )) + .with_extension("pdf") +} diff --git a/src/print_service.rs b/src/print_service.rs new file mode 100644 index 0000000..64054f1 --- /dev/null +++ b/src/print_service.rs @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/print_service.rs +// +// Print service – sends documents to the system printer via CUPS (lp). +// +// Supported formats: +// - PDF: sent directly to lp (CUPS) +// - Raster images: converted to PDF via img2pdf, then sent to lp +// - SVG: converted to PDF via pdftocairo -svg → pdftocairo -pdf, then sent to lp +// +// If img2pdf is not available, raster images are converted to PDF via +// ImageMagick's `convert` as a fallback. + +use std::path::PathBuf; +use std::process::Command; + +/// Result of a print operation. +#[derive(Debug)] +pub enum PrintResult { + /// The document was sent to the printer successfully. + Success, + /// The operation was cancelled by the user. + Cancelled, + /// An error occurred during printing. + Error(String), +} + +/// Print the currently active document. +/// +/// For PDF files the document is sent directly to CUPS via `lp`. +/// For raster images, the file is converted to PDF first (using `img2pdf` or +/// `convert` as fallback) and then sent to the printer. +/// For SVG files, the SVG is converted to PDF via `pdftocairo` and then sent +/// to the printer. +pub fn print_document(path: &PathBuf, kind: DocumentKind) -> PrintResult { + let pdf_path = match kind { + DocumentKind::Pdf => { + // PDFs can be sent directly to CUPS + path.clone() + } + DocumentKind::Raster => { + // Raster images need conversion to PDF first + convert_raster_to_pdf(path) + } + DocumentKind::Svg => { + // SVG needs conversion to PDF first + convert_svg_to_pdf(path) + } + }; + + match pdf_path { + Ok(pdf_path) => send_to_printer(&pdf_path), + Err(e) => PrintResult::Error(e), + } +} + +/// Convert a raster image to a temporary PDF file. +/// +/// Uses `img2pdf` first, then falls back to `convert` (ImageMagick). +fn convert_raster_to_pdf(path: &PathBuf) -> Result { + // Try img2pdf first (cleaner, no quality loss) + if let Ok(result) = convert_with_cmd("img2pdf", vec![path.to_string_lossy().into_owned()], path) { + return Ok(result); + } + + // Fallback to ImageMagick convert + convert_with_cmd("convert", vec![path.to_string_lossy().into_owned()], path) +} + +/// Convert an SVG to a temporary PDF file. +/// +/// Uses `pdftocairo` to render the SVG to a temporary PNG, then converts +/// that PNG to PDF. For a more direct approach, we try `svg2pdf` if available. +fn convert_svg_to_pdf(path: &PathBuf) -> Result { + // Try svg2pdf first (direct SVG → PDF) + if let Ok(result) = convert_with_cmd( + "svg2pdf", + vec![ + path.to_string_lossy().into_owned(), + "-".to_string(), + ], + path, + ) { + return Ok(result); + } + + // Fallback: render SVG to PNG with pdftocairo, then convert PNG to PDF + // First render SVG to PNG + let prefix = temp_prefix(path, "svg"); + let status = Command::new("pdftocairo") + .arg("-png") + .arg("-singlefile") + .arg("-f") + .arg("1") + .arg("-l") + .arg("1") + .arg("-scale-to") + .arg("2400") + .arg(path) + .arg(&prefix) + .status() + .map_err(|e| format!("Failed to run pdftocairo: {e}"))?; + + if !status.success() { + return Err("Failed to render SVG with pdftocairo".to_string()); + } + + let png_path = PathBuf::from(format!("{}.png", prefix)); + if !png_path.exists() { + return Err("pdftocairo did not produce output".to_string()); + } + + // Convert PNG to PDF + convert_with_cmd("img2pdf", vec![png_path.to_string_lossy().into_owned()], path) +} + +/// Convert a file to PDF using a command-line tool. +/// +/// Writes the output to a temporary file and returns its path. +fn convert_with_cmd( + cmd: &str, + args: Vec, + _source_path: &PathBuf, +) -> Result { + let status = Command::new(cmd) + .args(&args) + .output() + .map_err(|e| format!("Failed to run {cmd}: {e}"))?; + + if !status.status.success() { + let stderr = String::from_utf8_lossy(&status.stderr); + return Err(format!("{cmd} failed: {stderr}")); + } + + // img2pdf writes to stdout by default, convert writes to a file + // For img2pdf, we need to capture stdout + // For convert, the output is specified in args + // Let's handle both: + // - img2pdf: write to temp file + // - convert: find the output file + + // For img2pdf, we need to re-run with output redirect + // Actually, let's use a simpler approach: write to a known temp file + let output_path = temp_prefix(_source_path, "print"); + + // Re-run with explicit output + let mut cmd = Command::new(cmd); + if cmd.get_program().to_string() == "img2pdf" { + cmd.args(&args).arg("-o").arg(&output_path); + } else { + // For convert, the last arg is the output + if !args.is_empty() { + cmd.args(&args[..args.len().saturating_sub(1)]).arg(&output_path); + } + } + + let status = cmd.status().map_err(|e| format!("Failed to run {cmd}: {e}"))?; + if !status.success() { + return Err(format!("{cmd} failed")); + } + + if output_path.exists() { + Ok(output_path) + } else { + Err(format!("{cmd} did not produce output")) + } +} + +/// Send a PDF file to the system printer via CUPS (lp command). +fn send_to_printer(pdf_path: &PathBuf) -> PrintResult { + let status = Command::new("lp") + .arg(pdf_path) + .status() + .map_err(|e| format!("Failed to run lp: {e}"))?; + + if status.success() { + PrintResult::Success + } else { + PrintResult::Error("Failed to send document to printer".to_string()) + } +} + +/// Generate a temporary file path for printing. +fn temp_prefix(source_path: &PathBuf, prefix: &str) -> PathBuf { + std::env::temp_dir() + .join(format!( + "noctua-print-{}-{}", + path_hash(source_path), + prefix + )) + .with_extension("pdf") +} + +/// Hash a path for use in temp file names. +fn path_hash(path: &PathBuf) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + path.hash(&mut hasher); + hasher.finish() +} + +/// The document kind for print operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DocumentKind { + Pdf, + Raster, + Svg, +}