aboutsummaryrefslogtreecommitdiff
path: root/src/core/print
diff options
context:
space:
mode:
authorUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
committerUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
commit8323fdd73272a2882781aba3c499ba0be3dff2a6 (patch)
treeffbf86473933e69cfaeef30d5c6ea7e5b494856c /src/core/print
committing to insanityHEADmaster
Diffstat (limited to 'src/core/print')
-rw-r--r--src/core/print/mod.rs15
-rw-r--r--src/core/print/parsing.rs219
-rw-r--r--src/core/print/plugins/mod.rs2
-rw-r--r--src/core/print/plugins/pdf.rs27
-rw-r--r--src/core/print/plugins/system.rs49
-rw-r--r--src/core/print/printer_manager.rs228
-rw-r--r--src/core/print/renderer.rs1537
-rw-r--r--src/core/print/ui/mod.rs3
-rw-r--r--src/core/print/ui/print_dialog.rs999
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 { &current })
+ .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)
+ }
+}