diff options
Diffstat (limited to 'src/core/print')
| -rw-r--r-- | src/core/print/mod.rs | 15 | ||||
| -rw-r--r-- | src/core/print/parsing.rs | 219 | ||||
| -rw-r--r-- | src/core/print/plugins/mod.rs | 2 | ||||
| -rw-r--r-- | src/core/print/plugins/pdf.rs | 27 | ||||
| -rw-r--r-- | src/core/print/plugins/system.rs | 49 | ||||
| -rw-r--r-- | src/core/print/printer_manager.rs | 228 | ||||
| -rw-r--r-- | src/core/print/renderer.rs | 1537 | ||||
| -rw-r--r-- | src/core/print/ui/mod.rs | 3 | ||||
| -rw-r--r-- | src/core/print/ui/print_dialog.rs | 999 |
9 files changed, 3079 insertions, 0 deletions
diff --git a/src/core/print/mod.rs b/src/core/print/mod.rs new file mode 100644 index 0000000..a958b6a --- /dev/null +++ b/src/core/print/mod.rs @@ -0,0 +1,15 @@ +// Print module for BeepZone label printing +// This module contains the label renderer and printing UI + +pub mod parsing; +pub mod plugins; +pub mod printer_manager; +pub mod renderer; +pub mod ui; // system printer discovery & direct print + +// Re-export commonly used types +pub use ui::print_dialog::{PrintDialog, PrintOptions}; +// Other types available via submodules: +// - parsing::{parse_layout_json, parse_printer_settings, CenterMode, PrinterSettings} +// - plugins::{pdf::PdfPlugin, system::SystemPrintPlugin} +// - renderer::{LabelElement, LabelLayout, LabelRenderer} diff --git a/src/core/print/parsing.rs b/src/core/print/parsing.rs new file mode 100644 index 0000000..01edf37 --- /dev/null +++ b/src/core/print/parsing.rs @@ -0,0 +1,219 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// This file now centralizes parsing logic that was previously in system_print.rs +// It helps decouple the UI and plugins from the direct implementation of parsing. + +/// Represents the layout of a label, deserialized from JSON. +// NOTE: This assumes LabelLayout is defined in your renderer module. +// If not, you might need to move or publicly export it. +use super::renderer::LabelLayout; + +/// Represents printer-specific settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrinterSettings { + #[serde(default = "default_paper_size")] + pub paper_size: String, + #[serde(default = "default_orientation")] + pub orientation: String, + #[serde(default)] + pub margins: PrinterMargins, + #[serde(default = "default_color")] + pub color: bool, + #[serde(default = "default_quality")] + pub quality: String, + #[serde(default = "default_copies")] + pub copies: u32, + #[serde(default)] + pub duplex: bool, + #[serde(default)] + pub center: Option<CenterMode>, + #[serde(default)] + pub center_disabled: bool, + #[serde(default = "default_scale_mode")] + pub scale_mode: ScaleMode, + #[serde(default = "default_scale_factor")] + pub scale_factor: f32, + #[serde(default)] + pub custom_width_mm: Option<f32>, + #[serde(default)] + pub custom_height_mm: Option<f32>, + // New optional direct-print fields + #[serde(default)] + pub printer_name: Option<String>, + #[serde(default)] + pub show_dialog_if_unfound: Option<bool>, + #[serde(default)] + pub compatibility_mode: bool, +} + +impl Default for PrinterSettings { + fn default() -> Self { + Self { + paper_size: default_paper_size(), + orientation: default_orientation(), + margins: PrinterMargins::default(), + color: default_color(), + quality: default_quality(), + copies: default_copies(), + duplex: false, + center: None, + center_disabled: false, + scale_mode: default_scale_mode(), + scale_factor: default_scale_factor(), + custom_width_mm: None, + custom_height_mm: None, + printer_name: None, + show_dialog_if_unfound: None, + compatibility_mode: false, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CenterMode { + None, + Horizontal, + Vertical, + Both, +} + +impl CenterMode { + pub fn includes_horizontal(self) -> bool { + matches!(self, CenterMode::Horizontal | CenterMode::Both) + } + + pub fn includes_vertical(self) -> bool { + matches!(self, CenterMode::Vertical | CenterMode::Both) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PrinterMargins { + pub top: f32, + pub right: f32, + pub bottom: f32, + pub left: f32, +} + +// Default value functions for PrinterSettings +fn default_paper_size() -> String { + "A4".to_string() +} +fn default_orientation() -> String { + "portrait".to_string() +} +#[allow(dead_code)] +fn default_scale() -> f32 { + 1.0 +} +fn default_color() -> bool { + false +} +fn default_quality() -> String { + "high".to_string() +} +fn default_copies() -> u32 { + 1 +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ScaleMode { + Fit, + FitX, + FitY, + MaxBoth, + MaxX, + MaxY, + Manual, +} + +fn default_scale_mode() -> ScaleMode { + ScaleMode::Fit +} + +fn default_scale_factor() -> f32 { + 1.0 +} + +impl PrinterSettings { + pub fn canonicalize_dimensions(&mut self) { + // No-op: dimensions are used as specified + } + + pub fn get_dimensions_mm(&self) -> (f32, f32) { + if let (Some(w), Some(h)) = (self.custom_width_mm, self.custom_height_mm) { + // For custom dimensions, swap if landscape to create rotated PDF + let orientation = self.orientation.to_ascii_lowercase(); + + let result = if orientation == "landscape" { + // Landscape: swap dimensions for PDF (rotate 90°) + (h, w) + } else { + // Portrait: use as-is + (w, h) + }; + + log::info!( + "get_dimensions_mm: custom {}×{} mm, orientation='{}' → PDF {}×{} mm", + w, + h, + self.orientation, + result.0, + result.1 + ); + + result + } else { + // Standard paper sizes + let (width, height) = match self.paper_size.as_str() { + "A4" => (210.0, 297.0), + "A5" => (148.0, 210.0), + "Letter" => (215.9, 279.4), + _ => (100.0, 150.0), // Default + }; + if self.orientation == "landscape" { + (height, width) + } else { + (width, height) + } + } + } +} + +/// Utility function to parse a JSON value that might be a raw string, +/// a base64-encoded string, or a direct JSON object. +fn parse_flexible_json<T>(value: &Value) -> Result<T> +where + T: for<'de> Deserialize<'de>, +{ + match value { + Value::String(s) => { + if let Ok(parsed) = serde_json::from_str(s) { + return Ok(parsed); + } + match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s) { + Ok(decoded_bytes) => { + let decoded_str = String::from_utf8(decoded_bytes) + .context("Decoded base64 is not valid UTF-8")?; + serde_json::from_str(&decoded_str) + .context("Failed to parse base64-decoded JSON") + } + Err(_) => anyhow::bail!("Value is not valid JSON or base64-encoded JSON"), + } + } + json_obj => serde_json::from_value(json_obj.clone()) + .context("Failed to parse value as a direct JSON object"), + } +} + +pub fn parse_layout_json(layout_json_value: &Value) -> Result<LabelLayout> { + parse_flexible_json(layout_json_value) +} + +pub fn parse_printer_settings(settings_value: &Value) -> Result<PrinterSettings> { + parse_flexible_json(settings_value) +} diff --git a/src/core/print/plugins/mod.rs b/src/core/print/plugins/mod.rs new file mode 100644 index 0000000..8decf3b --- /dev/null +++ b/src/core/print/plugins/mod.rs @@ -0,0 +1,2 @@ +pub mod pdf; +pub mod system; diff --git a/src/core/print/plugins/pdf.rs b/src/core/print/plugins/pdf.rs new file mode 100644 index 0000000..2456edb --- /dev/null +++ b/src/core/print/plugins/pdf.rs @@ -0,0 +1,27 @@ +use anyhow::{Context, Result}; +use printpdf::PdfDocumentReference; +use std::fs::File; +use std::io::BufWriter; +use std::path::PathBuf; + +pub struct PdfPlugin; + +impl PdfPlugin { + pub fn new() -> Self { + Self + } + + pub fn export_pdf(&self, doc: PdfDocumentReference, path: &PathBuf) -> Result<()> { + let file = File::create(path).context("Failed to create PDF file for export")?; + let mut writer = BufWriter::new(file); + doc.save(&mut writer) + .context("Failed to save PDF to specified path")?; + Ok(()) + } +} + +impl Default for PdfPlugin { + fn default() -> Self { + Self::new() + } +} diff --git a/src/core/print/plugins/system.rs b/src/core/print/plugins/system.rs new file mode 100644 index 0000000..7525a03 --- /dev/null +++ b/src/core/print/plugins/system.rs @@ -0,0 +1,49 @@ +use anyhow::{Context, Result}; +use printpdf::PdfDocumentReference; +use std::fs::File; +use std::io::BufWriter; +use std::path::PathBuf; + +pub struct SystemPrintPlugin { + temp_dir: PathBuf, +} + +impl SystemPrintPlugin { + pub fn new() -> Result<Self> { + let temp_dir = std::env::temp_dir().join("beepzone_labels"); + std::fs::create_dir_all(&temp_dir)?; + Ok(Self { temp_dir }) + } + + #[allow(dead_code)] + pub fn print_label(&self, doc: PdfDocumentReference) -> Result<()> { + let pdf_path = self.save_pdf_to_temp(doc)?; + log::info!("Generated temporary PDF at: {:?}", pdf_path); + self.open_print_dialog(&pdf_path)?; + Ok(()) + } + + pub fn save_pdf_to_temp(&self, doc: PdfDocumentReference) -> Result<PathBuf> { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let pdf_path = self.temp_dir.join(format!("label_{}.pdf", timestamp)); + let file = File::create(&pdf_path).context("Failed to create temp PDF file")?; + let mut writer = BufWriter::new(file); + doc.save(&mut writer).context("Failed to save temp PDF")?; + Ok(pdf_path) + } + + pub fn open_print_dialog(&self, pdf_path: &PathBuf) -> Result<()> { + open::that(pdf_path).context("Failed to open PDF with system default application")?; + log::info!("PDF opened successfully. User can print from the PDF viewer."); + Ok(()) + } +} + +impl Default for SystemPrintPlugin { + fn default() -> Self { + Self::new().expect("Failed to initialize SystemPrintPlugin") + } +} diff --git a/src/core/print/printer_manager.rs b/src/core/print/printer_manager.rs new file mode 100644 index 0000000..e8dd7fd --- /dev/null +++ b/src/core/print/printer_manager.rs @@ -0,0 +1,228 @@ +use printers::common::base::job::PrinterJobOptions; +use printers::{get_default_printer, get_printer_by_name, get_printers}; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use crate::core::print::parsing::PrinterSettings; + +#[derive(Clone)] +pub struct PrinterInfo { + pub name: String, + #[allow(dead_code)] + pub is_default: bool, +} + +pub struct PrinterManager { + available_printers: Vec<PrinterInfo>, + last_refresh: Instant, +} + +impl PrinterManager { + pub fn new() -> Self { + let mut manager = Self { + available_printers: Vec::new(), + last_refresh: Instant::now() - Duration::from_secs(3600), // Force refresh on first call + }; + manager.refresh_printers(); + manager + } + + /// Refresh the list of available printers from the system. + pub fn refresh_printers(&mut self) { + log::info!("Refreshing printer list..."); + let default_printer = get_default_printer(); + let default_name = default_printer.as_ref().map(|p| p.name.clone()); + + self.available_printers = get_printers() + .into_iter() + .map(|p| { + let name = p.name.clone(); + let is_default = default_name.as_ref() == Some(&name); + PrinterInfo { name, is_default } + }) + .collect(); + + self.last_refresh = Instant::now(); + log::info!("Found {} printers.", self.available_printers.len()); + } + + /// Get a list of all available printers, refreshing if cache is stale. + pub fn get_printers(&mut self) -> &[PrinterInfo] { + if self.last_refresh.elapsed() > Duration::from_secs(60) { + self.refresh_printers(); + } + &self.available_printers + } + + /// Print a PDF file to the specified printer. + pub fn print_pdf_to( + &self, + printer_name: &str, + pdf_path: &Path, + printer_settings: Option<&PrinterSettings>, + ) -> Result<(), String> { + let normalized_settings = printer_settings.map(|ps| { + let mut copy = ps.clone(); + copy.canonicalize_dimensions(); + copy + }); + let effective_settings = normalized_settings.as_ref(); + + if let Some(ps) = effective_settings { + let (page_w, page_h) = ps.get_dimensions_mm(); + log::info!( + "Attempting to print '{}' to printer '{}' (paper_size={}, orientation={}, page={}×{} mm)", + pdf_path.display(), + printer_name, + ps.paper_size, + ps.orientation, + page_w, + page_h + ); + } else { + log::info!( + "Attempting to print '{}' to printer '{}' without explicit printer settings", + pdf_path.display(), + printer_name + ); + } + let printer = get_printer_by_name(printer_name) + .ok_or_else(|| format!("Printer '{}' not found on the system.", printer_name))?; + + let pdf_path_str = pdf_path + .to_str() + .ok_or_else(|| format!("PDF path '{}' contains invalid UTF-8", pdf_path.display()))?; + + let owned_options = Self::build_job_options(effective_settings); + let borrowed_options: Vec<(&str, &str)> = owned_options + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + + let result = if borrowed_options.is_empty() { + printer.print_file(pdf_path_str, PrinterJobOptions::none()) + } else { + log::info!( + "Applying {} print option(s) via CUPS", + borrowed_options.len() + ); + for (key, value) in borrowed_options.iter() { + log::debug!(" job option: {}={}", key, value); + } + let job_options = PrinterJobOptions { + name: Some("BeepZone Label"), + raw_properties: borrowed_options.as_slice(), + }; + printer.print_file(pdf_path_str, job_options) + }; + result + .map(|_| ()) + .map_err(|e| format!("Failed to send print job: {}", e)) + } + + fn build_job_options(printer_settings: Option<&PrinterSettings>) -> Vec<(String, String)> { + let mut owned: Vec<(String, String)> = Vec::new(); + + if let Some(ps) = printer_settings { + let compat_mode = ps.compatibility_mode; + + // In strict compatibility mode, send NO job options at all + // This avoids triggering buggy printer filters + if compat_mode { + log::info!("Compatibility mode enabled - sending no CUPS job options"); + return owned; + } + + // Determine media first (always in portrait orientation) + if let Some(media_value) = Self::media_to_cups(ps) { + owned.push(("media".to_string(), media_value.clone())); + owned.push(("PageSize".to_string(), media_value)); + } + + // Send orientation-requested to tell CUPS to rotate the media + if let Some(orientation_code) = Self::orientation_to_cups(ps) { + owned.push(("orientation-requested".to_string(), orientation_code)); + } + + if ps.copies > 1 { + owned.push(("copies".to_string(), ps.copies.to_string())); + } + } + + owned + } + + fn orientation_to_cups(ps: &PrinterSettings) -> Option<String> { + let orientation_raw = ps.orientation.trim(); + if orientation_raw.is_empty() { + return None; + } + + match orientation_raw.to_ascii_lowercase().as_str() { + "portrait" => Some("3".to_string()), + "landscape" => Some("4".to_string()), + "reverse_landscape" | "reverse-landscape" => Some("5".to_string()), + "reverse_portrait" | "reverse-portrait" => Some("6".to_string()), + _ => None, + } + } + + fn media_to_cups(ps: &PrinterSettings) -> Option<String> { + if let (Some(w), Some(h)) = (ps.custom_width_mm, ps.custom_height_mm) { + // For custom sizes, use dimensions exactly as specified + // The user knows their media dimensions and orientation needs + let width_str = Self::format_mm(w); + let height_str = Self::format_mm(h); + return Some(format!("Custom.{width_str}x{height_str}mm")); + } + + let paper = ps.paper_size.trim(); + if paper.is_empty() { + return None; + } + + Some(paper.to_string()) + } + + fn format_mm(value: f32) -> String { + let rounded = (value * 100.0).round() / 100.0; + if (rounded - rounded.round()).abs() < 0.005 { + format!("{:.0}", rounded.round()) + } else { + format!("{:.2}", rounded) + } + } +} + +// A thread-safe, shared wrapper for the PrinterManager +#[derive(Clone)] +pub struct SharedPrinterManager(Arc<Mutex<PrinterManager>>); + +impl SharedPrinterManager { + pub fn new() -> Self { + Self(Arc::new(Mutex::new(PrinterManager::new()))) + } + + pub fn get_printers(&self) -> Vec<PrinterInfo> { + self.0.lock().unwrap().get_printers().to_vec() + } + + pub fn print_pdf_to( + &self, + printer_name: &str, + pdf_path: &Path, + printer_settings: Option<&PrinterSettings>, + ) -> Result<(), String> { + self.0 + .lock() + .unwrap() + .print_pdf_to(printer_name, pdf_path, printer_settings) + } +} + +impl Default for SharedPrinterManager { + fn default() -> Self { + Self::new() + } +} diff --git a/src/core/print/renderer.rs b/src/core/print/renderer.rs new file mode 100644 index 0000000..79a8702 --- /dev/null +++ b/src/core/print/renderer.rs @@ -0,0 +1,1537 @@ +use anyhow::{bail, Context, Result}; +use base64::Engine; +use eframe::egui; +use printpdf::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::core::print::parsing::{PrinterMargins, PrinterSettings, ScaleMode}; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- +const POINTS_TO_MM: f32 = 0.352_777_78; // 1 pt -> mm +const TEXT_DESCENT_RATIO: f32 = 0.2; + +// Fallback page if no printer settings provided +const DEFAULT_CANVAS_WIDTH_MM: f32 = 100.0; +const DEFAULT_CANVAS_HEIGHT_MM: f32 = 50.0; + +// ----------------------------------------------------------------------------- +// Grid / Space definition for new layout system +// ----------------------------------------------------------------------------- +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct LayoutSpace { + pub width: f32, + pub height: f32, +} + +impl Default for LayoutSpace { + fn default() -> Self { + Self { + width: 256.0, + height: 128.0, + } + } +} + +// ----------------------------------------------------------------------------- +// Core layout structs (grid-based) +// ----------------------------------------------------------------------------- +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LabelLayout { + #[serde(default)] + pub background: Option<String>, + #[serde(default)] + pub space: LayoutSpace, + #[serde(default)] + pub elements: Vec<LabelElement>, +} + +fn default_font_size() -> f32 { + 12.0 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum LabelElement { + Text { + field: String, + x: f32, + y: f32, + #[serde(rename = "fontSize", default = "default_font_size")] + font_size: f32, + #[serde(rename = "fontWeight", default)] + font_weight: Option<String>, + #[serde(rename = "fontFamily", default)] + font_family: Option<String>, + #[serde(rename = "maxWidth", default)] + max_width: Option<f32>, + #[serde(default)] + wrap: Option<bool>, + #[serde(default)] + color: Option<String>, + }, + QrCode { + field: String, + x: f32, + y: f32, + size: f32, + #[serde(rename = "showText", default)] + show_text: Option<bool>, + }, + Barcode { + field: String, + x: f32, + y: f32, + width: f32, + height: f32, + #[serde(default)] + format: Option<String>, + #[serde(rename = "showText", default)] + show_text: Option<bool>, + }, + DataMatrix { + field: String, + x: f32, + y: f32, + size: f32, + #[serde(rename = "showText", default)] + show_text: Option<bool>, + }, + Rect { + x: f32, + y: f32, + width: f32, + height: f32, + #[serde(default)] + fill: Option<String>, + }, + Svg { + data: String, + x: f32, + y: f32, + width: f32, + height: f32, + }, +} + +#[derive(Debug, Clone)] +pub struct LabelRenderer { + pub layout: LabelLayout, +} + +impl LabelRenderer { + pub fn new(layout: LabelLayout) -> Self { + Self { layout } + } +} + +// Bounds actually used by elements (tight box) +#[derive(Debug, Clone, Copy)] +struct LayoutBounds { + min_x: f32, + min_y: f32, + max_x: f32, + max_y: f32, +} + +impl LayoutBounds { + fn empty() -> Self { + Self { + min_x: f32::INFINITY, + min_y: f32::INFINITY, + max_x: f32::NEG_INFINITY, + max_y: f32::NEG_INFINITY, + } + } + fn is_empty(&self) -> bool { + !self.min_x.is_finite() + } + fn extend_point(&mut self, x: f32, y: f32) { + self.min_x = self.min_x.min(x); + self.min_y = self.min_y.min(y); + self.max_x = self.max_x.max(x); + self.max_y = self.max_y.max(y); + } + fn extend_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { + let (min_x, max_x) = if w >= 0.0 { (x, x + w) } else { (x + w, x) }; + let (min_y, max_y) = if h >= 0.0 { (y, y + h) } else { (y + h, y) }; + self.extend_point(min_x, min_y); + self.extend_point(max_x, max_y); + } + fn width(&self) -> f32 { + (self.max_x - self.min_x).max(0.0) + } + fn height(&self) -> f32 { + (self.max_y - self.min_y).max(0.0) + } + fn normalize_x(&self, x: f32) -> f32 { + if self.min_x.is_finite() { + x - self.min_x + } else { + x + } + } + fn normalize_y(&self, y: f32) -> f32 { + if self.min_y.is_finite() { + y - self.min_y + } else { + y + } + } +} + +// LayoutTransform maps layout-space units onto final mm coordinates +#[derive(Debug, Clone)] +#[allow(dead_code)] +struct LayoutTransform { + bounds: LayoutBounds, + page_width: f32, + page_height: f32, + printable_width: f32, + printable_height: f32, + scale_x: f32, + scale_y: f32, + uniform_scale: f32, + offset_x: f32, + offset_y: f32, + rendered_width: f32, + rendered_height: f32, + margins: PrinterMargins, +} + +impl LayoutTransform { + fn new(bounds: LayoutBounds, settings: Option<&PrinterSettings>) -> Result<Self> { + let margins = settings.map(|s| s.margins.clone()).unwrap_or_default(); + let (page_w, page_h) = if let Some(s) = settings { + // Respect printer-provided orientation (already canonicalized by get_dimensions_mm) + s.get_dimensions_mm() + } else { + // No settings: default preview page matches design aspect + ( + bounds.width().max(DEFAULT_CANVAS_WIDTH_MM), + bounds.height().max(DEFAULT_CANVAS_HEIGHT_MM), + ) + }; + let printable_w = (page_w - margins.left - margins.right).max(1.0); + let printable_h = (page_h - margins.top - margins.bottom).max(1.0); + let design_w = bounds.width().max(1.0); + let design_h = bounds.height().max(1.0); + + let scale_mode = settings.map(|s| s.scale_mode).unwrap_or(ScaleMode::Fit); + let user_factor = settings.map(|s| s.scale_factor).unwrap_or(1.0).max(0.0); + + let mut sx = printable_w / design_w; + let mut sy = printable_h / design_h; + match scale_mode { + ScaleMode::Fit => { + let uni = sx.min(sy); + sx = uni; + sy = uni; + } + ScaleMode::FitX => { + sy = sx; + } + ScaleMode::FitY => { + sx = sy; + } + ScaleMode::MaxBoth => { /* stretch independently */ } + ScaleMode::MaxX => { + sy = sx; + } + ScaleMode::MaxY => { + sx = sy; + } + ScaleMode::Manual => { + sx = user_factor; + sy = user_factor; + } + } + sx *= user_factor; + sy *= user_factor; // Manual already multiplies; harmless if 1.0 + if !sx.is_finite() || sx <= 0.0 { + sx = 1.0; + } + if !sy.is_finite() || sy <= 0.0 { + sy = 1.0; + } + let uniform = sx.min(sy); + let rendered_w = design_w * sx; + let rendered_h = design_h * sy; + // Centering + let mut offset_x = margins.left; + let mut offset_y = margins.top; + if let Some(s) = settings { + if let Some(center_mode) = s.center.filter(|_| !s.center_disabled) { + if center_mode.includes_horizontal() { + let extra = printable_w - rendered_w; + if extra > 0.0 { + offset_x = margins.left + extra / 2.0; + } + } + if center_mode.includes_vertical() { + let extra = printable_h - rendered_h; + if extra > 0.0 { + offset_y = margins.top + extra / 2.0; + } + } + } + } + log::info!("layout_transform: page {:.2}x{:.2}mm printable {:.2}x{:.2}mm design {:.2}x{:.2} units scale_x {:.4} scale_y {:.4} uniform {:.4} offsets {:.2},{:.2}", + page_w, page_h, printable_w, printable_h, design_w, design_h, sx, sy, uniform, offset_x, offset_y); + Ok(Self { + bounds, + page_width: page_w, + page_height: page_h, + printable_width: printable_w, + printable_height: printable_h, + scale_x: sx, + scale_y: sy, + uniform_scale: uniform, + offset_x, + offset_y, + rendered_width: rendered_w, + rendered_height: rendered_h, + margins, + }) + } + fn x_mm(&self, x: f32) -> f32 { + self.offset_x + self.scale_x * self.bounds.normalize_x(x) + } + fn y_mm(&self, y: f32) -> f32 { + self.offset_y + self.scale_y * self.bounds.normalize_y(y) + } + fn width_mm(&self, w: f32) -> f32 { + self.scale_x * w + } + fn height_mm(&self, h: f32) -> f32 { + self.scale_y * h + } + fn uniform_mm(&self, s: f32) -> f32 { + self.uniform_scale * s + } +} + +impl LabelRenderer { + fn render_pdf_internal( + &self, + data: &HashMap<String, String>, + printer_settings: Option<&PrinterSettings>, + ) -> Result<( + PdfDocumentReference, + PdfPageIndex, + PdfLayerIndex, + LayoutTransform, + )> { + let bounds = self.calculate_layout_bounds()?; + let transform = LayoutTransform::new(bounds, printer_settings)?; + + let (doc, page_index, layer_index) = PdfDocument::new( + "BeepZone Label", + Mm(transform.page_width), + Mm(transform.page_height), + "Layer 1", + ); + + let font = doc + .add_builtin_font(printpdf::BuiltinFont::Helvetica) + .context("Failed to add Helvetica font")?; + + let layer = doc.get_page(page_index).get_layer(layer_index); + self.render_pdf_elements(&layer, &font, data, &transform)?; + + Ok((doc, page_index, layer_index, transform)) + } + + fn render_pdf_elements( + &self, + layer: &PdfLayerReference, + font: &IndirectFontRef, + data: &HashMap<String, String>, + transform: &LayoutTransform, + ) -> Result<()> { + for element in &self.layout.elements { + match element { + LabelElement::Text { + field, + x, + y, + font_size, + color, + max_width, + wrap, + .. + } => { + let value = Self::resolve_field(field, data); + let x_mm = transform.x_mm(*x); + let y_mm = transform.y_mm(*y); + let font_pt = (*font_size * transform.uniform_scale).max(0.1); + let color_ref = color.as_deref(); + + let wrap_enabled = wrap.unwrap_or(false); + if wrap_enabled { + if let Some(max_w) = max_width { + let max_w_mm = transform.width_mm(*max_w); + let lines = Self::wrap_lines(&value, max_w_mm, font_pt); + let line_gap_mm = font_pt * POINTS_TO_MM * 1.2; + for (i, line) in lines.iter().enumerate() { + let line_top_mm = y_mm + (i as f32) * line_gap_mm; + let baseline = Self::layout_text_baseline( + line_top_mm, + font_pt, + transform.page_height, + ); + self.render_text_to_pdf( + layer, font, x_mm, baseline, font_pt, line, color_ref, + )?; + } + } else { + let baseline = + Self::layout_text_baseline(y_mm, font_pt, transform.page_height); + self.render_text_to_pdf( + layer, font, x_mm, baseline, font_pt, &value, color_ref, + )?; + } + } else { + let baseline = + Self::layout_text_baseline(y_mm, font_pt, transform.page_height); + self.render_text_to_pdf( + layer, font, x_mm, baseline, font_pt, &value, color_ref, + )?; + } + } + LabelElement::QrCode { + field, + x, + y, + size, + show_text, + .. + } => { + let value = Self::resolve_field(field, data); + let x_mm = transform.x_mm(*x); + let y_mm = transform.y_mm(*y); + let side_mm = transform.uniform_mm(*size); + let y_bottom = + Self::layout_top_to_pdf_bottom(y_mm, side_mm, transform.page_height); + self.render_qrcode_to_pdf(layer, x_mm, y_bottom, side_mm, &value)?; + + if show_text.unwrap_or(false) { + let gap_mm = 1.5 * transform.uniform_scale; + let label_top_mm = y_mm + side_mm + gap_mm; + let label_font_pt = 10.0 * transform.uniform_scale; + let baseline = Self::layout_text_baseline( + label_top_mm, + label_font_pt, + transform.page_height, + ); + self.render_text_to_pdf( + layer, + font, + x_mm, + baseline, + label_font_pt, + &value, + None, + )?; + } + } + LabelElement::DataMatrix { + field, + x, + y, + size, + show_text, + } => { + let value = Self::resolve_field(field, data); + let x_mm = transform.x_mm(*x); + let y_mm = transform.y_mm(*y); + let side_mm = transform.uniform_mm(*size); + let y_bottom = + Self::layout_top_to_pdf_bottom(y_mm, side_mm, transform.page_height); + self.render_datamatrix_to_pdf(layer, x_mm, y_bottom, side_mm, &value)?; + + if show_text.unwrap_or(false) { + let gap_mm = 1.5 * transform.uniform_scale; + let label_top_mm = y_mm + side_mm + gap_mm; + let label_font_pt = 8.0 * transform.uniform_scale; + let baseline = Self::layout_text_baseline( + label_top_mm, + label_font_pt, + transform.page_height, + ); + self.render_text_to_pdf( + layer, + font, + x_mm, + baseline, + label_font_pt, + &value, + None, + )?; + } + } + LabelElement::Barcode { + field, + x, + y, + width, + height, + format, + show_text, + } => { + let value = Self::resolve_field(field, data); + let x_mm = transform.x_mm(*x); + let y_mm = transform.y_mm(*y); + let width_mm = transform.width_mm(*width); + let height_mm = transform.height_mm(*height); + let y_bottom = + Self::layout_top_to_pdf_bottom(y_mm, height_mm, transform.page_height); + + self.render_barcode_to_pdf( + layer, + x_mm, + y_bottom, + width_mm, + height_mm, + &value, + format.as_deref(), + )?; + + if show_text.unwrap_or(false) { + let gap_mm = 1.5 * transform.uniform_scale; + let label_top_mm = y_mm + height_mm + gap_mm; + let label_font_pt = 8.0 * transform.uniform_scale; + let baseline = Self::layout_text_baseline( + label_top_mm, + label_font_pt, + transform.page_height, + ); + self.render_text_to_pdf( + layer, + font, + x_mm, + baseline, + label_font_pt, + &value, + None, + )?; + } + } + LabelElement::Rect { + x, + y, + width, + height, + fill, + } => { + let x_mm = transform.x_mm(*x); + let y_mm = transform.y_mm(*y); + let width_mm = transform.width_mm(*width); + let height_mm = transform.height_mm(*height); + let y_bottom = + Self::layout_top_to_pdf_bottom(y_mm, height_mm, transform.page_height); + self.render_rect_to_pdf( + layer, + x_mm, + y_bottom, + width_mm, + height_mm, + fill.as_deref(), + )?; + } + LabelElement::Svg { + x, + y, + width, + height, + data, + } => { + let x_mm = transform.x_mm(*x); + let y_mm = transform.y_mm(*y); + let width_mm = transform.width_mm(*width).max(0.1); + let height_mm = transform.height_mm(*height).max(0.1); + let y_bottom = + Self::layout_top_to_pdf_bottom(y_mm, height_mm, transform.page_height); + + if let Some(svg_xml) = Self::decode_svg_data_uri(data) { + let px_w = (width_mm * 3.78).ceil().max(1.0) as u32; + let px_h = (height_mm * 3.78).ceil().max(1.0) as u32; + if let Some(rgba) = Self::rasterize_svg_to_rgba(&svg_xml, px_w, px_h) { + let mut rgb: Vec<u8> = Vec::with_capacity((px_w * px_h * 3) as usize); + for chunk in rgba.chunks(4) { + let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]); + if a == 255 { + rgb.extend_from_slice(&[r, g, b]); + } else { + let af = a as f32 / 255.0; + let blend = |c: u8| { + ((c as f32 * af) + 255.0 * (1.0 - af)).round() as u8 + }; + rgb.extend_from_slice(&[blend(r), blend(g), blend(b)]); + } + } + + let image_xobj = printpdf::ImageXObject { + width: printpdf::Px(px_w as usize), + height: printpdf::Px(px_h as usize), + color_space: printpdf::ColorSpace::Rgb, + bits_per_component: printpdf::ColorBits::Bit8, + interpolate: true, + image_data: rgb, + image_filter: None, + clipping_bbox: None, + smask: None, + }; + + let image = printpdf::Image::from(image_xobj); + let base_w_mm = (px_w as f32) * 25.4 / 300.0; + let base_h_mm = (px_h as f32) * 25.4 / 300.0; + let sx = if base_w_mm > 0.0 { + width_mm / base_w_mm + } else { + 1.0 + }; + let sy = if base_h_mm > 0.0 { + height_mm / base_h_mm + } else { + 1.0 + }; + let transform_img = printpdf::ImageTransform { + translate_x: Some(printpdf::Mm(x_mm)), + translate_y: Some(printpdf::Mm(y_bottom)), + rotate: None, + scale_x: Some(sx), + scale_y: Some(sy), + dpi: Some(300.0), + }; + image.add_to_layer(layer.clone(), transform_img); + } else { + self.render_rect_to_pdf( + layer, + x_mm, + y_bottom, + width_mm, + height_mm, + Some("#DDDDDD"), + )?; + } + } else { + self.render_rect_to_pdf( + layer, + x_mm, + y_bottom, + width_mm, + height_mm, + Some("#EEEEEE"), + )?; + } + } + } + } + + Ok(()) + } + + fn resolve_field(field: &str, data: &HashMap<String, String>) -> String { + if !field.contains("{{") { + return data + .get(field) + .cloned() + .unwrap_or_else(|| field.to_string()); + } + + let mut result = String::new(); + let mut rest = field; + + while let Some(open) = rest.find("{{") { + let (prefix, tail) = rest.split_at(open); + result.push_str(prefix); + + if let Some(close) = tail.find("}}") { + let var = tail[2..close].trim(); + // Exact match first, then case-insensitive fallback + if let Some(value) = data.get(var) { + result.push_str(value); + } else if let Some((_, v)) = data.iter().find(|(k, _)| k.eq_ignore_ascii_case(var)) + { + result.push_str(v); + } // else: missing vars become empty string + rest = &tail[close + 2..]; + } else { + result.push_str(tail); + return result; + } + } + + result.push_str(rest); + result + } + + fn calculate_layout_bounds(&self) -> Result<LayoutBounds> { + let space = self.layout.space; + if !space.width.is_finite() || !space.height.is_finite() { + bail!("layout space must provide finite width and height"); + } + if space.width <= 0.0 || space.height <= 0.0 { + bail!("layout space must define positive width and height"); + } + + let mut used = LayoutBounds::empty(); + + for element in &self.layout.elements { + match element { + LabelElement::Text { + x, + y, + font_size, + max_width, + .. + } => { + let height = (*font_size * POINTS_TO_MM).max(0.1); + // Wider heuristic for text width so long strings trigger downscaling. + let width = max_width + .and_then(|w| { + if w.is_finite() && w > 0.0 { + Some(w) + } else { + None + } + }) + .unwrap_or_else(|| (*font_size * POINTS_TO_MM * 8.5).max(1.0)); + used.extend_rect(*x, *y, width, height); + } + LabelElement::QrCode { x, y, size, .. } + | LabelElement::DataMatrix { x, y, size, .. } => { + used.extend_rect(*x, *y, *size, *size); + } + LabelElement::Barcode { + x, + y, + width, + height, + .. + } => { + used.extend_rect(*x, *y, *width, *height); + } + LabelElement::Rect { + x, + y, + width, + height, + .. + } => { + used.extend_rect(*x, *y, *width, *height); + } + LabelElement::Svg { + x, + y, + width, + height, + .. + } => { + used.extend_rect(*x, *y, *width, *height); + } + } + } + + if used.is_empty() { + return Ok(LayoutBounds { + min_x: 0.0, + min_y: 0.0, + max_x: space.width, + max_y: space.height, + }); + } + + let mut bounds = used; + bounds.min_x = bounds.min_x.max(0.0); + bounds.min_y = bounds.min_y.max(0.0); + + let min_width = (space.width * 0.01).max(1.0); + let min_height = (space.height * 0.01).max(1.0); + + if bounds.width() < min_width { + bounds.min_x = 0.0; + bounds.max_x = space.width; + } else if bounds.max_x > space.width { + // allow overhang but ensure width positive + bounds.max_x = bounds.max_x.max(bounds.min_x + min_width); + } + + if bounds.height() < min_height { + bounds.min_y = 0.0; + bounds.max_y = space.height; + } else if bounds.max_y > space.height { + bounds.max_y = bounds.max_y.max(bounds.min_y + min_height); + } + + // No seal() needed; bounds already finalized + Ok(bounds) + } + + fn parse_hex_color(hex: &str) -> Option<egui::Color32> { + let raw = hex.trim(); + let raw = raw.strip_prefix('#').unwrap_or(raw); + if raw.len() != 6 { + return None; + } + + let r = u8::from_str_radix(&raw[0..2], 16).ok()?; + let g = u8::from_str_radix(&raw[2..4], 16).ok()?; + let b = u8::from_str_radix(&raw[4..6], 16).ok()?; + + Some(egui::Color32::from_rgb(r, g, b)) + } + + #[allow(dead_code)] + pub fn generate_pdf( + &self, + data: &HashMap<String, String>, + ) -> Result<(PdfDocumentReference, PdfPageIndex, PdfLayerIndex)> { + let (doc, page, layer, _) = self.render_pdf_internal(data, None)?; + Ok((doc, page, layer)) + } + + pub fn generate_pdf_with_settings( + &self, + data: &HashMap<String, String>, + printer_settings: &PrinterSettings, + ) -> Result<(PdfDocumentReference, PdfPageIndex, PdfLayerIndex)> { + let (doc, page, layer, _) = self.render_pdf_internal(data, Some(printer_settings))?; + Ok((doc, page, layer)) + } + + // Removed legacy template bounds calculation (numeric widths now direct) + + fn render_text_to_pdf( + &self, + layer: &PdfLayerReference, + font: &IndirectFontRef, + x: f32, + baseline_y: f32, + font_size_pt: f32, + text: &str, + color: Option<&str>, + ) -> Result<()> { + let (r, g, b) = color.map(Self::parse_hex_to_rgb).unwrap_or((0.0, 0.0, 0.0)); + + layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new(r, g, b, None))); + layer.use_text(text, font_size_pt, Mm(x), Mm(baseline_y), font); + + Ok(()) + } + + fn render_qrcode_to_pdf( + &self, + layer: &PdfLayerReference, + x: f32, + y_bottom: f32, + size: f32, + data: &str, + ) -> Result<()> { + use qrcodegen::{QrCode, QrCodeEcc}; + + let qr = + QrCode::encode_text(data, QrCodeEcc::Medium).context("Failed to generate QR code")?; + + let qr_size = qr.size() as usize; + let module_mm = size / qr_size as f32; + + layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new( + 0.0, 0.0, 0.0, None, + ))); + + for y in 0..qr_size { + for x_idx in 0..qr_size { + if qr.get_module(x_idx as i32, y as i32) { + let px = x + (x_idx as f32 * module_mm); + let py = y_bottom + ((qr_size - 1 - y) as f32 * module_mm); + Self::draw_filled_rect(layer, px, py, module_mm, module_mm); + } + } + } + + Ok(()) + } + + fn render_datamatrix_to_pdf( + &self, + layer: &PdfLayerReference, + x: f32, + y_bottom: f32, + size: f32, + data: &str, + ) -> Result<()> { + use datamatrix::{DataMatrix, SymbolList}; + let encoded = match DataMatrix::encode_str(data, SymbolList::default()) { + Ok(dm) => dm, + Err(e) => { + log::error!("Failed to generate DataMatrix for '{}': {:?}", data, e); + return Ok(()); + } + }; + let bmp = encoded.bitmap(); + let rows = bmp.height() as usize; + let cols = bmp.width() as usize; + if rows == 0 || cols == 0 { + return Ok(()); + } + let module_mm = size / rows.max(cols) as f32; + layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new( + 0.0, 0.0, 0.0, None, + ))); + for (px_idx, py_idx) in bmp.pixels() { + // (x,y) + let px_mm = x + px_idx as f32 * module_mm; + let py_mm = y_bottom + ((rows - 1 - py_idx) as f32 * module_mm); + Self::draw_filled_rect(layer, px_mm, py_mm, module_mm, module_mm); + } + Ok(()) + } + + fn render_barcode_to_pdf( + &self, + layer: &PdfLayerReference, + x: f32, + y_bottom: f32, + width: f32, + height: f32, + data: &str, + format: Option<&str>, + ) -> Result<()> { + use barcoders::sym::{code11::Code11, code128::Code128}; + + // Choose symbology + enum Sym { + C128(String), + C11(String), + } + + let sym = match format.map(|s| s.to_lowercase()) { + Some(ref f) if f == "code11" => { + // Code11 supports digits and '-' + let cleaned: String = data + .chars() + .filter(|c| c.is_ascii_digit() || *c == '-') + .collect(); + if cleaned.is_empty() { + log::warn!("Skipping Code11 - invalid payload: '{}'", data); + return Ok(()); + } + Sym::C11(cleaned) + } + _ => { + // Default Code128 with smart preparation + match Self::prepare_code128_payload(data) { + Some(p) => Sym::C128(p), + None => { + log::warn!("Skipping barcode - unsupported payload: '{}'", data); + return Ok(()); + } + } + } + }; + + let modules: Vec<u8> = match sym { + Sym::C128(p) => match Code128::new(&p) { + Ok(c) => c.encode(), + Err(e) => { + log::error!("Code128 encode failed: {:?}", e); + return Ok(()); + } + }, + Sym::C11(p) => match Code11::new(&p) { + Ok(c) => c.encode(), + Err(e) => { + log::error!("Code11 encode failed: {:?}", e); + return Ok(()); + } + }, + }; + + if modules.is_empty() { + log::warn!("Barcode produced no modules"); + return Ok(()); + } + + let module_width = width / modules.len() as f32; + if module_width <= 0.0 { + log::warn!("Computed non-positive module width, skipping"); + return Ok(()); + } + + layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new( + 0.0, 0.0, 0.0, None, + ))); + + let mut run_start: Option<usize> = None; + for (idx, bit) in modules.iter().enumerate() { + if *bit == 1 { + run_start.get_or_insert(idx); + } else if let Some(start) = run_start.take() { + let bar_start = x + start as f32 * module_width; + let bar_width = (idx - start) as f32 * module_width; + if bar_width > 0.0 { + Self::draw_filled_rect(layer, bar_start, y_bottom, bar_width, height); + } + } + } + + if let Some(start) = run_start.take() { + let bar_start = x + start as f32 * module_width; + let bar_width = (modules.len() - start) as f32 * module_width; + if bar_width > 0.0 { + Self::draw_filled_rect(layer, bar_start, y_bottom, bar_width, height); + } + } + + Ok(()) + } + + fn render_rect_to_pdf( + &self, + layer: &PdfLayerReference, + x: f32, + y_bottom: f32, + width_mm: f32, + height_mm: f32, + fill: Option<&str>, + ) -> Result<()> { + use printpdf::path::{PaintMode, WindingOrder}; + + let (r, g, b) = fill.map(Self::parse_hex_to_rgb).unwrap_or((0.5, 0.5, 0.5)); + + layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new(r, g, b, None))); + + let points = vec![ + (Point::new(Mm(x), Mm(y_bottom)), false), + (Point::new(Mm(x + width_mm), Mm(y_bottom)), false), + ( + Point::new(Mm(x + width_mm), Mm(y_bottom + height_mm)), + false, + ), + (Point::new(Mm(x), Mm(y_bottom + height_mm)), false), + ]; + + let polygon = printpdf::Polygon { + rings: vec![points], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + + layer.add_polygon(polygon); + Ok(()) + } + + fn parse_hex_to_rgb(hex: &str) -> (f32, f32, f32) { + let raw = hex.trim(); + let raw = raw.strip_prefix('#').unwrap_or(raw); + if raw.len() != 6 { + return (0.0, 0.0, 0.0); + } + + let r = u8::from_str_radix(&raw[0..2], 16).unwrap_or(0) as f32 / 255.0; + let g = u8::from_str_radix(&raw[2..4], 16).unwrap_or(0) as f32 / 255.0; + let b = u8::from_str_radix(&raw[4..6], 16).unwrap_or(0) as f32 / 255.0; + + (r, g, b) + } + + fn draw_filled_rect(layer: &PdfLayerReference, x: f32, y_bottom: f32, width: f32, height: f32) { + use printpdf::path::{PaintMode, WindingOrder}; + + let points = vec![ + (Point::new(Mm(x), Mm(y_bottom)), false), + (Point::new(Mm(x + width), Mm(y_bottom)), false), + (Point::new(Mm(x + width), Mm(y_bottom + height)), false), + (Point::new(Mm(x), Mm(y_bottom + height)), false), + ]; + + let polygon = printpdf::Polygon { + rings: vec![points], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + + layer.add_polygon(polygon); + } + + fn layout_top_to_pdf_bottom(y_top: f32, element_height: f32, page_height: f32) -> f32 { + page_height - y_top - element_height + } + + fn layout_text_baseline(y_top: f32, font_size_pt: f32, page_height: f32) -> f32 { + let text_height_mm = font_size_pt * POINTS_TO_MM; + let bottom = Self::layout_top_to_pdf_bottom(y_top, text_height_mm, page_height); + bottom + text_height_mm * TEXT_DESCENT_RATIO + } + + // Removed resolve_rect_width: Rect.width is now numeric grid units directly + + pub fn from_json(raw: &str) -> Result<Self> { + let json = if raw.trim_start().starts_with('{') { + raw.to_string() + } else { + // Attempt base64 decode; fall back to raw + match base64::engine::general_purpose::STANDARD.decode(raw) { + Ok(bytes) => String::from_utf8(bytes).unwrap_or_else(|_| raw.to_string()), + Err(_) => raw.to_string(), + } + }; + let layout: LabelLayout = + serde_json::from_str(&json).context("Failed to parse label layout JSON")?; + Ok(LabelRenderer::new(layout)) + } + + pub fn render_preview( + &self, + ui: &mut egui::Ui, + data: &HashMap<String, String>, + preview_scale: f32, + printer_settings: Option<&PrinterSettings>, + ) -> Result<()> { + let bounds = self.calculate_layout_bounds()?; + let transform = LayoutTransform::new(bounds, printer_settings)?; + let canvas_w_px = (transform.page_width * preview_scale).ceil().max(1.0); + let canvas_h_px = (transform.page_height * preview_scale).ceil().max(1.0); + let (resp, painter) = + ui.allocate_painter(egui::vec2(canvas_w_px, canvas_h_px), egui::Sense::hover()); + let rect = resp.rect; + // Background + let page_bg = egui::Color32::from_rgb(250, 250, 250); + painter.rect_filled(rect, egui::CornerRadius::ZERO, page_bg); + // Draw printable area to visualize margins + let printable_rect = egui::Rect::from_min_size( + egui::pos2( + rect.min.x + transform.margins.left * preview_scale, + rect.min.y + transform.margins.top * preview_scale, + ), + egui::vec2( + transform.printable_width * preview_scale, + transform.printable_height * preview_scale, + ), + ); + let printable_bg = self + .layout + .background + .as_deref() + .and_then(Self::parse_hex_color) + .unwrap_or(egui::Color32::WHITE); + painter.rect_filled(printable_rect, egui::CornerRadius::ZERO, printable_bg); + + for element in &self.layout.elements { + match element { + LabelElement::Text { + field, + x, + y, + font_size, + color, + max_width, + wrap, + .. + } => { + let value = Self::resolve_field(field, data); + let x_px = transform.x_mm(*x) * preview_scale; + let y_top_mm = transform.y_mm(*y); + let font_pt = (*font_size * transform.uniform_scale).max(0.5); + let color32 = color + .as_deref() + .and_then(Self::parse_hex_color) + .unwrap_or(egui::Color32::BLACK); + let line_height_mm = font_pt * POINTS_TO_MM * 1.2; + let lines: Vec<String> = if wrap.unwrap_or(false) && max_width.is_some() { + Self::wrap_lines(&value, transform.width_mm(max_width.unwrap()), font_pt) + } else { + vec![value] + }; + for (i, line) in lines.iter().enumerate() { + let line_y_mm = y_top_mm + i as f32 * line_height_mm; + let baseline_mm = + line_y_mm + font_pt * POINTS_TO_MM * (1.0 - TEXT_DESCENT_RATIO); + let baseline_px = baseline_mm * preview_scale; + painter.text( + egui::pos2(rect.min.x + x_px, rect.min.y + baseline_px), + egui::Align2::LEFT_BOTTOM, + line, + egui::FontId::proportional(font_pt), + color32, + ); + } + } + LabelElement::QrCode { + field, + x, + y, + size, + show_text, + .. + } => { + let value = Self::resolve_field(field, data); + let side_mm = transform.uniform_mm(*size).max(0.1); + let x_px = transform.x_mm(*x) * preview_scale; + let y_px = transform.y_mm(*y) * preview_scale; + let side_px = side_mm * preview_scale; + // Simple placeholder squares for modules (not rendering actual QR in preview for speed) + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(rect.min.x + x_px, rect.min.y + y_px), + egui::vec2(side_px, side_px), + ), + egui::CornerRadius::ZERO, + egui::Color32::BLACK, + ); + if show_text.unwrap_or(false) { + let font_pt = 10.0 * transform.uniform_scale; + let text_y_px = rect.min.y + + (transform.y_mm(*y) + side_mm + 1.5 * transform.uniform_scale) + * preview_scale + + font_pt * POINTS_TO_MM * (1.0 - TEXT_DESCENT_RATIO) * preview_scale; + painter.text( + egui::pos2(rect.min.x + x_px, text_y_px), + egui::Align2::LEFT_BOTTOM, + &value, + egui::FontId::proportional(font_pt), + egui::Color32::BLACK, + ); + } + } + LabelElement::DataMatrix { + field, + x, + y, + size, + show_text, + } => { + let value = Self::resolve_field(field, data); + let side_mm = transform.uniform_mm(*size).max(0.1); + let x_px = transform.x_mm(*x) * preview_scale; + let y_px = transform.y_mm(*y) * preview_scale; + let side_px = side_mm * preview_scale; + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(rect.min.x + x_px, rect.min.y + y_px), + egui::vec2(side_px, side_px), + ), + egui::CornerRadius::ZERO, + egui::Color32::DARK_GRAY, + ); + if show_text.unwrap_or(false) { + let font_pt = 8.0 * transform.uniform_scale; + let text_y_px = rect.min.y + + (transform.y_mm(*y) + side_mm + 1.5 * transform.uniform_scale) + * preview_scale + + font_pt * POINTS_TO_MM * (1.0 - TEXT_DESCENT_RATIO) * preview_scale; + painter.text( + egui::pos2(rect.min.x + x_px, text_y_px), + egui::Align2::LEFT_BOTTOM, + &value, + egui::FontId::proportional(font_pt), + egui::Color32::BLACK, + ); + } + } + LabelElement::Barcode { + field, + x, + y, + width, + height, + show_text, + .. + } => { + let value = Self::resolve_field(field, data); + let w_mm = transform.width_mm(*width).max(0.1); + let h_mm = transform.height_mm(*height).max(0.1); + let x_px = transform.x_mm(*x) * preview_scale; + let y_px = transform.y_mm(*y) * preview_scale; + let w_px = w_mm * preview_scale; + let h_px = h_mm * preview_scale; + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(rect.min.x + x_px, rect.min.y + y_px), + egui::vec2(w_px, h_px), + ), + egui::CornerRadius::ZERO, + egui::Color32::BLACK, + ); + if show_text.unwrap_or(false) { + let font_pt = 8.0 * transform.uniform_scale; + let text_y_px = rect.min.y + + (transform.y_mm(*y) + h_mm + 1.5 * transform.uniform_scale) + * preview_scale + + font_pt * POINTS_TO_MM * (1.0 - TEXT_DESCENT_RATIO) * preview_scale; + painter.text( + egui::pos2(rect.min.x + x_px, text_y_px), + egui::Align2::LEFT_BOTTOM, + &value, + egui::FontId::proportional(font_pt), + egui::Color32::BLACK, + ); + } + } + LabelElement::Rect { + x, + y, + width, + height, + fill, + } => { + let w_mm = transform.width_mm(*width).max(0.1); + let h_mm = transform.height_mm(*height).max(0.1); + let x_px = transform.x_mm(*x) * preview_scale; + let y_px = transform.y_mm(*y) * preview_scale; + let w_px = w_mm * preview_scale; + let h_px = h_mm * preview_scale; + let color32 = fill + .as_deref() + .and_then(Self::parse_hex_color) + .unwrap_or(egui::Color32::from_gray(180)); + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(rect.min.x + x_px, rect.min.y + y_px), + egui::vec2(w_px, h_px), + ), + egui::CornerRadius::ZERO, + color32, + ); + } + LabelElement::Svg { + x, + y, + width, + height, + .. + } => { + let w_mm = transform.width_mm(*width).max(0.1); + let h_mm = transform.height_mm(*height).max(0.1); + let x_px = transform.x_mm(*x) * preview_scale; + let y_px = transform.y_mm(*y) * preview_scale; + let w_px = w_mm * preview_scale; + let h_px = h_mm * preview_scale; + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(rect.min.x + x_px, rect.min.y + y_px), + egui::vec2(w_px, h_px), + ), + egui::CornerRadius::ZERO, + egui::Color32::from_gray(200), + ); + } + } + } + Ok(()) + } + + fn prepare_code128_payload(data: &str) -> Option<String> { + // Strip BOM and surrounding whitespace + let mut s = data.trim().trim_start_matches('\u{FEFF}').to_string(); + if s.is_empty() { + return None; + } + + // Allow user-provided advanced Code128 sequence (already has start set char) + if let Some(first) = s.chars().next() { + if matches!(first, 'À' | 'Ɓ' | 'Ć') { + // Minimal length check (library requires at least 2 chars total) + if s.len() >= 2 { + return Some(s); + } else { + return None; + } + } + } + + // Remove internal whitespace + s.retain(|c| !c.is_whitespace()); + if s.is_empty() { + return None; + } + + // Pure digits: use Code Set C (double-density). Must be even length; pad leading 0 if needed. + if s.chars().all(|c| c.is_ascii_digit()) { + if s.len() % 2 == 1 { + s.insert(0, '0'); + } + // Prefix with Set C start char 'Ć' + return Some(format!("Ć{}", s)); + } + + // General printable ASCII: choose Set B start ('Ɓ'). Filter to printable 32..=126. + let mut cleaned = String::new(); + for ch in s.chars() { + let code = ch as u32; + if (32..=126).contains(&code) { + cleaned.push(ch); + } + } + if cleaned.is_empty() { + return None; + } + Some(format!("Ɓ{}", cleaned)) + } + + // Naive word-wrap: estimate character width ~= 0.55 * font_size_pt * POINTS_TO_MM + fn wrap_lines(text: &str, max_width_mm: f32, font_size_pt: f32) -> Vec<String> { + let approx_char_mm = font_size_pt * POINTS_TO_MM * 0.55; + if approx_char_mm <= 0.0 || max_width_mm <= 0.0 { + return vec![text.to_string()]; + } + let max_chars = (max_width_mm / approx_char_mm).floor().max(1.0) as usize; + let mut lines = Vec::new(); + let mut current = String::new(); + for word in text.split_whitespace() { + if current.is_empty() { + current.push_str(word); + continue; + } + if current.len() + 1 + word.len() <= max_chars { + current.push(' '); + current.push_str(word); + } else { + lines.push(std::mem::take(&mut current)); + current.push_str(word); + } + } + if !current.is_empty() { + lines.push(current); + } + if lines.is_empty() { + lines.push(String::new()); + } + lines + } + + // Decode data URI to raw SVG XML string + fn decode_svg_data_uri(data_uri: &str) -> Option<String> { + if let Some(idx) = data_uri.find(',') { + let (header, payload) = data_uri.split_at(idx + 1); + if header.contains("base64") { + let bytes = base64::engine::general_purpose::STANDARD + .decode(payload) + .ok()?; + String::from_utf8(bytes).ok() + } else { + Some(payload.to_string()) + } + } else { + if data_uri.contains("<svg") { + Some(data_uri.to_string()) + } else { + None + } + } + } + + fn rasterize_svg_to_rgba(svg_xml: &str, target_w: u32, target_h: u32) -> Option<Vec<u8>> { + use tiny_skia::Pixmap; + use usvg::Options; + + let opt = Options::default(); + let tree = usvg::Tree::from_str(svg_xml, &opt).ok()?; + let mut pixmap = Pixmap::new(target_w, target_h)?; + // Compute uniform scale to fit preserving aspect + let view_size = tree.size(); + let sx = target_w as f32 / view_size.width(); + let sy = target_h as f32 / view_size.height(); + let scale = sx.min(sy); + let transform = tiny_skia::Transform::from_scale(scale, scale); + // Render using resvg + resvg::render(&tree, transform, &mut pixmap.as_mut()); + Some(pixmap.data().to_vec()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn code128_numeric_even_len_encodes() { + let raw = "75650012"; // even length digits + let payload = LabelRenderer::prepare_code128_payload(raw).expect("payload"); + assert!(payload.starts_with('Ć')); + let code = barcoders::sym::code128::Code128::new(&payload).expect("encode code128"); + assert!(!code.encode().is_empty()); + } + + #[test] + fn code128_numeric_odd_len_padded() { + let raw = "123"; + let payload = LabelRenderer::prepare_code128_payload(raw).expect("payload"); + assert!(payload.starts_with('Ć')); + // Collect digits after the first unicode character (start set) + let digits: String = payload.chars().skip(1).collect(); + assert_eq!(digits.len() % 2, 0); + let code = barcoders::sym::code128::Code128::new(&payload).expect("encode code128"); + assert!(!code.encode().is_empty()); + } + + #[test] + fn code128_basic_ascii_encodes() { + let payload = LabelRenderer::prepare_code128_payload("HELLO-123").expect("payload"); + assert!(payload.starts_with('Ɓ')); + let code = barcoders::sym::code128::Code128::new(&payload).expect("encode code128"); + assert!(!code.encode().is_empty()); + } + + #[test] + fn code11_accepts_digits_and_dash() { + use barcoders::sym::code11::Code11; + // Valid payload containing digits and dash + let c = Code11::new("123-45").expect("encode code11"); + assert!(!c.encode().is_empty()); + // Library should reject invalid characters; ensure it errors + assert!(Code11::new("12A45").is_err()); + } + + #[test] + fn datamatrix_encodes_nonempty_bitmap() { + use datamatrix::{DataMatrix, SymbolList}; + let dm = + DataMatrix::encode_str("DM-OK-123", SymbolList::default()).expect("encode datamatrix"); + let bmp = dm.bitmap(); + assert!(bmp.width() > 0 && bmp.height() > 0); + assert!(bmp.pixels().next().is_some()); + } + + #[test] + fn layout_deserialize_show_text_flags_raw_and_base64() { + // Minimal layout exercising showText flags across elements + let raw_json = r##"{ + "background": "#FFFFFF", + "elements": [ + {"type": "qrcode", "field": "A", "x": 5, "y": 5, "size": 20, "showText": true}, + {"type": "datamatrix", "field": "B", "x": 30, "y": 5, "size": 20, "showText": false}, + {"type": "barcode", "field": "C", "x": 5, "y": 30, "width": 40, "height": 12, "format": "code128", "showText": true} + ] + }"##; + + // Parse raw + let r1 = LabelRenderer::from_json(raw_json).expect("raw parse"); + assert_eq!(r1.layout.elements.len(), 3); + + // Parse base64 of same JSON + let b64 = base64::engine::general_purpose::STANDARD.encode(raw_json); + let r2 = LabelRenderer::from_json(&b64).expect("b64 parse"); + assert_eq!(r2.layout.elements.len(), 3); + + // Spot-check variant fields carry show_text flags via serde mapping + match &r1.layout.elements[0] { + LabelElement::QrCode { show_text, .. } => assert_eq!(show_text.unwrap_or(false), true), + _ => panic!("expected qrcode"), + } + match &r1.layout.elements[1] { + LabelElement::DataMatrix { show_text, .. } => { + assert_eq!(show_text.unwrap_or(true), false) + } + _ => panic!("expected datamatrix"), + } + match &r1.layout.elements[2] { + LabelElement::Barcode { show_text, .. } => assert_eq!(show_text.unwrap_or(false), true), + _ => panic!("expected barcode"), + } + } +} diff --git a/src/core/print/ui/mod.rs b/src/core/print/ui/mod.rs new file mode 100644 index 0000000..b134f6e --- /dev/null +++ b/src/core/print/ui/mod.rs @@ -0,0 +1,3 @@ +pub mod print_dialog; + +// PrintDialog is re-exported at crate::core::print level diff --git a/src/core/print/ui/print_dialog.rs b/src/core/print/ui/print_dialog.rs new file mode 100644 index 0000000..8ac503a --- /dev/null +++ b/src/core/print/ui/print_dialog.rs @@ -0,0 +1,999 @@ +use anyhow::Result; +use eframe::egui; +use serde_json::Value; +use std::collections::HashMap; + +use crate::api::ApiClient; +use crate::core::print::parsing::{parse_layout_json, parse_printer_settings, PrinterSettings}; +use crate::core::print::plugins::pdf::PdfPlugin; +use crate::core::print::printer_manager::{PrinterInfo, SharedPrinterManager}; +use crate::core::print::renderer::LabelRenderer; +use poll_promise::Promise; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PaperSizeOverride { + UseSaved, + A4, + Letter, + Custom, +} + +/// Print options selected by user +#[derive(Debug, Clone)] +pub struct PrintOptions { + pub printer_id: Option<i64>, + pub printer_name: String, + pub label_template_id: Option<i64>, + pub label_template_name: String, + pub copies: i32, +} + +impl Default for PrintOptions { + fn default() -> Self { + Self { + printer_id: None, + printer_name: String::new(), + label_template_id: None, + label_template_name: String::new(), + copies: 1, + } + } +} + +/// Print dialog for selecting printer, template, and preview +pub struct PrintDialog { + options: PrintOptions, + pub asset_data: HashMap<String, String>, + printers: Vec<Value>, + templates: Vec<Value>, + renderer: Option<LabelRenderer>, + preview_scale: f32, + error_message: Option<String>, + loading: bool, + // Promise for handling async PDF export + pdf_export_promise: Option<Promise<Option<PathBuf>>>, + // OS printer fallback popup + os_popup_visible: bool, + os_printers: Vec<PrinterInfo>, + os_selected_index: usize, + os_print_path: Option<PathBuf>, + os_error_message: Option<String>, + os_base_settings: Option<PrinterSettings>, + os_renderer: Option<LabelRenderer>, + os_size_override: PaperSizeOverride, + os_custom_width_mm: f32, + os_custom_height_mm: f32, +} + +impl PrintDialog { + /// Create new print dialog with asset data + pub fn new(asset_data: HashMap<String, String>) -> Self { + Self { + options: PrintOptions::default(), + asset_data, + printers: Vec::new(), + templates: Vec::new(), + renderer: None, + preview_scale: 3.78, // Default scale: 1mm = 3.78px at 96 DPI + error_message: None, + loading: false, + pdf_export_promise: None, + os_popup_visible: false, + os_printers: Vec::new(), + os_selected_index: 0, + os_print_path: None, + os_error_message: None, + os_base_settings: None, + os_renderer: None, + os_size_override: PaperSizeOverride::UseSaved, + os_custom_width_mm: 0.0, + os_custom_height_mm: 0.0, + } + } + + /// Initialize with default printer and template if available + pub fn with_defaults( + mut self, + default_printer_id: Option<i64>, + label_template_id: Option<i64>, + last_printer_id: Option<i64>, + ) -> Self { + // Prefer last-used printer if available, otherwise fall back to default + self.options.printer_id = last_printer_id.or(default_printer_id); + // Label template is *not* persisted across sessions; if none is set on the asset, + // the dialog will require the user to choose one. + self.options.label_template_id = label_template_id; + self + } + + /// Load printers and templates from API + pub fn load_data(&mut self, api_client: &ApiClient) -> Result<()> { + self.loading = true; + self.error_message = None; + + // Load printers + match crate::core::tables::get_printers(api_client) { + Ok(printers) => self.printers = printers, + Err(e) => { + self.error_message = Some(format!("Failed to load printers: {}", e)); + return Err(e); + } + } + + // Load templates + match crate::core::tables::get_label_templates(api_client) { + Ok(templates) => self.templates = templates, + Err(e) => { + self.error_message = Some(format!("Failed to load templates: {}", e)); + return Err(e); + } + } + + // Set default selections if IDs provided + if let Some(printer_id) = self.options.printer_id { + if let Some(printer) = self + .printers + .iter() + .find(|p| p.get("id").and_then(|v| v.as_i64()) == Some(printer_id)) + { + self.options.printer_name = printer + .get("printer_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Fetch printer_settings for preview sizing/orientation + let resp = api_client.select( + "printer_settings", + Some(vec!["printer_settings".into()]), + Some(serde_json::json!({"id": printer_id})), + None, + Some(1), + )?; + if let Some(first) = resp.data.as_ref().and_then(|d| d.get(0)) { + if let Some(ps_val) = first.get("printer_settings") { + if let Ok(ps) = parse_printer_settings(ps_val) { + self.os_base_settings = Some(ps); + } + } + } + } + } + + if let Some(template_id) = self.options.label_template_id { + if let Some(template) = self + .templates + .iter() + .find(|t| t.get("id").and_then(|v| v.as_i64()) == Some(template_id)) + { + let template_name = template + .get("template_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + self.options.label_template_name = template_name.clone(); + + // Load renderer for preview + if let Some(layout_json) = template.get("layout_json").and_then(|v| v.as_str()) { + if layout_json.trim().is_empty() { + log::warn!("Label template '{}' has empty layout_json", template_name); + self.error_message = Some("This label template has no layout defined. Please edit the template in Label Templates view.".to_string()); + } else { + match LabelRenderer::from_json(layout_json) { + Ok(renderer) => self.renderer = Some(renderer), + Err(e) => { + log::warn!( + "Failed to parse label layout for '{}': {}", + template_name, + e + ); + self.error_message = Some(format!("Invalid template layout JSON. Please fix in Label Templates view.\n\nError: {}", e)); + } + } + } + } else { + log::warn!( + "Label template '{}' missing layout_json field", + template_name + ); + self.error_message = Some( + "This label template is missing layout data. Please edit the template." + .to_string(), + ); + } + } + } + + self.loading = false; + Ok(()) + } + + /// Show the dialog and return true if user clicked Print and the action is complete + pub fn show( + &mut self, + ctx: &egui::Context, + open: &mut bool, + api_client: Option<&ApiClient>, + ) -> bool { + let mut print_action_complete = false; + let mut close_dialog = false; + + if let Some(_response) = egui::Window::new("Print Label") + .open(open) + .resizable(true) + .default_width(600.0) + .default_height(500.0) + .show(ctx, |ui| { + // Load data if not loaded yet + if self.printers.is_empty() && !self.loading && api_client.is_some() { + if let Err(e) = self.load_data(api_client.unwrap()) { + log::error!("Failed to load print data: {}", e); + } + } + + // Show error if any + if let Some(error) = &self.error_message { + ui.colored_label(egui::Color32::RED, error); + ui.add_space(8.0); + } + + // Show loading spinner + if self.loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Loading printers and templates..."); + }); + return; + } + + // Options panel + egui::ScrollArea::vertical() + .id_salt("print_options_scroll") + .show(ui, |ui| { + self.show_options(ui); + ui.add_space(12.0); + self.show_preview(ui); + }); + + // Handle PDF export promise + if let Some(promise) = &self.pdf_export_promise { + if let Some(result) = promise.ready() { + match result { + Some(path) => { + log::info!("PDF export promise ready, path: {:?}", path); + // The file dialog is done, now we can save the file. + // We need the ApiClient and other details again. + if let Some(client) = api_client { + if let Err(e) = self.execute_pdf_export(path, client) { + self.error_message = + Some(format!("Failed to export PDF: {}", e)); + } else { + // Successfully exported, close dialog + print_action_complete = true; + close_dialog = true; + } + } else { + self.error_message = Some( + "API client not available for PDF export.".to_string(), + ); + } + } + None => { + // User cancelled the dialog + log::info!("PDF export cancelled by user."); + } + } + self.pdf_export_promise = None; // Consume the promise + } else { + ui.spinner(); + ui.label("Waiting for file path..."); + } + } + + // Bottom buttons + ui.add_space(8.0); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + close_dialog = true; + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let can_print = self.options.printer_id.is_some() + && self.options.label_template_id.is_some() + && self.options.copies > 0 + && self.pdf_export_promise.is_none(); // Disable while waiting for path + + ui.add_enabled_ui(can_print, |ui| { + if ui.button("Print").clicked() { + if let Some(client) = api_client { + match self.execute_print(client) { + Ok(completed) => { + if completed { + print_action_complete = true; + close_dialog = true; + } + // if not completed, dialog stays open for promise + } + Err(e) => { + self.error_message = + Some(format!("Print error: {}", e)); + } + } + } else { + self.error_message = + Some("API Client not available.".to_string()); + } + } + }); + }); + }); + }) + { + // Window was shown + } + + // Render OS printer fallback popup if requested + if self.os_popup_visible { + let mut close_os_popup = false; + let mut keep_open_flag = true; + egui::Window::new("Select System Printer") + .collapsible(false) + .resizable(true) + .default_width(420.0) + .open(&mut keep_open_flag) + .show(ctx, |ui| { + if let Some(err) = &self.os_error_message { + ui.colored_label(egui::Color32::RED, err); + ui.separator(); + } + if self.os_printers.is_empty() { + let mgr = SharedPrinterManager::new(); + self.os_printers = mgr.get_printers(); + if let Some(base) = &self.os_base_settings { + if let Some(target_name) = &base.printer_name { + if let Some((idx, _)) = self + .os_printers + .iter() + .enumerate() + .find(|(_, p)| &p.name == target_name) + { + self.os_selected_index = idx; + } + } + } + } + if self.os_printers.is_empty() { + ui.label("No system printers found."); + } else { + if self.os_selected_index >= self.os_printers.len() { + self.os_selected_index = 0; + } + let current = self + .os_printers + .get(self.os_selected_index) + .map(|p| p.name.clone()) + .unwrap_or_default(); + egui::ComboBox::from_id_salt("os_printers_combo") + .selected_text(if current.is_empty() { "Select printer" } else { ¤t }) + .show_ui(ui, |ui| { + for (i, p) in self.os_printers.iter().enumerate() { + if ui + .selectable_label(i == self.os_selected_index, &p.name) + .clicked() + { + self.os_selected_index = i; + } + } + }); + } + ui.separator(); + + if let Some(base) = &self.os_base_settings { + let saved_label = format!( + "Use saved ({})", + base.paper_size.as_str() + ); + + egui::ComboBox::from_id_salt("os_size_override") + .selected_text(match self.os_size_override { + PaperSizeOverride::UseSaved => saved_label.clone(), + PaperSizeOverride::A4 => "A4 (210×297 mm)".into(), + PaperSizeOverride::Letter => "Letter (215.9×279.4 mm)".into(), + PaperSizeOverride::Custom => "Custom size".into(), + }) + .show_ui(ui, |ui| { + if ui + .selectable_value( + &mut self.os_size_override, + PaperSizeOverride::UseSaved, + saved_label, + ) + .clicked() + { + self.os_error_message = None; + } + if ui + .selectable_value( + &mut self.os_size_override, + PaperSizeOverride::A4, + "A4 (210×297 mm)", + ) + .clicked() + { + self.os_error_message = None; + } + if ui + .selectable_value( + &mut self.os_size_override, + PaperSizeOverride::Letter, + "Letter (215.9×279.4 mm)", + ) + .clicked() + { + self.os_error_message = None; + } + if ui + .selectable_value( + &mut self.os_size_override, + PaperSizeOverride::Custom, + "Custom size", + ) + .clicked() + { + if base.custom_width_mm.is_some() + && base.custom_height_mm.is_some() + { + let (w, h) = base.get_dimensions_mm(); + self.os_custom_width_mm = w; + self.os_custom_height_mm = h; + } else { + self.os_custom_width_mm = 0.0; + self.os_custom_height_mm = 0.0; + } + self.os_error_message = None; + } + }); + + if matches!(self.os_size_override, PaperSizeOverride::Custom) { + ui.vertical(|ui| { + ui.label("Custom page size (mm)"); + ui.horizontal(|ui| { + ui.label("Width:"); + ui.add( + egui::DragValue::new(&mut self.os_custom_width_mm) + .range(10.0..=600.0) + .suffix(" mm"), + ); + ui.label("Height:"); + ui.add( + egui::DragValue::new(&mut self.os_custom_height_mm) + .range(10.0..=600.0) + .suffix(" mm"), + ); + }); + }); + } + } + + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + self.os_print_path = None; + self.os_base_settings = None; + close_os_popup = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let can_print = !self.os_printers.is_empty() + && self + .os_printers + .get(self.os_selected_index) + .is_some(); + ui.add_enabled_ui(can_print, |ui| { + if ui.button("Print").clicked() { + let selected_name = self + .os_printers + .get(self.os_selected_index) + .map(|p| p.name.clone()); + if let Some(name) = selected_name { + match self.print_via_os_popup(&name) { + Ok(true) => { + self.os_print_path = None; + self.os_base_settings = None; + close_os_popup = true; + print_action_complete = true; + close_dialog = true; + } + Ok(false) => { /* not used: function only returns true on success */ } + Err(e) => { + self.os_error_message = Some(e); + } + } + } + } + }); + }); + }); + }); + // Apply window close state after rendering + if !keep_open_flag { + close_os_popup = true; + } + if close_os_popup { + self.os_popup_visible = false; + self.os_base_settings = None; + } + } + + if close_dialog { + *open = false; + } + + print_action_complete + } + + /// Show options section + fn show_options(&mut self, ui: &mut egui::Ui) { + ui.heading("Print Options"); + ui.add_space(8.0); + + egui::Grid::new("print_options_grid") + .num_columns(2) + .spacing([8.0, 8.0]) + .show(ui, |ui| { + // Printer selection + ui.label("Printer:"); + egui::ComboBox::from_id_salt("printer_select") + .selected_text(&self.options.printer_name) + .width(300.0) + .show_ui(ui, |ui| { + for printer in &self.printers { + let printer_id = printer.get("id").and_then(|v| v.as_i64()); + let printer_name = printer + .get("printer_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + + if ui + .selectable_label( + self.options.printer_id == printer_id, + printer_name, + ) + .clicked() + { + self.options.printer_id = printer_id; + self.options.printer_name = printer_name.to_string(); + // Try to parse printer settings for preview (if provided by the DB row) + if let Some(ps_val) = printer.get("printer_settings") { + match parse_printer_settings(ps_val) { + Ok(ps) => { + self.os_base_settings = Some(ps); + } + Err(e) => { + log::warn!( + "Failed to parse printer_settings for preview: {}", + e + ); + self.os_base_settings = None; + } + } + } else { + self.os_base_settings = None; + } + } + } + }); + ui.end_row(); + + // Template selection + ui.label("Label Template:"); + egui::ComboBox::from_id_salt("template_select") + .selected_text(&self.options.label_template_name) + .width(300.0) + .show_ui(ui, |ui| { + for template in &self.templates { + let template_id = template.get("id").and_then(|v| v.as_i64()); + let template_name = template + .get("template_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + + if ui + .selectable_label( + self.options.label_template_id == template_id, + template_name, + ) + .clicked() + { + self.options.label_template_id = template_id; + self.options.label_template_name = template_name.to_string(); + + // Update renderer + if let Some(layout_json) = + template.get("layout_json").and_then(|v| v.as_str()) + { + match LabelRenderer::from_json(layout_json) { + Ok(renderer) => { + self.renderer = Some(renderer); + self.error_message = None; + } + Err(e) => { + log::warn!("Failed to parse label layout: {}", e); + self.error_message = + Some(format!("Invalid template: {}", e)); + self.renderer = None; + } + } + } + } + } + }); + ui.end_row(); + + // Number of copies + ui.label("Copies:"); + ui.add(egui::DragValue::new(&mut self.options.copies).range(1..=99)); + ui.end_row(); + }); + } + + /// Show preview section + fn show_preview(&mut self, ui: &mut egui::Ui) { + ui.add_space(8.0); + ui.heading("Preview"); + ui.add_space(8.0); + + // Preview scale control + ui.horizontal(|ui| { + ui.label("Scale:"); + ui.add(egui::Slider::new(&mut self.preview_scale, 2.0..=8.0).suffix("x")); + }); + + ui.add_space(8.0); + + // Render preview + if let Some(renderer) = &self.renderer { + egui::ScrollArea::both() // Enable both horizontal and vertical scrolling + .max_height(300.0) + .auto_shrink([false, false]) // Don't shrink in either direction + .show(ui, |ui| { + egui::Frame::new() + .fill(egui::Color32::from_gray(240)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_gray(200))) + .inner_margin(16.0) + .show(ui, |ui| { + if let Err(e) = renderer.render_preview( + ui, + &self.asset_data, + self.preview_scale, + self.os_base_settings.as_ref(), + ) { + ui.colored_label( + egui::Color32::RED, + format!("Preview error: {}", e), + ); + } + }); + }); + } else { + ui.colored_label( + egui::Color32::from_gray(150), + "Select a label template to see preview", + ); + } + } + + /// Get asset data reference + pub fn asset_data(&self) -> &HashMap<String, String> { + &self.asset_data + } + + /// Get current print options + pub fn options(&self) -> &PrintOptions { + &self.options + } + + /// Executes the actual PDF file saving. This is called after the promise resolves. + fn execute_pdf_export(&self, path: &PathBuf, api_client: &ApiClient) -> Result<()> { + let template_id = self + .options + .label_template_id + .ok_or_else(|| anyhow::anyhow!("No template selected"))?; + let printer_id = self + .options + .printer_id + .ok_or_else(|| anyhow::anyhow!("No printer selected"))?; + + // Fetch template + let template_resp = api_client.select( + "label_templates", + Some(vec!["layout_json".into()]), + Some(serde_json::json!({"id": template_id})), + None, + Some(1), + )?; + let template_data = template_resp + .data + .as_ref() + .and_then(|d| d.get(0)) + .ok_or_else(|| anyhow::anyhow!("Template not found"))?; + let layout_json = template_data + .get("layout_json") + .ok_or_else(|| anyhow::anyhow!("No layout JSON"))?; + let layout = parse_layout_json(layout_json)?; + + // Fetch printer settings + let printer_resp = api_client.select( + "printer_settings", + Some(vec!["printer_settings".into()]), + Some(serde_json::json!({"id": printer_id})), + None, + Some(1), + )?; + let printer_data = printer_resp + .data + .as_ref() + .and_then(|d| d.get(0)) + .ok_or_else(|| anyhow::anyhow!("Printer settings not found"))?; + let printer_settings_value = printer_data + .get("printer_settings") + .ok_or_else(|| anyhow::anyhow!("No printer settings JSON"))?; + let printer_settings = parse_printer_settings(printer_settings_value)?; + + // Generate and save PDF + let renderer = LabelRenderer::new(layout); + let (doc, _, _) = + renderer.generate_pdf_with_settings(&self.asset_data, &printer_settings)?; + let pdf_plugin = PdfPlugin::new(); + pdf_plugin.export_pdf(doc, path) + } + + /// Execute print job - handles all the loading, parsing, and printing. + /// Returns Ok(true) if the job is complete, Ok(false) if it's pending (e.g., PDF export). + pub fn execute_print(&mut self, api_client: &ApiClient) -> Result<bool> { + let printer_id = self + .options + .printer_id + .ok_or_else(|| anyhow::anyhow!("No printer selected"))?; + let template_id = self + .options + .label_template_id + .ok_or_else(|| anyhow::anyhow!("No template selected"))?; + + log::info!( + "Executing print: printer_id={}, template_id={}, copies={}", + printer_id, + template_id, + self.options.copies + ); + + // 1. Load printer settings and plugin info + let printer_resp = api_client.select( + "printer_settings", + Some(vec![ + "printer_name".into(), + "printer_settings".into(), + "printer_plugin".into(), + ]), + Some(serde_json::json!({ "id": printer_id })), + None, + Some(1), + )?; + + let printer_data = printer_resp + .data + .as_ref() + .and_then(|d| if !d.is_empty() { Some(d) } else { None }) + .ok_or_else(|| anyhow::anyhow!("Printer {} not found", printer_id))?; + + let printer_plugin = printer_data[0] + .get("printer_plugin") + .and_then(|v| v.as_str()) + .unwrap_or("System"); + + let printer_settings_value = printer_data[0] + .get("printer_settings") + .ok_or_else(|| anyhow::anyhow!("printer_settings field not found"))?; + let printer_settings = parse_printer_settings(printer_settings_value)?; + + // 2. Load label template layout + let template_resp = api_client.select( + "label_templates", + Some(vec!["layout_json".into()]), + Some(serde_json::json!({"id": template_id})), + None, + Some(1), + )?; + + let template_data = template_resp + .data + .as_ref() + .and_then(|d| if !d.is_empty() { Some(d) } else { None }) + .ok_or_else(|| anyhow::anyhow!("Label template {} not found", template_id))?; + + let layout_json_value = template_data[0] + .get("layout_json") + .ok_or_else(|| anyhow::anyhow!("layout_json field not found in template"))?; + let layout = parse_layout_json(layout_json_value)?; + + // 3. Dispatch to appropriate plugin based on the printer_plugin field + match printer_plugin { + "PDF" => { + // Use a promise to handle the blocking file dialog in a background thread + let promise = Promise::spawn_thread("pdf_export_dialog", || { + rfd::FileDialog::new() + .add_filter("PDF Document", &["pdf"]) + .set_file_name("label.pdf") + .save_file() + }); + self.pdf_export_promise = Some(promise); + } + "System" | _ => { + // Use SystemPrintPlugin for system printing + use crate::core::print::plugins::system::SystemPrintPlugin; + let renderer = LabelRenderer::new(layout); + let (doc, _, _) = + renderer.generate_pdf_with_settings(&self.asset_data, &printer_settings)?; + + let system_plugin = SystemPrintPlugin::new() + .map_err(|e| anyhow::anyhow!("Failed to initialize system print: {}", e))?; + + // Save PDF first since doc can't be cloned + let pdf_path = system_plugin.save_pdf_to_temp(doc)?; + + // Try direct print to named system printer if provided + if let Some(name) = printer_settings.printer_name.clone() { + let mgr = SharedPrinterManager::new(); + match mgr.print_pdf_to(&name, pdf_path.as_path(), Some(&printer_settings)) { + Ok(()) => { + return Ok(true); + } + Err(e) => { + log::warn!("Direct system print failed: {}", e); + let fallback = printer_settings.show_dialog_if_unfound.unwrap_or(true); + if fallback { + // Show OS printer chooser popup + self.os_print_path = Some(pdf_path); + self.os_popup_visible = true; + self.os_error_message = Some(format!( + "Named printer '{}' not found. Please select a system printer.", + name + )); + // Provide base settings and renderer so overrides can regenerate PDF + self.os_base_settings = Some(printer_settings.clone()); + self.os_renderer = Some(renderer.clone()); + self.os_size_override = PaperSizeOverride::UseSaved; + return Ok(false); + } else { + // Fallback to opening in viewer using SystemPrintPlugin + system_plugin.open_print_dialog(&pdf_path)?; + return Ok(true); + } + } + } + } else { + // No printer_name provided: either show chooser or open viewer + if printer_settings.show_dialog_if_unfound.unwrap_or(true) { + self.os_print_path = Some(pdf_path); + self.os_popup_visible = true; + self.os_error_message = None; + // Provide base settings and renderer so overrides can regenerate PDF + self.os_base_settings = Some(printer_settings.clone()); + self.os_renderer = Some(renderer.clone()); + self.os_size_override = PaperSizeOverride::UseSaved; + return Ok(false); + } else { + system_plugin.open_print_dialog(&pdf_path)?; + return Ok(true); + } + } + } + } + + log::info!("Print job for plugin '{}' dispatched.", printer_plugin); + Ok(false) // Dialog should remain open for PDF export + } + + /// Print via the OS popup selection with optional paper size overrides. + /// Returns Ok(true) if a job was sent, Err(message) on failure. + fn print_via_os_popup(&mut self, target_printer_name: &str) -> Result<bool, String> { + // Determine the PDF to print: reuse existing if no override, or regenerate if overridden + let (path_to_print, job_settings) = match self.os_size_override { + PaperSizeOverride::UseSaved => { + let mut settings = self + .os_base_settings + .clone() + .unwrap_or_else(|| PrinterSettings::default()); + settings.canonicalize_dimensions(); + let path = self + .os_print_path + .clone() + .ok_or_else(|| "No PDF available to print".to_string())?; + (path, settings) + } + PaperSizeOverride::A4 | PaperSizeOverride::Letter | PaperSizeOverride::Custom => { + let base = self + .os_base_settings + .clone() + .ok_or_else(|| "Missing base printer settings for override".to_string())?; + let renderer = self + .os_renderer + .clone() + .ok_or_else(|| "Missing renderer for override".to_string())?; + + let mut settings = base.clone(); + match self.os_size_override { + PaperSizeOverride::A4 => { + settings.paper_size = "A4".to_string(); + settings.custom_width_mm = None; + settings.custom_height_mm = None; + } + PaperSizeOverride::Letter => { + settings.paper_size = "Letter".to_string(); + settings.custom_width_mm = None; + settings.custom_height_mm = None; + } + PaperSizeOverride::Custom => { + let w = self.os_custom_width_mm.max(0.0); + let h = self.os_custom_height_mm.max(0.0); + if w <= 0.0 || h <= 0.0 { + return Err("Please enter a valid custom size in mm".into()); + } + settings.custom_width_mm = Some(w); + settings.custom_height_mm = Some(h); + } + PaperSizeOverride::UseSaved => unreachable!(), + } + + settings.canonicalize_dimensions(); + + // Regenerate the PDF with overridden settings + let (doc, _, _) = renderer + .generate_pdf_with_settings(&self.asset_data, &settings) + .map_err(|e| format!("Failed to generate PDF: {}", e))?; + let new_path = Self::save_pdf_to_temp(doc) + .map_err(|e| format!("Failed to save PDF: {}", e))?; + // Update stored state for potential re-prints + self.os_print_path = Some(new_path.clone()); + self.os_base_settings = Some(settings.clone()); + (new_path, settings) + } + }; + + // Send to the selected OS printer + let mgr = SharedPrinterManager::new(); + let job_settings_owned = job_settings; + let result = mgr.print_pdf_to( + target_printer_name, + path_to_print.as_path(), + Some(&job_settings_owned), + ); + + if result.is_ok() { + // Ensure latest settings persist for future retries when using saved path + self.os_base_settings = Some(job_settings_owned.clone()); + self.os_print_path = Some(path_to_print.clone()); + } + + result.map(|_| true) + } + + fn save_pdf_to_temp(doc: printpdf::PdfDocumentReference) -> Result<PathBuf> { + use anyhow::Context; + use std::fs::File; + use std::io::BufWriter; + let temp_dir = std::env::temp_dir().join("beepzone_labels"); + std::fs::create_dir_all(&temp_dir).context("Failed to create temp directory for labels")?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let pdf_path = temp_dir.join(format!("label_{}.pdf", timestamp)); + let file = File::create(&pdf_path).context("Failed to create temp PDF file")?; + let mut writer = BufWriter::new(file); + doc.save(&mut writer).context("Failed to save temp PDF")?; + Ok(pdf_path) + } +} |
