diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index aff6a0d..7716dd1 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -13,7 +13,7 @@ on: jobs: build-windows: name: Build Windows - runs-on: windows-latest + runs-on: windows steps: - name: Checkout repository @@ -26,17 +26,25 @@ jobs: cache: 'npm' - name: Install Rust stable - uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable - targets: x86_64-pc-windows-msvc + shell: powershell + run: | + if (!(Get-Command rustup -ErrorAction SilentlyContinue) -and !(Test-Path "$env:USERPROFILE\.cargo\bin\rustup.exe")) { + Write-Host "Downloading rustup-init.exe..." + Invoke-WebRequest -Uri "https://win.rustup.rs/x86_64" -OutFile "$env:TEMP\rustup-init.exe" + & "$env:TEMP\rustup-init.exe" -y --default-toolchain stable --profile minimal + } + "$env:USERPROFILE\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + rustup default stable + rustup target add x86_64-pc-windows-msvc - name: Rust Cache uses: Swatinem/rust-cache@v2 + continue-on-error: true with: workspaces: "src-tauri -> target" - name: Check sidecar binaries + shell: powershell run: | if (!(Test-Path "src-tauri\argyll\windows-x86_64\argyll-instlist.exe")) { Write-Host "Warning: Windows ArgyllCMS binaries not found. Please ensure they are downloaded/committed to src-tauri/argyll/windows-x86_64 before release." @@ -47,14 +55,14 @@ jobs: - name: Get App Version id: get_version - shell: bash + shell: powershell run: | - VERSION=$(node -p "require('./package.json').version") - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - echo "APP_VERSION=${VERSION}" >> $GITHUB_ENV - else - echo "APP_VERSION=${VERSION}-dev" >> $GITHUB_ENV - fi + $VERSION = node -p "require('./package.json').version" + if ($env:GITHUB_REF -eq "refs/heads/main") { + "APP_VERSION=$VERSION" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + } else { + "APP_VERSION=$VERSION-dev" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + } - name: Build Tauri App run: npm run tauri build -- -v diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 83e5a5c..2ab6c9b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -109,16 +109,22 @@ pub struct TargenConfig { pub patch_count: u32, pub white_patches: Option, pub black_patches: Option, + pub total_patches: Option, pub basename: String, pub cwd: String, } +fn default_dpi() -> u32 { + 300 +} + #[derive(Debug, Deserialize, Serialize)] pub struct PrinttargConfig { pub instrument: String, // One of: "i1", "p3", "CM", "SS", "20", "22", "41", "51" pub page_size: String, // One of: "A4", "A4R", "A3", "A2", "Letter", "LetterR", "Legal", "4x6", "11x17", or "WWWxHHH" pub bit_depth: u8, // 8 or 16 - pub dpi: u32, // TIFF resolution, e.g. 100, 200, 300 + #[serde(default = "default_dpi")] + pub dpi: u32, // TIFF resolution, defaults to 300 DPI pub basename: String, // Must match the .ti1 basename from Stage 1 pub cwd: String, // Working directory where the .ti1 file resides } @@ -135,8 +141,10 @@ pub fn build_targen_args(config: &TargenConfig) -> Vec { args.push("2".to_string()); // Default to RGB } - args.push("-f".to_string()); - args.push(config.patch_count.to_string()); + if let Some(patches) = config.total_patches { + args.push("-f".to_string()); + args.push(patches.to_string()); + } if let Some(white) = config.white_patches { args.push("-e".to_string()); @@ -256,34 +264,48 @@ pub async fn run_chartread( #[derive(Debug, Deserialize, Serialize)] pub struct ColprofConfig { - pub quality: String, pub algorithm: String, - pub description: String, + pub quality: String, + pub intent: Option, pub copyright: Option, - pub basename: String, + pub description: Option, pub cwd: String, + pub ti3_path: String, + pub icc_path: String, } pub fn build_colprof_args(config: &ColprofConfig) -> Vec { let mut args = vec![ "-v".to_string(), - "-u".to_string(), - "-q".to_string(), - config.quality.clone(), "-a".to_string(), config.algorithm.clone(), - "-D".to_string(), - config.description.clone(), + "-q".to_string(), + config.quality.clone(), ]; - if let Some(copyright) = &config.copyright { - if !copyright.trim().is_empty() { - args.push("-C".to_string()); - args.push(copyright.clone()); + if let Some(ref intent) = config.intent { + if !intent.trim().is_empty() { + args.push("-p".to_string()); + args.push(intent.clone()); } } - args.push(config.basename.clone()); + if let Some(ref cr) = config.copyright { + if !cr.trim().is_empty() { + args.push("-C".to_string()); + args.push(cr.clone()); + } + } + + if let Some(ref desc) = config.description { + if !desc.trim().is_empty() { + args.push("-D".to_string()); + args.push(desc.clone()); + } + } + + args.push(config.ti3_path.clone()); + args.push(config.icc_path.clone()); args } @@ -295,7 +317,7 @@ pub async fn run_colprof( ) -> Result<(), String> { let binary = resolve_binary(app.clone(), "colprof".to_string()).await?; let args = build_colprof_args(&config); - let id = format!("colprof_{}", config.basename); + let id = format!("colprof_{}", config.icc_path); let cwd = if config.cwd.trim().is_empty() { None @@ -317,7 +339,7 @@ pub fn build_profcheck_args(config: &ProfcheckConfig) -> Vec { vec![ "-v".to_string(), "-k".to_string(), - "-u".to_string(), + "-s".to_string(), config.ti3_path.clone(), config.icc_path.clone(), ] @@ -355,14 +377,19 @@ pub async fn get_windows_printers() -> Result, String> { } #[tauri::command] -pub async fn print_target_windows(printer_name: String, tiff_path: String) -> Result<(), String> { +pub async fn print_target_windows( + state: State<'_, crate::print::PrinterDevModeStore>, + printer_name: String, + tiff_path: String, + options: Option, +) -> Result<(), String> { #[cfg(windows)] { - crate::print::windows::print_target(&printer_name, &tiff_path) + crate::print::windows::print_target(&printer_name, &tiff_path, options.as_ref(), Some(&state)) } #[cfg(not(windows))] { - let _ = (printer_name, tiff_path); + let _ = (state, printer_name, tiff_path, options); Err("Windows native printing is only supported on Windows.".to_string()) } } @@ -383,19 +410,19 @@ pub async fn get_cups_printers() -> Result, String> { pub async fn print_target_cups( printer_name: String, tiff_path: String, - ppd_uncorrected_passthrough: Option, + options: Option, ) -> Result<(), String> { #[cfg(unix)] { crate::print::unix::print_target( &printer_name, &tiff_path, - ppd_uncorrected_passthrough.unwrap_or(false), + options.as_ref(), ) } #[cfg(not(unix))] { - let _ = (printer_name, tiff_path, ppd_uncorrected_passthrough); + let _ = (printer_name, tiff_path, options); Err("CUPS printing is only supported on macOS and Linux.".to_string()) } } @@ -417,27 +444,73 @@ pub async fn get_printers() -> Result, String> { } #[tauri::command] -pub async fn print_target_native( +pub async fn get_printer_capabilities( printer_name: String, - tiff_path: String, - ppd_uncorrected_passthrough: Option, -) -> Result<(), String> { +) -> Result { #[cfg(windows)] { - let _ = ppd_uncorrected_passthrough; - crate::print::windows::print_target(&printer_name, &tiff_path) + crate::print::windows::get_printer_capabilities(&printer_name) } #[cfg(unix)] { + crate::print::unix::get_printer_capabilities(&printer_name) + } + #[cfg(not(any(windows, unix)))] + { + let _ = printer_name; + Err("Printer capabilities query is not supported on this platform.".to_string()) + } +} + +#[tauri::command] +pub async fn show_printer_properties( + state: State<'_, crate::print::PrinterDevModeStore>, + printer_name: String, +) -> Result<(), String> { + #[cfg(windows)] + { + crate::print::windows::show_printer_properties(&printer_name, &state) + } + #[cfg(unix)] + { + let _ = (state, printer_name); + Ok(()) + } + #[cfg(not(any(windows, unix)))] + { + let _ = (state, printer_name); + Err("Printer properties dialog is not supported on this platform.".to_string()) + } +} + +#[tauri::command] +pub async fn print_target_native( + state: State<'_, crate::print::PrinterDevModeStore>, + printer_name: String, + tiff_path: String, + options: Option, +) -> Result<(), String> { + #[cfg(windows)] + { + crate::print::windows::print_target( + &printer_name, + &tiff_path, + options.as_ref(), + Some(&state), + ) + } + #[cfg(unix)] + { + let _ = state; crate::print::unix::print_target( &printer_name, &tiff_path, - ppd_uncorrected_passthrough.unwrap_or(false), + options.as_ref(), ) } #[cfg(not(any(windows, unix)))] { - let _ = (printer_name, tiff_path, ppd_uncorrected_passthrough); + let _ = (state, printer_name, tiff_path, options); Err("Native raw printing is not supported on this platform.".to_string()) } } @@ -453,6 +526,7 @@ mod tests { patch_count: 800, white_patches: Some(4), black_patches: None, + total_patches: Some(800), basename: "my_profile".to_string(), cwd: "/tmp".to_string(), }; @@ -467,6 +541,7 @@ mod tests { patch_count: 1500, white_patches: None, black_patches: Some(8), + total_patches: Some(1500), basename: "cmyk_profile".to_string(), cwd: "/tmp".to_string(), }; @@ -531,15 +606,17 @@ mod tests { let config = ColprofConfig { quality: "h".to_string(), algorithm: "l".to_string(), - description: "My Profile".to_string(), + intent: None, + description: Some("My Profile".to_string()), copyright: Some("2026 ACME".to_string()), - basename: "my_profile".to_string(), + ti3_path: "my_profile.ti3".to_string(), + icc_path: "my_profile.icc".to_string(), cwd: "/home/user".to_string(), }; let args = build_colprof_args(&config); assert_eq!( args, - vec!["-v", "-u", "-q", "h", "-a", "l", "-D", "My Profile", "-C", "2026 ACME", "my_profile"] + vec!["-v", "-a", "l", "-q", "h", "-C", "2026 ACME", "-D", "My Profile", "my_profile.ti3", "my_profile.icc"] ); } @@ -551,6 +628,6 @@ mod tests { cwd: "/home/user".to_string(), }; let args = build_profcheck_args(&config); - assert_eq!(args, vec!["-v", "-k", "-u", "my_profile.ti3", "my_profile.icc"]); + assert_eq!(args, vec!["-v", "-k", "-s", "my_profile.ti3", "my_profile.icc"]); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2ea6023..abeb5de 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .manage(process_manager::ProcessManager::new()) + .manage(print::PrinterDevModeStore::new()) .invoke_handler(tauri::generate_handler![ commands::spawn_process, commands::get_app_info, @@ -29,6 +30,8 @@ pub fn run() { commands::get_cups_printers, commands::print_target_cups, commands::get_printers, + commands::get_printer_capabilities, + commands::show_printer_properties, commands::print_target_native, settings::load_settings, settings::save_settings, diff --git a/src-tauri/src/print/mod.rs b/src-tauri/src/print/mod.rs index 3a96deb..e8ec944 100644 --- a/src-tauri/src/print/mod.rs +++ b/src-tauri/src/print/mod.rs @@ -1,4 +1,6 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Printer { @@ -7,12 +9,62 @@ pub struct Printer { pub is_default: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PrinterTray { + pub id: u16, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PrinterPaperSize { + pub id: u16, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PrinterCapabilities { + pub trays: Vec, + pub paper_sizes: Vec, + pub supports_orientation: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct PrintOptions { + pub paper_source: Option, + pub orientation: Option, + pub paper_size: Option, + pub ppd_uncorrected_passthrough: Option, +} + +#[derive(Clone, Default)] +pub struct PrinterDevModeStore { + pub devmodes: Arc>>>, +} + +impl PrinterDevModeStore { + pub fn new() -> Self { + Self { + devmodes: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn get(&self, printer_name: &str) -> Option> { + let map = self.devmodes.lock().ok()?; + map.get(printer_name).cloned() + } + + pub fn set(&self, printer_name: &str, devmode: Vec) { + if let Ok(mut map) = self.devmodes.lock() { + map.insert(printer_name.to_string(), devmode); + } + } +} + #[cfg(windows)] pub mod windows; -#[cfg(unix)] +#[cfg(any(unix, test))] pub mod unix; #[cfg(test)] mod tests; - diff --git a/src-tauri/src/print/tests.rs b/src-tauri/src/print/tests.rs index 955c9b9..8cf24f5 100644 --- a/src-tauri/src/print/tests.rs +++ b/src-tauri/src/print/tests.rs @@ -1,6 +1,9 @@ #[cfg(test)] mod integration_tests { - use crate::print::Printer; + use crate::print::{ + PrintOptions, Printer, PrinterCapabilities, PrinterDevModeStore, PrinterPaperSize, + PrinterTray, + }; use crate::print::unix::{build_lp_args, parse_lpstat_d, parse_lpstat_e, parse_lpstat_p, print_target}; #[test] @@ -20,6 +23,98 @@ mod integration_tests { assert_eq!(deserialized, printer); } + #[test] + fn test_printer_capabilities_serialization() { + let caps = PrinterCapabilities { + trays: vec![ + PrinterTray { + id: 1, + name: "Auto Sheet Feeder".to_string(), + }, + PrinterTray { + id: 2, + name: "Rear Manual Feed".to_string(), + }, + ], + paper_sizes: vec![ + PrinterPaperSize { + id: 9, + name: "A4 (210 x 297 mm)".to_string(), + }, + PrinterPaperSize { + id: 1, + name: "Letter (8.5 x 11 in)".to_string(), + }, + ], + supports_orientation: true, + }; + + let json = serde_json::to_string(&caps).expect("Failed to serialize capabilities"); + assert!(json.contains("Auto Sheet Feeder")); + assert!(json.contains("Rear Manual Feed")); + assert!(json.contains("A4 (210 x 297 mm)")); + + let deserialized: PrinterCapabilities = + serde_json::from_str(&json).expect("Failed to deserialize capabilities"); + assert_eq!(deserialized, caps); + } + + #[test] + fn test_print_options_serialization() { + let opts = PrintOptions { + paper_source: Some(2), + orientation: Some("landscape".to_string()), + paper_size: Some("A4".to_string()), + ppd_uncorrected_passthrough: Some(false), + }; + + let json = serde_json::to_string(&opts).expect("Failed to serialize PrintOptions"); + let deserialized: PrintOptions = + serde_json::from_str(&json).expect("Failed to deserialize PrintOptions"); + assert_eq!(deserialized.paper_source, Some(2)); + assert_eq!(deserialized.orientation.as_deref(), Some("landscape")); + assert_eq!(deserialized.paper_size.as_deref(), Some("A4")); + } + + #[test] + fn test_devmode_store_concurrency() { + let store = PrinterDevModeStore::new(); + assert!(store.get("Epson-P900").is_none()); + + let fake_devmode = vec![1, 2, 3, 4, 5]; + store.set("Epson-P900", fake_devmode.clone()); + assert_eq!(store.get("Epson-P900"), Some(fake_devmode)); + } + + #[test] + fn test_auto_scaler_proportional_fit_math() { + // Page printable area: 4800 x 6800 device units + let page_w = 4800f64; + let page_h = 6800f64; + + // Image: 2400 x 3000 (aspect ratio 0.8) + let img_w = 2400f64; + let img_h = 3000f64; + + let scale_x = page_w / img_w; // 2.0 + let scale_y = page_h / img_h; // 2.2666 + let scale = scale_x.min(scale_y); // 2.0 + + let dest_w = (img_w * scale).floor() as i32; + let dest_h = (img_h * scale).floor() as i32; + + assert_eq!(dest_w, 4800); + assert_eq!(dest_h, 6000); + assert!(dest_w <= page_w as i32); + assert!(dest_h <= page_h as i32); + + let dest_x = (page_w as i32 - dest_w) / 2; + let dest_y = (page_h as i32 - dest_h) / 2; + + assert_eq!(dest_x, 0); + assert_eq!(dest_y, 400); // Vertically centered + } + #[test] fn test_lpstat_e_multiline_and_whitespace_handling() { let sample = " \n Epson_SureColor_P900 \n\n Canon_imagePROGRAF_PRO_1000 \n\n"; @@ -62,7 +157,7 @@ printer Custom_Queue unknown state\n\ for (_idx, page) in pages.iter().enumerate() { let path = format!("/tmp/profiling_run/{}", page); - let args_raw = build_lp_args(printer, &path, false); + let args_raw = build_lp_args(printer, &path, None); assert_eq!(args_raw[0], "-d"); assert_eq!(args_raw[1], printer); assert_eq!(args_raw[2], "-t"); @@ -71,7 +166,11 @@ printer Custom_Queue unknown state\n\ assert_eq!(args_raw[5], "raw"); assert_eq!(args_raw[6], path); - let args_fallback = build_lp_args(printer, &path, true); + let opts = PrintOptions { + ppd_uncorrected_passthrough: Some(true), + ..Default::default() + }; + let args_fallback = build_lp_args(printer, &path, Some(&opts)); assert_eq!(args_fallback[4], "-o"); assert_eq!(args_fallback[5], "ColorModel=Gray"); assert_eq!(args_fallback[6], "-o"); @@ -82,9 +181,10 @@ printer Custom_Queue unknown state\n\ #[test] fn test_print_target_nonexistent_file_handling() { - let res = print_target("Test_Printer", "/non/existent/path/target_page_99.tif", false); + let res = print_target("Test_Printer", "/non/existent/path/target_page_99.tif", None); assert!(res.is_err()); let err = res.unwrap_err(); assert!(err.contains("Target TIFF file not found")); } } + diff --git a/src-tauri/src/print/unix.rs b/src-tauri/src/print/unix.rs index 88e4f07..b13edd8 100644 --- a/src-tauri/src/print/unix.rs +++ b/src-tauri/src/print/unix.rs @@ -1,5 +1,9 @@ +#![allow(dead_code)] + use std::path::Path; -use crate::print::Printer; +use crate::print::{ + PrintOptions, Printer, PrinterCapabilities, PrinterPaperSize, PrinterTray, +}; /// Parse `lpstat -e` output into a list of printer destination names. pub fn parse_lpstat_e(output: &str) -> Vec { @@ -94,7 +98,6 @@ pub fn merge_printer_info( /// Query available CUPS printers, connection statuses, and default destination. pub fn get_printers() -> Result, String> { - // 1. Run lpstat -e let e_output = std::process::Command::new("lpstat") .arg("-e") .output(); @@ -105,7 +108,6 @@ pub fn get_printers() -> Result, String> { _ => Vec::new(), }; - // 2. Run lpstat -p let p_output = std::process::Command::new("lpstat") .arg("-p") .output(); @@ -121,7 +123,6 @@ pub fn get_printers() -> Result, String> { } }; - // 3. Run lpstat -d let d_output = std::process::Command::new("lpstat") .arg("-d") .output(); @@ -133,11 +134,69 @@ pub fn get_printers() -> Result, String> { Ok(merge_printer_info(&destinations, &statuses, default_dest.as_deref())) } -/// Construct `lp` command line arguments for target printing. +/// Parse `lpoptions -p -l` output into trays and paper sizes. +pub fn parse_lpoptions_l(output: &str) -> (Vec, Vec) { + let mut trays = Vec::new(); + let mut paper_sizes = Vec::new(); + + for line in output.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if let Some((key_part, values_part)) = trimmed.split_once(':') { + let key_name = key_part.split('/').next().unwrap_or("").trim(); + let values = values_part.split_whitespace(); + + if key_name.eq_ignore_ascii_case("InputSlot") || key_name.eq_ignore_ascii_case("MediaSource") { + for (idx, val) in values.enumerate() { + let clean_val = val.trim_start_matches('*'); + trays.push(PrinterTray { + id: (idx + 1) as u16, + name: clean_val.to_string(), + }); + } + } else if key_name.eq_ignore_ascii_case("PageSize") || key_name.eq_ignore_ascii_case("MediaSize") { + for (idx, val) in values.enumerate() { + let clean_val = val.trim_start_matches('*'); + paper_sizes.push(PrinterPaperSize { + id: (idx + 1) as u16, + name: clean_val.to_string(), + }); + } + } + } + } + + (trays, paper_sizes) +} + +/// Query CUPS printer capabilities via `lpoptions -p -l`. +pub fn get_printer_capabilities(printer_name: &str) -> Result { + let output = std::process::Command::new("lpoptions") + .args(["-p", printer_name, "-l"]) + .output(); + + let (trays, paper_sizes) = match output { + Ok(out) if out.status.success() => { + parse_lpoptions_l(&String::from_utf8_lossy(&out.stdout)) + } + _ => (Vec::new(), Vec::new()), + }; + + Ok(PrinterCapabilities { + trays, + paper_sizes, + supports_orientation: true, + }) +} + +/// Construct `lp` command line arguments for target printing with options. pub fn build_lp_args( printer_name: &str, tiff_path: &str, - ppd_uncorrected_passthrough: bool, + options: Option<&PrintOptions>, ) -> Vec { let path = Path::new(tiff_path); let title = format!( @@ -152,7 +211,11 @@ pub fn build_lp_args( title, ]; - if ppd_uncorrected_passthrough { + let ppd_fallback = options + .and_then(|o| o.ppd_uncorrected_passthrough) + .unwrap_or(false); + + if ppd_fallback { args.push("-o".to_string()); args.push("ColorModel=Gray".to_string()); args.push("-o".to_string()); @@ -162,6 +225,24 @@ pub fn build_lp_args( args.push("raw".to_string()); } + if let Some(opts) = options { + if let Some(ref orient) = opts.orientation { + args.push("-o".to_string()); + if orient.eq_ignore_ascii_case("landscape") { + args.push("orientation-requested=4".to_string()); + } else { + args.push("orientation-requested=3".to_string()); + } + } + + if let Some(ref page_size) = opts.paper_size { + if !page_size.trim().is_empty() { + args.push("-o".to_string()); + args.push(format!("PageSize={}", page_size.trim())); + } + } + } + args.push(tiff_path.to_string()); args } @@ -170,14 +251,14 @@ pub fn build_lp_args( pub fn print_target( printer_name: &str, tiff_path: &str, - ppd_uncorrected_passthrough: bool, + options: Option<&PrintOptions>, ) -> Result<(), String> { let path = Path::new(tiff_path); if !path.exists() { return Err(format!("Target TIFF file not found: {}", tiff_path)); } - let args = build_lp_args(printer_name, tiff_path, ppd_uncorrected_passthrough); + let args = build_lp_args(printer_name, tiff_path, options); let output = std::process::Command::new("lp") .args(&args) @@ -200,6 +281,7 @@ pub fn print_target( Ok(()) } + #[cfg(test)] mod tests { use super::*; @@ -295,9 +377,27 @@ printer Zebra_Label disabled since Wed 10 Jun 2026 - reason: out of ribbon\n\ ); } + #[test] + fn test_parse_lpoptions_l() { + let sample = "\ +PageSize/Media Size: *A4 Letter Legal A3\n\ +InputSlot/Media Source: *Auto Upper Lower Rear Manual\n\ +Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\ +"; + let (trays, paper_sizes) = parse_lpoptions_l(sample); + assert_eq!(trays.len(), 5); + assert_eq!(trays[0].name, "Auto"); + assert_eq!(trays[1].name, "Upper"); + assert_eq!(trays[4].name, "Manual"); + + assert_eq!(paper_sizes.len(), 4); + assert_eq!(paper_sizes[0].name, "A4"); + assert_eq!(paper_sizes[1].name, "Letter"); + } + #[test] fn test_build_lp_args_raw() { - let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", false); + let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", None); assert_eq!( args, vec![ @@ -314,7 +414,11 @@ printer Zebra_Label disabled since Wed 10 Jun 2026 - reason: out of ribbon\n\ #[test] fn test_build_lp_args_ppd_fallback() { - let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", true); + let opts = PrintOptions { + ppd_uncorrected_passthrough: Some(true), + ..Default::default() + }; + let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", Some(&opts)); assert_eq!( args, vec![ @@ -331,9 +435,36 @@ printer Zebra_Label disabled since Wed 10 Jun 2026 - reason: out of ribbon\n\ ); } + #[test] + fn test_build_lp_args_with_orientation_and_size() { + let opts = PrintOptions { + orientation: Some("landscape".to_string()), + paper_size: Some("A4".to_string()), + ppd_uncorrected_passthrough: Some(false), + ..Default::default() + }; + let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", Some(&opts)); + assert_eq!( + args, + vec![ + "-d", + "Epson-Stylus-SX420W", + "-t", + "ICCery Target - target.tif", + "-o", + "raw", + "-o", + "orientation-requested=4", + "-o", + "PageSize=A4", + "/tmp/target.tif" + ] + ); + } + #[test] fn test_print_target_file_not_found() { - let res = print_target("Epson-Stylus-SX420W", "/non/existent/target.tif", false); + let res = print_target("Epson-Stylus-SX420W", "/non/existent/target.tif", None); assert!(res.is_err()); assert!(res.unwrap_err().contains("not found")); } diff --git a/src-tauri/src/print/windows.rs b/src-tauri/src/print/windows.rs index a7fa166..2d0328d 100644 --- a/src-tauri/src/print/windows.rs +++ b/src-tauri/src/print/windows.rs @@ -1,3 +1,5 @@ +#![allow(non_snake_case, dead_code)] + use std::ffi::{c_void, OsStr}; use std::os::windows::ffi::OsStrExt; use std::path::Path; @@ -12,8 +14,11 @@ use windows::Win32::Graphics::Printing::{ ClosePrinter, DocumentPropertiesW, EnumPrintersW, OpenPrinterW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, PRINTER_INFO_1W, PRINTER_INFO_4W, }; +use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; -use crate::print::Printer; +use crate::print::{ + PrintOptions, Printer, PrinterCapabilities, PrinterDevModeStore, PrinterPaperSize, PrinterTray, +}; #[repr(C)] struct DOCINFOW { @@ -30,17 +35,41 @@ extern "system" { fn StartPage(hdc: HDC) -> i32; fn EndPage(hdc: HDC) -> i32; fn EndDoc(hdc: HDC) -> i32; + fn DeviceCapabilitiesW( + pdevicename: PCWSTR, + pport: PCWSTR, + fwcapability: u16, + poutput: *mut u16, + pdevmode: *const DEVMODEW, + ) -> i32; } const ICM_OFF: i32 = 1; +const DM_IN_PROMPT: u32 = 4; +const DM_IN_BUFFER: u32 = 8; const DM_OUT_BUFFER: u32 = 2; +const DM_ORIENTATION: u32 = 0x00000001; +const DM_DEFAULTSOURCE: u32 = 0x00000200; const DM_ICMMETHOD: u32 = 0x00800000; const DMICMMETHOD_NONE: u32 = 1; +const DMORIENT_PORTRAIT: i16 = 1; +const DMORIENT_LANDSCAPE: i16 = 2; + +const DC_PAPERS: u16 = 2; +const DC_BINS: u16 = 6; +const DC_BINNAMES: u16 = 12; +const DC_PAPERNAMES: u16 = 16; +const IDOK: i32 = 1; fn to_wide(s: &str) -> Vec { OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect() } +fn extract_null_terminated_string(wide_slice: &[u16]) -> String { + let len = wide_slice.iter().position(|&c| c == 0).unwrap_or(wide_slice.len()); + String::from_utf16_lossy(&wide_slice[..len]).trim().to_string() +} + /// Enumerate available printers on the system pub fn get_printers() -> Result, String> { unsafe { @@ -48,7 +77,6 @@ pub fn get_printers() -> Result, String> { let mut bytes_needed = 0u32; let mut count = 0u32; - // Query buffer size using Level 4 (fast local & network connections) let _ = EnumPrintersW( flags, PCWSTR::null(), @@ -59,7 +87,6 @@ pub fn get_printers() -> Result, String> { ); if bytes_needed == 0 { - // Fallback to Level 1 let _ = EnumPrintersW( flags, PCWSTR::null(), @@ -134,8 +161,197 @@ pub fn get_printers() -> Result, String> { } } -/// Print target TIFF file to printer with GDI ICM bypass and DIB drawing -pub fn print_target(printer_name: &str, tiff_path: &str) -> Result<(), String> { +/// Query hardware capabilities (paper trays and paper sizes) for a given printer +pub fn get_printer_capabilities(printer_name: &str) -> Result { + let printer_wide = to_wide(printer_name); + + unsafe { + // Query trays (bins) + let num_bins = DeviceCapabilitiesW( + PCWSTR(printer_wide.as_ptr()), + PCWSTR::null(), + DC_BINS, + std::ptr::null_mut(), + std::ptr::null(), + ); + + let mut trays = Vec::new(); + if num_bins > 0 { + let mut bin_ids = vec![0u16; num_bins as usize]; + let mut bin_names_raw = vec![0u16; num_bins as usize * 24]; + + let res_ids = DeviceCapabilitiesW( + PCWSTR(printer_wide.as_ptr()), + PCWSTR::null(), + DC_BINS, + bin_ids.as_mut_ptr(), + std::ptr::null(), + ); + + let res_names = DeviceCapabilitiesW( + PCWSTR(printer_wide.as_ptr()), + PCWSTR::null(), + DC_BINNAMES, + bin_names_raw.as_mut_ptr(), + std::ptr::null(), + ); + + if res_ids > 0 && res_names > 0 { + for i in 0..num_bins as usize { + let id = bin_ids[i]; + let name_slice = &bin_names_raw[i * 24..(i + 1) * 24]; + let name = extract_null_terminated_string(name_slice); + let display_name = if name.is_empty() { + format!("Tray {}", id) + } else { + name + }; + trays.push(PrinterTray { + id, + name: display_name, + }); + } + } + } + + // Query paper sizes + let num_papers = DeviceCapabilitiesW( + PCWSTR(printer_wide.as_ptr()), + PCWSTR::null(), + DC_PAPERS, + std::ptr::null_mut(), + std::ptr::null(), + ); + + let mut paper_sizes = Vec::new(); + if num_papers > 0 { + let mut paper_ids = vec![0u16; num_papers as usize]; + let mut paper_names_raw = vec![0u16; num_papers as usize * 64]; + + let res_ids = DeviceCapabilitiesW( + PCWSTR(printer_wide.as_ptr()), + PCWSTR::null(), + DC_PAPERS, + paper_ids.as_mut_ptr(), + std::ptr::null(), + ); + + let res_names = DeviceCapabilitiesW( + PCWSTR(printer_wide.as_ptr()), + PCWSTR::null(), + DC_PAPERNAMES, + paper_names_raw.as_mut_ptr(), + std::ptr::null(), + ); + + if res_ids > 0 && res_names > 0 { + for i in 0..num_papers as usize { + let id = paper_ids[i]; + let name_slice = &paper_names_raw[i * 64..(i + 1) * 64]; + let name = extract_null_terminated_string(name_slice); + let display_name = if name.is_empty() { + format!("Paper Size {}", id) + } else { + name + }; + paper_sizes.push(PrinterPaperSize { + id, + name: display_name, + }); + } + } + } + + Ok(PrinterCapabilities { + trays, + paper_sizes, + supports_orientation: true, + }) + } +} + +/// Open the native modal Printer Properties / Preferences dialog and retain DEVMODE changes +pub fn show_printer_properties( + printer_name: &str, + devmode_store: &PrinterDevModeStore, +) -> Result<(), String> { + let printer_wide = to_wide(printer_name); + + unsafe { + let mut h_printer = HANDLE::default(); + let open_res = OpenPrinterW(PCWSTR(printer_wide.as_ptr()), &mut h_printer, None); + if open_res.is_err() || h_printer.is_invalid() { + return Err(format!("Failed to open printer '{}'", printer_name)); + } + + let parent_hwnd = GetForegroundWindow(); + + let devmode_size = DocumentPropertiesW( + parent_hwnd, + h_printer, + PCWSTR(printer_wide.as_ptr()), + None, + None, + 0, + ); + + if devmode_size <= 0 { + let _ = ClosePrinter(h_printer); + return Err("Failed to query DEVMODE size for printer properties".to_string()); + } + + let mut in_buf = devmode_store + .get(printer_name) + .unwrap_or_else(|| vec![0u8; devmode_size as usize]); + + if in_buf.len() < devmode_size as usize { + in_buf.resize(devmode_size as usize, 0); + } + + // Populate initial DEVMODE if empty + if in_buf.iter().all(|&b| b == 0) { + let _ = DocumentPropertiesW( + parent_hwnd, + h_printer, + PCWSTR(printer_wide.as_ptr()), + Some(in_buf.as_mut_ptr() as *mut DEVMODEW), + None, + DM_OUT_BUFFER, + ); + } + + let mut out_buf = vec![0u8; devmode_size as usize]; + + let res = DocumentPropertiesW( + parent_hwnd, + h_printer, + PCWSTR(printer_wide.as_ptr()), + Some(out_buf.as_mut_ptr() as *mut DEVMODEW), + Some(in_buf.as_ptr() as *const DEVMODEW), + DM_IN_PROMPT | DM_IN_BUFFER | DM_OUT_BUFFER, + ); + + let _ = ClosePrinter(h_printer); + + if res == IDOK { + devmode_store.set(printer_name, out_buf); + Ok(()) + } else if res == 2 { + // Cancelled by user + Ok(()) + } else { + Err("Printer properties dialog was dismissed or encountered an error".to_string()) + } + } +} + +/// Print target TIFF file to printer with GDI ICM bypass, DEVMODE overrides, and auto-fit scaling +pub fn print_target( + printer_name: &str, + tiff_path: &str, + options: Option<&PrintOptions>, + devmode_store: Option<&PrinterDevModeStore>, +) -> Result<(), String> { let path = Path::new(tiff_path); if !path.exists() { return Err(format!("Target TIFF file not found: {}", tiff_path)); @@ -166,7 +382,10 @@ pub fn print_target(printer_name: &str, tiff_path: &str) -> Result<(), String> { } let printer_wide = to_wide(printer_name); - let doc_name = format!("ICCery Target - {}", path.file_name().and_then(|n| n.to_str()).unwrap_or("Profiling Target")); + let doc_name = format!( + "ICCery Target - {}", + path.file_name().and_then(|n| n.to_str()).unwrap_or("Profiling Target") + ); let doc_name_wide = to_wide(&doc_name); unsafe { @@ -177,7 +396,7 @@ pub fn print_target(printer_name: &str, tiff_path: &str) -> Result<(), String> { return Err(format!("Failed to open printer '{}'", printer_name)); } - // Configure DEVMODE if possible + // Configure DEVMODE let devmode_size = DocumentPropertiesW( HWND::default(), h_printer, @@ -188,7 +407,14 @@ pub fn print_target(printer_name: &str, tiff_path: &str) -> Result<(), String> { ); let mut devmode_buf = if devmode_size > 0 { - let mut buf = vec![0u8; devmode_size as usize]; + let mut buf = devmode_store + .and_then(|s| s.get(printer_name)) + .unwrap_or_else(|| vec![0u8; devmode_size as usize]); + + if buf.len() < devmode_size as usize { + buf.resize(devmode_size as usize, 0); + } + let p_devmode = buf.as_mut_ptr() as *mut DEVMODEW; let res = DocumentPropertiesW( HWND::default(), @@ -198,10 +424,29 @@ pub fn print_target(printer_name: &str, tiff_path: &str) -> Result<(), String> { None, DM_OUT_BUFFER, ); + if res >= 0 { - // Attempt to explicitly disable driver ICM if supported in DEVMODE + // Apply strict ICM bypass (*p_devmode).dmFields |= DEVMODE_FIELD_FLAGS(DM_ICMMETHOD); (*p_devmode).dmICMMethod = DMICMMETHOD_NONE; + + // Apply UI options if specified + if let Some(opts) = options { + if let Some(tray_id) = opts.paper_source { + (*p_devmode).dmFields |= DEVMODE_FIELD_FLAGS(DM_DEFAULTSOURCE); + (*p_devmode).Anonymous1.Anonymous1.dmDefaultSource = tray_id as i16; + } + + if let Some(ref orient) = opts.orientation { + (*p_devmode).dmFields |= DEVMODE_FIELD_FLAGS(DM_ORIENTATION); + if orient.eq_ignore_ascii_case("landscape") { + (*p_devmode).Anonymous1.Anonymous1.dmOrientation = DMORIENT_LANDSCAPE; + } else { + (*p_devmode).Anonymous1.Anonymous1.dmOrientation = DMORIENT_PORTRAIT; + } + } + } + Some(buf) } else { None @@ -255,25 +500,20 @@ pub fn print_target(printer_name: &str, tiff_path: &str) -> Result<(), String> { return Err("Failed to start print page (StartPage)".to_string()); } - // Query DC dimensions and DPI + // Query printable device dimensions let dpi_x = GetDeviceCaps(hdc, LOGPIXELSX); let dpi_y = GetDeviceCaps(hdc, LOGPIXELSY); let page_width = GetDeviceCaps(hdc, HORZRES); let page_height = GetDeviceCaps(hdc, VERTRES); - // Calculate destination dimensions assuming standard 300 DPI target - let target_dpi = 300.0f64; - let mut dest_width = ((img_width as f64 / target_dpi) * dpi_x as f64).round() as i32; - let mut dest_height = ((img_height as f64 / target_dpi) * dpi_y as f64).round() as i32; + // AUTOMATIC BEHIND-THE-SCENES PAGE-FIT SCALER: + // Proportionally scale the target TIFF to fill the available physical printable area + let scale_x = page_width as f64 / img_width as f64; + let scale_y = page_height as f64 / img_height as f64; + let scale = scale_x.min(scale_y); - // If target exceeds page bounds, scale proportionally to fit printable area - if dest_width > page_width || dest_height > page_height { - let scale_x = page_width as f64 / dest_width as f64; - let scale_y = page_height as f64 / dest_height as f64; - let scale = scale_x.min(scale_y); - dest_width = (dest_width as f64 * scale).round() as i32; - dest_height = (dest_height as f64 * scale).round() as i32; - } + let dest_width = (img_width as f64 * scale).floor() as i32; + let dest_height = (img_height as f64 * scale).floor() as i32; let dest_x = (page_width - dest_width).max(0) / 2; let dest_y = (page_height - dest_height).max(0) / 2; diff --git a/src/index.html b/src/index.html index d895838..d63eaf4 100644 --- a/src/index.html +++ b/src/index.html @@ -166,13 +166,11 @@
- - + +
+ + 300 DPI Auto-Fit Scaler (Active) +
@@ -219,6 +217,7 @@ + @@ -231,6 +230,24 @@ + +
+
+ + +
+ +
+ +
+ + +
+
+
+