feat(print): Enhanced Print Target Settings & Auto-Fitting Scaler (#34) #35
@@ -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
|
||||
|
||||
+113
-36
@@ -109,16 +109,22 @@ pub struct TargenConfig {
|
||||
pub patch_count: u32,
|
||||
pub white_patches: Option<u32>,
|
||||
pub black_patches: Option<u32>,
|
||||
pub total_patches: Option<u32>,
|
||||
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<String> {
|
||||
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<String>,
|
||||
pub copyright: Option<String>,
|
||||
pub basename: String,
|
||||
pub description: Option<String>,
|
||||
pub cwd: String,
|
||||
pub ti3_path: String,
|
||||
pub icc_path: String,
|
||||
}
|
||||
|
||||
pub fn build_colprof_args(config: &ColprofConfig) -> Vec<String> {
|
||||
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<String> {
|
||||
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<Vec<String>, 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<crate::print::PrintOptions>,
|
||||
) -> 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<Vec<crate::print::Printer>, String> {
|
||||
pub async fn print_target_cups(
|
||||
printer_name: String,
|
||||
tiff_path: String,
|
||||
ppd_uncorrected_passthrough: Option<bool>,
|
||||
options: Option<crate::print::PrintOptions>,
|
||||
) -> 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<Vec<crate::print::Printer>, 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<bool>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<crate::print::PrinterCapabilities, String> {
|
||||
#[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<crate::print::PrintOptions>,
|
||||
) -> 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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PrinterTray>,
|
||||
pub paper_sizes: Vec<PrinterPaperSize>,
|
||||
pub supports_orientation: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct PrintOptions {
|
||||
pub paper_source: Option<u16>,
|
||||
pub orientation: Option<String>,
|
||||
pub paper_size: Option<String>,
|
||||
pub ppd_uncorrected_passthrough: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PrinterDevModeStore {
|
||||
pub devmodes: Arc<Mutex<HashMap<String, Vec<u8>>>>,
|
||||
}
|
||||
|
||||
impl PrinterDevModeStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
devmodes: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, printer_name: &str) -> Option<Vec<u8>> {
|
||||
let map = self.devmodes.lock().ok()?;
|
||||
map.get(printer_name).cloned()
|
||||
}
|
||||
|
||||
pub fn set(&self, printer_name: &str, devmode: Vec<u8>) {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+143
-12
@@ -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<String> {
|
||||
@@ -94,7 +98,6 @@ pub fn merge_printer_info(
|
||||
|
||||
/// Query available CUPS printers, connection statuses, and default destination.
|
||||
pub fn get_printers() -> Result<Vec<Printer>, 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<Vec<Printer>, 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<Vec<Printer>, 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<Vec<Printer>, String> {
|
||||
Ok(merge_printer_info(&destinations, &statuses, default_dest.as_deref()))
|
||||
}
|
||||
|
||||
/// Construct `lp` command line arguments for target printing.
|
||||
/// Parse `lpoptions -p <printer> -l` output into trays and paper sizes.
|
||||
pub fn parse_lpoptions_l(output: &str) -> (Vec<PrinterTray>, Vec<PrinterPaperSize>) {
|
||||
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 <printer> -l`.
|
||||
pub fn get_printer_capabilities(printer_name: &str) -> Result<PrinterCapabilities, String> {
|
||||
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<String> {
|
||||
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"));
|
||||
}
|
||||
|
||||
+262
-22
@@ -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<u16> {
|
||||
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<Vec<Printer>, String> {
|
||||
unsafe {
|
||||
@@ -48,7 +77,6 @@ pub fn get_printers() -> Result<Vec<Printer>, 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<Vec<Printer>, String> {
|
||||
);
|
||||
|
||||
if bytes_needed == 0 {
|
||||
// Fallback to Level 1
|
||||
let _ = EnumPrintersW(
|
||||
flags,
|
||||
PCWSTR::null(),
|
||||
@@ -134,8 +161,197 @@ pub fn get_printers() -> Result<Vec<Printer>, 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<PrinterCapabilities, String> {
|
||||
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;
|
||||
|
||||
+24
-7
@@ -166,13 +166,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tiffDpi" class="sub-label">Resolution (DPI)</label>
|
||||
<select id="tiffDpi">
|
||||
<option value="100">100 DPI (Draft)</option>
|
||||
<option value="200" selected>200 DPI (Standard)</option>
|
||||
<option value="300">300 DPI (High Quality)</option>
|
||||
<option value="600">600 DPI (Ultra)</option>
|
||||
</select>
|
||||
<label class="sub-label">Automatic Fit Scaling</label>
|
||||
<div class="auto-scaler-badge" title="Master 300 DPI high-resolution target is automatically scaled to fit the device printable area without distortion or clipping">
|
||||
<span class="badge-icon">⚡</span>
|
||||
<span>300 DPI Auto-Fit Scaler (Active)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,6 +217,7 @@
|
||||
<option value="" disabled selected>Detecting printers...</option>
|
||||
</select>
|
||||
<button type="button" class="btn-icon" id="btnRefreshPrinters" title="Refresh printer list">↻</button>
|
||||
<button type="button" class="btn-properties" id="btnPrinterProperties" title="Open native printer driver preferences (disable colour management, set media type, etc.)">⚙️ Preferences</button>
|
||||
<span id="printerStatusBadge" class="status-badge badge-idle hidden">Idle</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,6 +230,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media & Print Job Configuration Row -->
|
||||
<div class="printer-media-row">
|
||||
<div class="form-group media-group">
|
||||
<label for="printerTraySelect">Paper Source / Tray</label>
|
||||
<select id="printerTraySelect">
|
||||
<option value="" selected>Default / Auto Select</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group orientation-group">
|
||||
<label>Orientation</label>
|
||||
<div class="orientation-toggle-group">
|
||||
<button type="button" class="btn-toggle active" id="btnOrientPortrait" data-orient="portrait" title="Portrait orientation">📄 Portrait</button>
|
||||
<button type="button" class="btn-toggle" id="btnOrientLandscape" data-orient="landscape" title="Landscape orientation">📄 Landscape</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="print-actions-row">
|
||||
<button class="primary btn-print" id="btnPrintAll">
|
||||
<span class="btn-text">🖨️ Print All Pages (Bypass CM)</span>
|
||||
|
||||
+90
-6
@@ -36,6 +36,10 @@ export function initPrinttarg() {
|
||||
const rawPrintPanel = document.getElementById("rawPrintPanel");
|
||||
const printerSelect = document.getElementById("printerSelect");
|
||||
const btnRefreshPrinters = document.getElementById("btnRefreshPrinters");
|
||||
const btnPrinterProperties = document.getElementById("btnPrinterProperties");
|
||||
const printerTraySelect = document.getElementById("printerTraySelect");
|
||||
const btnOrientPortrait = document.getElementById("btnOrientPortrait");
|
||||
const btnOrientLandscape = document.getElementById("btnOrientLandscape");
|
||||
const printerStatusBadge = document.getElementById("printerStatusBadge");
|
||||
const chkPpdFallback = document.getElementById("chkPpdFallback");
|
||||
const btnPrintAll = document.getElementById("btnPrintAll");
|
||||
@@ -44,6 +48,23 @@ export function initPrinttarg() {
|
||||
const printNotificationIcon = document.getElementById("printNotificationIcon");
|
||||
const printNotificationText = document.getElementById("printNotificationText");
|
||||
|
||||
let selectedOrientation = "portrait";
|
||||
|
||||
// Orientation toggle buttons
|
||||
if (btnOrientPortrait && btnOrientLandscape) {
|
||||
btnOrientPortrait.addEventListener("click", () => {
|
||||
selectedOrientation = "portrait";
|
||||
btnOrientPortrait.classList.add("active");
|
||||
btnOrientLandscape.classList.remove("active");
|
||||
});
|
||||
|
||||
btnOrientLandscape.addEventListener("click", () => {
|
||||
selectedOrientation = "landscape";
|
||||
btnOrientLandscape.classList.add("active");
|
||||
btnOrientPortrait.classList.remove("active");
|
||||
});
|
||||
}
|
||||
|
||||
// Show/hide custom page size inputs
|
||||
pageSizeSelect.addEventListener("change", (e) => {
|
||||
if (e.target.value === "custom") {
|
||||
@@ -78,6 +99,44 @@ export function initPrinttarg() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch hardware trays and paper capabilities for selected printer.
|
||||
*/
|
||||
async function loadPrinterCapabilities(printerName) {
|
||||
if (!printerTraySelect || !printerName) return;
|
||||
printerTraySelect.innerHTML = '<option value="" selected>Default / Auto Select</option>';
|
||||
|
||||
try {
|
||||
const caps = await invoke("get_printer_capabilities", { printerName });
|
||||
if (caps && caps.trays && caps.trays.length > 0) {
|
||||
caps.trays.forEach(tray => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = tray.id;
|
||||
opt.textContent = tray.name;
|
||||
printerTraySelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[ICCery Print] Could not fetch printer capabilities:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build current print options payload from UI controls.
|
||||
*/
|
||||
function getSelectedPrintOptions() {
|
||||
const trayVal = printerTraySelect ? printerTraySelect.value : "";
|
||||
const paperSource = trayVal ? parseInt(trayVal, 10) : null;
|
||||
const ppdFallback = chkPpdFallback ? chkPpdFallback.checked : false;
|
||||
|
||||
return {
|
||||
paper_source: paperSource,
|
||||
orientation: selectedOrientation,
|
||||
paper_size: pageSizeSelect ? pageSizeSelect.value : null,
|
||||
ppd_uncorrected_passthrough: ppdFallback,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the badge reflecting current printer's status.
|
||||
*/
|
||||
@@ -145,6 +204,11 @@ export function initPrinttarg() {
|
||||
printerSelect.selectedIndex = 0;
|
||||
}
|
||||
|
||||
const activePrinter = printerSelect.value;
|
||||
if (activePrinter) {
|
||||
loadPrinterCapabilities(activePrinter);
|
||||
}
|
||||
|
||||
if (btnPrintAll && currentManifest) btnPrintAll.disabled = false;
|
||||
updatePrinterStatusBadge();
|
||||
|
||||
@@ -160,6 +224,7 @@ export function initPrinttarg() {
|
||||
if (printerSelect) {
|
||||
printerSelect.addEventListener("change", () => {
|
||||
updatePrinterStatusBadge();
|
||||
loadPrinterCapabilities(printerSelect.value);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,6 +234,25 @@ export function initPrinttarg() {
|
||||
});
|
||||
}
|
||||
|
||||
if (btnPrinterProperties) {
|
||||
btnPrinterProperties.addEventListener("click", async () => {
|
||||
const printerName = printerSelect ? printerSelect.value : "";
|
||||
if (!printerName) {
|
||||
showNotification("error", "Please select a destination printer first.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showNotification("info", `Opening native printer preferences for '${printerName}'...`);
|
||||
await invoke("show_printer_properties", { printerName });
|
||||
showNotification("success", `✓ Printer driver preferences configured for '${printerName}'.`);
|
||||
} catch (err) {
|
||||
console.error("[ICCery Print] Failed to open printer properties:", err);
|
||||
showNotification("error", `Could not open printer properties: ${err}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load initial printer list on startup
|
||||
loadPrinters();
|
||||
|
||||
@@ -212,7 +296,7 @@ export function initPrinttarg() {
|
||||
instrument: instrumentSelect.value,
|
||||
page_size: pageSize,
|
||||
bit_depth: bitDepth,
|
||||
dpi: parseInt(tiffDpi.value, 10),
|
||||
dpi: 300, // Master high-resolution layout scaled automatically to device bounds
|
||||
basename: stage1Basename,
|
||||
cwd: stage1Cwd,
|
||||
};
|
||||
@@ -274,7 +358,7 @@ export function initPrinttarg() {
|
||||
});
|
||||
|
||||
/**
|
||||
* Spool a single TIFF target file with native color management bypass.
|
||||
* Spool a single TIFF target file with native color management bypass and options.
|
||||
*/
|
||||
async function printTargetFile(filePath, label, triggeringButton) {
|
||||
const printerName = printerSelect ? printerSelect.value : "";
|
||||
@@ -283,7 +367,7 @@ export function initPrinttarg() {
|
||||
return;
|
||||
}
|
||||
|
||||
const ppdFallback = chkPpdFallback ? chkPpdFallback.checked : false;
|
||||
const options = getSelectedPrintOptions();
|
||||
const origBtnContent = triggeringButton ? triggeringButton.innerHTML : "";
|
||||
|
||||
if (triggeringButton) {
|
||||
@@ -297,7 +381,7 @@ export function initPrinttarg() {
|
||||
await invoke("print_target_native", {
|
||||
printerName,
|
||||
tiffPath: filePath,
|
||||
ppdUncorrectedPassthrough: ppdFallback,
|
||||
options,
|
||||
});
|
||||
|
||||
showNotification("success", `✓ Successfully spooled ${label} to '${printerName}' with raw color management bypass.`);
|
||||
@@ -328,7 +412,7 @@ export function initPrinttarg() {
|
||||
return;
|
||||
}
|
||||
|
||||
const ppdFallback = chkPpdFallback ? chkPpdFallback.checked : false;
|
||||
const options = getSelectedPrintOptions();
|
||||
const cwd = stage1Cwd;
|
||||
const sep = cwd.includes('\\') ? '\\' : '/';
|
||||
const pages = currentManifest.pages;
|
||||
@@ -350,7 +434,7 @@ export function initPrinttarg() {
|
||||
await invoke("print_target_native", {
|
||||
printerName,
|
||||
tiffPath: filePath,
|
||||
ppdUncorrectedPassthrough: ppdFallback,
|
||||
options,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[ICCery Print Error] Page ${page.filename}:`, err);
|
||||
|
||||
@@ -924,6 +924,97 @@ button.danger:hover {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.auto-scaler-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(0, 122, 204, 0.12);
|
||||
border: 1px solid rgba(0, 122, 204, 0.35);
|
||||
color: #4fc3f7;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.auto-scaler-badge .badge-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-properties {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-properties:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: var(--accent-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.printer-media-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.printer-media-row .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.orientation-toggle-group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-toggle {
|
||||
flex: 1;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: #aaa;
|
||||
padding: 7px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-toggle:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.btn-toggle.active {
|
||||
background: rgba(0, 122, 204, 0.25);
|
||||
border-color: var(--accent-color);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
Reference in New Issue
Block a user