feat(print): Enhanced Print Target Settings & Auto-Fitting Scaler (#34) #35

Merged
gronod merged 12 commits from feature/issue-34-enhanced-print-settings into development 2026-08-23 22:01:57 +01:00
10 changed files with 904 additions and 101 deletions
+20 -12
View File
@@ -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
View File
@@ -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"]);
}
}
+3
View File
@@ -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,
+54 -2
View File
@@ -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;
+104 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+91
View File
@@ -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);