From 24318bca580e3b45185033def53fb4a11fa660f0 Mon Sep 17 00:00:00 2001 From: gronod <1+gronod@noreply@i3omb.com> Date: Mon, 7 Sep 2026 16:59:35 +0000 Subject: [PATCH] feat(cal): printer linearization via printcal / applycal (#224) Add an optional Stage 0 calibration dashboard so CMYK / RIP workflows can linearize and set ink limits before a full profile. CAL_ artefacts keep the 5-stage wizard unchanged when calibration is skipped. printtarg receives -K only for profiling layouts; colprof embeds curves with applycal. Existing .cal files require Overwrite / Rename / Cancel. Bump version to v0.8.5. --- AGENTS.md | 11 + README.md | 11 +- ROADMAP.md | 5 +- package.json | 4 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 4 +- src-tauri/src/calibration.rs | 1083 ++++++++++++++++++++++++++++++++++ src-tauri/src/commands.rs | 73 +++ src-tauri/src/lib.rs | 10 + src-tauri/src/settings.rs | 19 + src-tauri/tauri.conf.json | 2 +- src/index.html | 139 ++++- src/js/app.js | 2 + src/js/calibration.js | 658 +++++++++++++++++++++ src/js/calibration.test.js | 109 ++++ src/js/colprof.js | 11 + src/js/presets.js | 4 + src/js/printtarg.js | 2 + src/js/settings.js | 5 + src/js/state.js | 16 +- src/styles/main.css | 117 ++++ 21 files changed, 2274 insertions(+), 13 deletions(-) create mode 100644 src-tauri/src/calibration.rs create mode 100644 src/js/calibration.js create mode 100644 src/js/calibration.test.js diff --git a/AGENTS.md b/AGENTS.md index d30ba69..435eb69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,15 @@ # ICCery Agent Notes +## Printer Calibration (`printcal` / `applycal`) (#224) + +- Optional Stage 0 dashboard, opened from **Calibrate Printer**. The 1–5 wizard is unchanged when calibration is skipped. +- Calibration charts use a `CAL_` basename so they never collide with the profiling `.ti1`/`.ti2`/`.ti3`. +- `printtarg -K file.cal` is applied only to **profiling** layouts, never to the calibration chart itself. +- After Stage 4 `colprof`, `applycal` embeds the curves into the ICC/ICM when Apply Calibration is on. +- `.cal` overwrite requires an explicit Overwrite / Rename / Cancel choice. +- Warn when a loaded `.cal` is older than `calibration_stale_days` (default 30) or the stored printer name differs. +- Tests: `src-tauri/src/calibration.rs` (arg builders + `.cal` parser) and `src/js/calibration.test.js`. + ## Stage 5 Verification / Profcheck - `profcheck` output is parsed from both JSON summaries (preferred) and legacy plain-text report formats. @@ -64,6 +74,7 @@ The frontend uses a tiered button sizing system defined in `src/styles/main.css` - Verification & drift tests: `node src/js/profcheck.test.js` (21 tests) - Chartread classifier & XY table tests: `node src/js/chartread.test.js` (39 tests) - Gamut viewer tests: `node src/js/gamut_viewer.test.js` + - Calibration helpers: `node src/js/calibration.test.js` - Browser devtools console: `import('./profcheck.test.js').then(m => m.runAll())` - **Frontend development server**: `npm run tauri dev` - **Production package build**: `npm run tauri build` diff --git a/README.md b/README.md index acad0c2..a5c01c9 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,20 @@ > Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS. -[![Release](https://img.shields.io/badge/version-v0.8.4-blue.svg)](https://git.i3omb.com/gronod/ICCery) +[![Release](https://img.shields.io/badge/version-v0.8.5-blue.svg)](https://git.i3omb.com/gronod/ICCery) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://git.i3omb.com/gronod/ICCery) [![Framework](https://img.shields.io/badge/framework-Tauri%20v2%20%2B%20Rust-orange.svg)](https://tauri.app) [![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](LICENCE.md) -**ICCery** is a native GUI frontend designed to make creating custom ICC/ICM printer profiles seamless, visual, and reliable. It wraps the powerful color management capabilities of [ArgyllCMS](https://www.argyllcms.com/) within an intuitive, artefact-gated 5-stage wizard. +**ICCery** is a native GUI frontend designed to make creating custom ICC/ICM printer profiles seamless, visual, and reliable. It wraps the powerful color management capabilities of [ArgyllCMS](https://www.argyllcms.com/) within an intuitive, artefact-gated 5-stage wizard, with an optional printer calibration (linearization) workflow. --- ## Key Features - 🪄 **Linear 5-Stage Wizard Workflow**: - 1. **Stage 1 — Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, neutral/grey axis boosting, and 11 advanced generation parameters with contextual guidance tooltips. Supports direct-resume from existing `.ti2` target files to jump straight to measurement. + 0. **Optional — Printer Calibration (`printcal` / `applycal`)** (#224): Per-channel linearization and ink-limit discovery before a full profile. Generate a short `CAL_` chart, print and measure it with the existing Stage 2/3 engines, compute `.cal` curves, inspect channel-response plots, and toggle **Apply Calibration** so subsequent `printtarg` (`-K`) and `colprof` (`applycal`) runs consume the curves. Skip entirely for simple RGB photo printers. + 1. **Stage 1 — Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, neutral/grey axis boosting, and 11 advanced generation parameters with contextual guidance tooltips. Supports direct-resume from existing `.ti2` target files to jump straight to measurement. CMYK / RIP workflows show a reminder when no calibration is applied. 2. **Stage 2 — Target Creation & Raw Printing (`printtarg`)**: Format patch targets for handheld spectrophotometers (i1Pro, i1Pro2, ColorMunki, SpyderPrint) and automated XY tables (i1iO, SpectroScan). View high-resolution downscaled TIFF previews and print directly using native OS unmanaged pathways: - **macOS**: Native `NSPrintPanel` driver preferences with automatic ColorSync suppression (`AP_ColorMatchingMode=AP_ApplicationColorMatching`), CUPS media type selection, and driver-specific color adjustment bypass detection (Canon `CNIJIntent2`, Epson `ColorCorrection`, Gutenprint). - **Windows**: GDI uncorrected raw printing and DEVMODE preferences. @@ -54,10 +55,12 @@ flowchart TD QualityStore[Verification History & Drift Analytics] PrintEngine["Raw Print Subsystem (GDI / CUPS / NSPrintPanel)"] ProcMgr[Async Subprocess IPC Manager] + CalStore[Calibration .cal library] UI <--> State State <--> ProcMgr State <--> QualityStore + State <--> CalStore ProcMgr --> ThreeJS UI --> PrintEngine QualityStore --> UI @@ -67,6 +70,7 @@ flowchart TD BIN_TAR[targen] BIN_PRT[printtarg] BIN_CHR[chartread] + BIN_CAL[printcal / applycal] BIN_COL[colprof] BIN_CHK[profcheck] BIN_GAM[iccgamut] @@ -75,6 +79,7 @@ flowchart TD ProcMgr -- stdin/stdout/stderr pipes --> BIN_TAR ProcMgr -- stdin/stdout/stderr pipes --> BIN_PRT ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHR + ProcMgr -- stdin/stdout/stderr pipes --> BIN_CAL ProcMgr -- stdin/stdout/stderr pipes --> BIN_COL ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHK ProcMgr -- stdin/stdout/stderr pipes --> BIN_GAM diff --git a/ROADMAP.md b/ROADMAP.md index cba00f3..1bcb82a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ ICCery is a native, cross-platform desktop application built with: - **Frontend**: Vanilla JS (ES Modules) + HTML5/CSS3 with a modern dark theme and responsive layout. - **Visualization**: Three.js WebGL engine for 3D CIELAB color gamut volumes and sRGB reference comparisons. - **Engine**: ArgyllCMS command-line utilities orchestrated over isolated standard stream IPC (`stdin`, `stdout`, `stderr`). -- **Current Version**: `v0.8.4` (Production release). +- **Current Version**: `v0.8.5` (Production release). --- @@ -118,6 +118,9 @@ ICCery is a native, cross-platform desktop application built with: - [x] **Frontend Unit Testing & CI Integration (#215)**: Added standard `npm test` script executing the 3 frontend test suites (`profcheck`, `chartread`, and `gamut_viewer`) and integrated automated frontend test validation into macOS, Linux, and Windows CI workflows. - [x] **macOS Monterey WKWebView survival (#225)**: Deferred Stage 5 WebGL until the gamut viewer is shown, hid the main window until first paint, painted a dark WKWebView backing, logged Web Content termination, and raised `minimumSystemVersion` to 12.0. +### Printer Calibration Release (`v0.8.5`) +- [x] **Printer Calibration Curves (#224)**: Optional Stage 0 dashboard for `printcal` linearization and ink limits. `CAL_` artefacts, Apply Calibration toggle feeding `printtarg -K` and `applycal`, channel-response plots, stale-cal warnings, and project/library persistence. + --- ## 3. Future Roadmap diff --git a/package.json b/package.json index d8e26bc..6715262 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "iccery", "private": true, - "version": "0.8.4", + "version": "0.8.5", "type": "module", "scripts": { "fetch-argyll": "node scripts/fetch-argyll.mjs", "tauri": "tauri", - "test": "node src/js/profcheck.test.js && node src/js/chartread.test.js && node src/js/gamut_viewer.test.js" + "test": "node src/js/profcheck.test.js && node src/js/chartread.test.js && node src/js/gamut_viewer.test.js && node src/js/calibration.test.js" }, "devDependencies": { "@tauri-apps/cli": "^2" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 63cde58..11eaf7b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1423,7 +1423,7 @@ dependencies = [ [[package]] name = "iccery" -version = "0.8.4" +version = "0.8.5" dependencies = [ "base64 0.22.1", "image", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bdc0735..ed60ad8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iccery" -version = "0.8.4" +version = "0.8.5" description = "Modern Printer Profiling UI frontend for ArgyllCMS" authors = ["Gordon"] edition = "2021" @@ -21,7 +21,7 @@ include = [ [lib] # The `_lib` suffix may seem redundant but it is necessary # to make the lib name unique and wouldn't conflict with the bin name. -# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +# This is only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 name = "iccery_lib" crate-type = ["staticlib", "cdylib", "rlib"] diff --git a/src-tauri/src/calibration.rs b/src-tauri/src/calibration.rs new file mode 100644 index 0000000..f014e04 --- /dev/null +++ b/src-tauri/src/calibration.rs @@ -0,0 +1,1083 @@ +//! Printer linearization via ArgyllCMS `printcal` / `applycal` / `targen` (#224). +//! +//! All orchestration stays in the proprietary host. Argyll binaries are invoked +//! as isolated subprocesses — never linked. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Manager, State}; + +use crate::commands::{resolve_binary, resolve_safe_cwd}; +use crate::process_manager::ProcessManager; + +pub const CAL_PREFIX: &str = "CAL_"; +pub const DEFAULT_STEPS: u32 = 21; +pub const MIN_STEPS: u32 = 11; +pub const MAX_STEPS: u32 = 51; +pub const DEFAULT_STALE_DAYS: u32 = 30; +pub const PROJECT_STATE_FILENAME: &str = "iccery-calibration.json"; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct PrintcalTargetConfig { + pub colour_space: String, + #[serde(default = "default_steps")] + pub steps_per_channel: u32, + pub ink_limit_exploration: Option, + pub channels: Option, + #[serde(default)] + pub white_patches: Option, + #[serde(default)] + pub neutral_emphasis: bool, + pub basename: String, + pub cwd: String, +} + +fn default_steps() -> u32 { + DEFAULT_STEPS +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct PrintcalConfig { + pub ti3_basename: String, + pub cwd: String, + pub output_cal: Option, + pub previous_cal: Option, + #[serde(default)] + pub force_overwrite: bool, + #[serde(default)] + pub no_ink_limit: bool, + #[serde(default)] + pub verify: bool, + pub total_ink_limit: Option, + #[serde(default)] + pub channel_limits: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +pub struct ChannelLimit { + pub channel: String, + pub percent: f64, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ApplycalConfig { + pub cal_path: String, + pub input_path: String, + pub output_path: Option, + #[serde(default)] + pub unapply: bool, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +pub struct CalCurve { + pub channel: String, + pub points: Vec<[f64; 2]>, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +pub struct CalMetadata { + pub path: String, + pub filename: String, + pub color_rep: Option, + pub created: Option, + pub description: Option, + pub modified_ms: u128, + pub age_days: f64, + pub ink_limits: Vec, + pub total_ink_limit: Option, + pub curves: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct CalResult { + pub cal_path: String, + pub stdout: String, + pub stderr: String, + pub ink_limits: Vec, + pub total_ink_limit: Option, + pub recommended_power: Option, + pub metadata: Option, + pub message: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct ProjectCalibrationState { + pub cal_path: Option, + pub apply_enabled: bool, + pub printer_name: Option, + pub colour_space: Option, + pub created: Option, + pub cal_basename: Option, + pub ink_limit_overrides: Vec, + pub total_ink_override: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ApplycalResult { + pub output_path: String, + pub stdout: String, + pub message: String, +} + +pub fn clamp_steps(steps: u32) -> u32 { + steps.clamp(MIN_STEPS, MAX_STEPS) +} + +pub fn calibration_basename(profile_basename: &str) -> String { + let trimmed = profile_basename.trim(); + let base = trimmed + .strip_prefix(CAL_PREFIX) + .unwrap_or(trimmed) + .trim(); + let base = if base.is_empty() { "printer" } else { base }; + format!("{CAL_PREFIX}{base}") +} + +pub fn is_calibration_basename(basename: &str) -> bool { + basename.trim().starts_with(CAL_PREFIX) +} + +/// Build `targen` arguments for a short per-channel calibration chart. +/// +/// RGB: `-d2 -s{N} -g{N}` (and optional `-e` white patches). +/// CMYK: `-d4 -s{N} -g{N}` plus optional `-l` TAC exploration. +pub fn build_calibration_targen_args(config: &PrintcalTargetConfig) -> Result, String> { + let basename = sanitize_cal_basename(&config.basename)?; + let steps = clamp_steps(config.steps_per_channel); + let is_cmyk = config.colour_space.eq_ignore_ascii_case("cmyk"); + + let mut args = vec!["-v".to_string(), "-d".to_string()]; + args.push(if is_cmyk { "4".to_string() } else { "2".to_string() }); + + args.push("-s".to_string()); + args.push(steps.to_string()); + args.push("-g".to_string()); + args.push(steps.to_string()); + + if config.neutral_emphasis { + args.push("-n".to_string()); + args.push(steps.to_string()); + } + + if let Some(white) = config.white_patches { + if white > 0 { + args.push("-e".to_string()); + args.push(white.to_string()); + } + } else { + args.push("-e".to_string()); + args.push("4".to_string()); + } + + if is_cmyk { + if let Some(limit) = config.ink_limit_exploration { + if (200..=400).contains(&limit) { + args.push("-l".to_string()); + args.push(limit.to_string()); + } + } + } + + // Full-spread patches are not useful on a calibration wedge. + args.push("-f".to_string()); + args.push("0".to_string()); + + args.push(basename); + Ok(args) +} + +pub fn build_printcal_args(config: &PrintcalConfig) -> Result, String> { + let basename = sanitize_cal_basename(&config.ti3_basename)?; + let mut args = vec!["-v".to_string(), "-e".to_string()]; + + if config.no_ink_limit { + args.push("-I".to_string()); + } + if config.verify { + args.push("-z".to_string()); + } + if let Some(ref prev) = config.previous_cal { + let trimmed = prev.trim(); + if !trimmed.is_empty() { + args.push("-a".to_string()); + args.push(trimmed.to_string()); + } + } + if let Some(tac) = config.total_ink_limit { + if tac > 0.0 { + args.push("-m".to_string()); + args.push(format!("{tac:.1}")); + } + } + for limit in &config.channel_limits { + let ch = limit.channel.trim(); + if ch.is_empty() { + continue; + } + let flag = format!("-x{}", ch.chars().next().unwrap_or('C')); + args.push(flag); + args.push(format!("{:.1}", limit.percent)); + } + + let output = config + .output_cal + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("{basename}.cal")); + args.push("-o".to_string()); + args.push(output); + args.push(basename); + Ok(args) +} + +pub fn build_applycal_args(config: &ApplycalConfig) -> Result, String> { + let cal = config.cal_path.trim(); + let input = config.input_path.trim(); + if cal.is_empty() { + return Err("calibration file path is empty".to_string()); + } + if input.is_empty() { + return Err("input profile path is empty".to_string()); + } + let mut args = vec!["-v".to_string()]; + if config.unapply { + args.push("-u".to_string()); + } else { + args.push("-a".to_string()); + } + args.push(cal.to_string()); + args.push(input.to_string()); + if let Some(ref out) = config.output_path { + let trimmed = out.trim(); + if !trimmed.is_empty() { + args.push(trimmed.to_string()); + } + } + Ok(args) +} + +pub fn sanitize_cal_basename(basename: &str) -> Result { + let name = basename.trim(); + if name.is_empty() { + return Err("basename is empty".to_string()); + } + if name.contains('/') || name.contains('\\') || name.contains("..") { + return Err("basename must not contain path separators".to_string()); + } + Ok(name.to_string()) +} + +pub fn parse_printcal_stdout(stdout: &str) -> (Vec, Option, Option) { + let mut limits = Vec::new(); + let mut total = None; + let mut power = None; + + for raw in stdout.lines() { + let line = raw.trim(); + let lower = line.to_ascii_lowercase(); + + if lower.contains("ideal power") || lower.contains("device power") || lower.contains("power value") { + if let Some(v) = first_number(line) { + power = Some(v); + } + } + + if lower.contains("total") && (lower.contains("ink") || lower.contains("tac") || lower.contains("limit")) { + if let Some(v) = first_number(line) { + total = Some(v); + } + continue; + } + + if let Some(ch) = channel_from_limit_line(line) { + if let Some(v) = first_number(line) { + limits.push(ChannelLimit { + channel: ch, + percent: v, + }); + } + } + } + + (limits, total, power) +} + +fn channel_from_limit_line(line: &str) -> Option { + let t = line.trim(); + let letters = ["Cyan", "Magenta", "Yellow", "Black", "Red", "Green", "Blue"]; + let shorts = ["C", "M", "Y", "K", "R", "G", "B"]; + for (full, short) in letters.iter().zip(shorts) { + if t.starts_with(full) || t.starts_with(&format!("{full}:")) || t.starts_with(&format!("{short}:")) || t.starts_with(&format!("{short} ")) { + return Some((*short).to_string()); + } + } + None +} + +fn first_number(line: &str) -> Option { + let mut buf = String::new(); + let mut seen_digit = false; + for ch in line.chars() { + if ch.is_ascii_digit() || (ch == '.' && seen_digit && !buf.contains('.')) { + buf.push(ch); + seen_digit = true; + } else if seen_digit { + break; + } + } + if buf.is_empty() { + None + } else { + buf.parse().ok() + } +} + +pub fn parse_cal_file(path: &Path) -> Result { + let content = fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?; + parse_cal_contents(path, &content) +} + +pub fn parse_cal_contents(path: &Path, content: &str) -> Result { + let mut color_rep = None; + let mut created = None; + let mut description = None; + let mut total_ink_limit = None; + let mut format_fields: Vec = Vec::new(); + let mut in_format = false; + let mut in_data = false; + let mut rows: Vec> = Vec::new(); + let mut ink_limits: Vec = Vec::new(); + + for raw in content.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if line.eq_ignore_ascii_case("BEGIN_DATA_FORMAT") { + in_format = true; + continue; + } + if line.eq_ignore_ascii_case("END_DATA_FORMAT") { + in_format = false; + continue; + } + if line.eq_ignore_ascii_case("BEGIN_DATA") { + in_data = true; + continue; + } + if line.eq_ignore_ascii_case("END_DATA") { + in_data = false; + continue; + } + if in_format { + format_fields.extend( + line.split_whitespace() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + ); + continue; + } + if in_data { + let nums: Vec = line + .split_whitespace() + .filter_map(|t| t.parse().ok()) + .collect(); + if !nums.is_empty() { + rows.push(nums); + } + continue; + } + + let (key, value) = split_cgats_kv(line); + match key.to_ascii_uppercase().as_str() { + "COLOR_REP" | "COLORANT_COLOURSPACE" => color_rep = Some(unquote(&value)), + "CREATED" => created = Some(unquote(&value)), + "DESCRIPTOR" | "DESCRIPTION" => description = Some(unquote(&value)), + "MAX_TAC" | "TOTAL_INK_LIMIT" | "INK_LIMIT" => { + total_ink_limit = unquote(&value).parse().ok(); + } + other if other.starts_with("INK_LIMIT_") => { + let ch = other.rsplit('_').next().unwrap_or("").to_string(); + if let Ok(percent) = unquote(&value).parse::() { + ink_limits.push(ChannelLimit { channel: ch, percent }); + } + } + _ => {} + } + } + + let curves = curves_from_rows(&format_fields, &rows); + if ink_limits.is_empty() { + ink_limits = infer_channel_limits_from_curves(&curves); + } + + let modified_ms = fs::metadata(path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis()) + .unwrap_or(0); + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(modified_ms); + let age_days = if modified_ms == 0 { + 0.0 + } else { + (now_ms.saturating_sub(modified_ms) as f64) / 86_400_000.0 + }; + + Ok(CalMetadata { + path: path.to_string_lossy().to_string(), + filename: path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown.cal".to_string()), + color_rep, + created, + description, + modified_ms, + age_days, + ink_limits, + total_ink_limit, + curves, + }) +} + +fn split_cgats_kv(line: &str) -> (String, String) { + let mut parts = line.splitn(2, char::is_whitespace); + let key = parts.next().unwrap_or("").to_string(); + let value = parts.next().unwrap_or("").trim().to_string(); + (key, value) +} + +fn unquote(s: &str) -> String { + s.trim().trim_matches('"').to_string() +} + +fn curves_from_rows(fields: &[String], rows: &[Vec]) -> Vec { + if fields.is_empty() || rows.is_empty() { + return Vec::new(); + } + let input_idx = fields.iter().position(|f| { + let u = f.to_ascii_uppercase(); + u.ends_with("_I") || u == "RGB_I" || u == "CMYK_I" || u == "GRAY_I" + }); + let Some(input_idx) = input_idx else { + return Vec::new(); + }; + + let mut curves = Vec::new(); + for (idx, field) in fields.iter().enumerate() { + if idx == input_idx { + continue; + } + let channel = channel_from_field(field); + if channel.is_empty() { + continue; + } + let mut points = Vec::new(); + for row in rows { + if row.len() > input_idx && row.len() > idx { + points.push([row[input_idx], row[idx]]); + } + } + if !points.is_empty() { + curves.push(CalCurve { channel, points }); + } + } + curves +} + +fn channel_from_field(field: &str) -> String { + let u = field.to_ascii_uppercase(); + if let Some(rest) = u.strip_prefix("RGB_") { + return rest.to_string(); + } + if let Some(rest) = u.strip_prefix("CMYK_") { + return rest.to_string(); + } + if u.contains("CYAN") || u.ends_with("_C") { + return "C".to_string(); + } + if u.contains("MAGENTA") || u.ends_with("_M") { + return "M".to_string(); + } + if u.contains("YELLOW") || u.ends_with("_Y") { + return "Y".to_string(); + } + if u.contains("BLACK") || u.ends_with("_K") { + return "K".to_string(); + } + field.to_string() +} + +fn infer_channel_limits_from_curves(curves: &[CalCurve]) -> Vec { + curves + .iter() + .filter_map(|c| { + let max_out = c.points.iter().map(|p| p[1]).fold(0.0_f64, f64::max); + if max_out <= 0.0 { + None + } else { + Some(ChannelLimit { + channel: c.channel.clone(), + percent: (max_out * 100.0).clamp(0.0, 100.0), + }) + } + }) + .collect() +} + +pub fn is_cal_stale(age_days: f64, stale_days: u32) -> bool { + age_days > f64::from(stale_days.max(1)) +} + +pub fn list_cal_files_in_dir(dir: &Path) -> Vec { + let mut out = Vec::new(); + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return out, + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let ext = path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if ext != "cal" { + continue; + } + if let Ok(meta) = parse_cal_file(&path) { + out.push(meta); + } + } + out.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms)); + out +} + +pub fn project_state_path(cwd: &Path) -> PathBuf { + cwd.join(PROJECT_STATE_FILENAME) +} + +pub fn load_project_state(cwd: &Path) -> ProjectCalibrationState { + let path = project_state_path(cwd); + fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +pub fn save_project_state(cwd: &Path, state: &ProjectCalibrationState) -> Result<(), String> { + fs::create_dir_all(cwd).map_err(|e| e.to_string())?; + let path = project_state_path(cwd); + let tmp = cwd.join(format!("{PROJECT_STATE_FILENAME}.tmp")); + let json = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?; + fs::write(&tmp, json).map_err(|e| e.to_string())?; + fs::rename(&tmp, path).map_err(|e| e.to_string()) +} + +fn library_dir(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("calibrations"); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +#[tauri::command] +pub async fn generate_calibration_target( + app: AppHandle, + state: State<'_, ProcessManager>, + config: PrintcalTargetConfig, +) -> Result { + let basename = calibration_basename(&config.basename); + let mut cfg = config; + cfg.basename = basename.clone(); + let args = build_calibration_targen_args(&cfg)?; + let binary = resolve_binary(app.clone(), "targen".to_string()).await?; + let cwd = Some(resolve_safe_cwd(&app, &cfg.cwd)?); + let id = format!("targen_{basename}"); + state.spawn(app, id, binary, args, cwd).await?; + Ok(basename) +} + +#[tauri::command] +pub async fn compute_calibration_curves( + app: AppHandle, + config: PrintcalConfig, +) -> Result { + let cwd = resolve_safe_cwd(&app, &config.cwd)?; + let basename = sanitize_cal_basename(&config.ti3_basename)?; + let ti3 = Path::new(&cwd).join(format!("{basename}.ti3")); + if !ti3.is_file() { + return Err(format!( + "Measurement file not found: {}. Measure the calibration chart before computing curves.", + ti3.display() + )); + } + + let output_name = config + .output_cal + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| { + if s.to_ascii_lowercase().ends_with(".cal") { + s.to_string() + } else { + format!("{s}.cal") + } + }) + .unwrap_or_else(|| format!("{basename}.cal")); + if output_name.contains('/') || output_name.contains('\\') || output_name.contains("..") { + return Err("output calibration filename is invalid".to_string()); + } + let dest = Path::new(&cwd).join(&output_name); + if dest.exists() && !config.force_overwrite { + return Err(format!( + "Calibration file already exists: {}. Choose Overwrite, Rename, or Cancel.", + dest.display() + )); + } + + let mut run_cfg = config.clone(); + run_cfg.output_cal = Some(output_name.clone()); + let args = build_printcal_args(&run_cfg)?; + let binary = resolve_binary(app.clone(), "printcal".to_string()).await?; + let (code, stdout, stderr) = run_captured(&binary, &args, &cwd).await?; + if code != 0 { + return Err(format!( + "printcal exited with code {code}. {}", + stderr.lines().last().unwrap_or("No stderr.") + )); + } + if !dest.is_file() { + return Err(format!( + "printcal reported success but {} was not created.", + dest.display() + )); + } + + let (mut ink_limits, mut total, power) = parse_printcal_stdout(&stdout); + let metadata = parse_cal_file(&dest).ok(); + if let Some(ref meta) = metadata { + if ink_limits.is_empty() { + ink_limits = meta.ink_limits.clone(); + } + if total.is_none() { + total = meta.total_ink_limit; + } + } + + log::info!( + target: "calibration", + "Computed calibration {} (limits: {:?}, TAC: {:?})", + dest.display(), + ink_limits, + total + ); + + Ok(CalResult { + cal_path: dest.to_string_lossy().to_string(), + stdout, + stderr, + ink_limits, + total_ink_limit: total, + recommended_power: power, + metadata, + message: format!("Saved calibration curves to {}", dest.display()), + }) +} + +#[tauri::command] +pub async fn apply_calibration(app: AppHandle, config: ApplycalConfig) -> Result { + let cal = PathBuf::from(config.cal_path.trim()); + if !cal.is_file() { + return Err(format!("Calibration file not found: {}", cal.display())); + } + let input = PathBuf::from(config.input_path.trim()); + if !input.is_file() { + return Err(format!("Input file not found: {}", input.display())); + } + + let output = match config.output_path.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + Some(p) => PathBuf::from(p), + None => input.clone(), + }; + + let tmp = if output == input { + let mut t = input.clone(); + t.set_extension("applycal.tmp"); + t + } else { + output.clone() + }; + + let run_cfg = ApplycalConfig { + cal_path: cal.to_string_lossy().to_string(), + input_path: input.to_string_lossy().to_string(), + output_path: Some(tmp.to_string_lossy().to_string()), + unapply: config.unapply, + }; + let args = build_applycal_args(&run_cfg)?; + let binary = resolve_binary(app.clone(), "applycal".to_string()).await?; + let cwd = input + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| ".".to_string()); + let (code, stdout, stderr) = run_captured(&binary, &args, &cwd).await?; + if code != 0 { + let _ = fs::remove_file(&tmp); + return Err(format!( + "applycal exited with code {code}. {}", + stderr.lines().last().unwrap_or("No stderr.") + )); + } + if tmp != output { + fs::rename(&tmp, &output).map_err(|e| format!("Failed to replace profile with calibrated copy: {e}"))?; + } + log::info!( + target: "calibration", + "Applied {} to {} -> {}", + cal.display(), + input.display(), + output.display() + ); + Ok(ApplycalResult { + output_path: output.to_string_lossy().to_string(), + stdout, + message: format!("Applied calibration to {}", output.display()), + }) +} + +#[tauri::command] +pub fn parse_cal_file_cmd(path: String) -> Result { + parse_cal_file(Path::new(&path)) +} + +#[tauri::command] +pub fn list_saved_calibrations(app: AppHandle, cwd: Option) -> Result, String> { + let mut all = Vec::new(); + if let Some(dir) = cwd { + if !dir.trim().is_empty() { + all.extend(list_cal_files_in_dir(Path::new(&dir))); + } + } + if let Ok(lib) = library_dir(&app) { + for meta in list_cal_files_in_dir(&lib) { + if !all.iter().any(|m| m.path == meta.path) { + all.push(meta); + } + } + } + all.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms)); + Ok(all) +} + +#[tauri::command] +pub fn save_calibration_to_library(app: AppHandle, cal_path: String) -> Result { + let src = Path::new(&cal_path); + if !src.is_file() { + return Err(format!("Calibration file not found: {cal_path}")); + } + let lib = library_dir(&app)?; + let name = src + .file_name() + .ok_or_else(|| "invalid calibration filename".to_string())?; + let dest = lib.join(name); + fs::copy(src, &dest).map_err(|e| format!("Failed to copy into library: {e}"))?; + Ok(dest.to_string_lossy().to_string()) +} + +#[tauri::command] +pub async fn select_cal_file( + app: AppHandle, + default_dir: Option, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let mut builder = app + .dialog() + .file() + .add_filter("Argyll calibration (*.cal)", &["cal"]); + if let Some(ref dir) = default_dir { + if !dir.trim().is_empty() { + builder = builder.set_directory(PathBuf::from(dir)); + } + } + let (tx, rx) = tokio::sync::oneshot::channel(); + builder.pick_file(move |file_path| { + let res = file_path.map(|p| p.to_string()); + let _ = tx.send(res); + }); + rx.await.map_err(|e| format!("Dialog channel error: {e}")) +} + +#[tauri::command] +pub fn load_project_calibration(cwd: String) -> Result { + if cwd.trim().is_empty() { + return Ok(ProjectCalibrationState::default()); + } + Ok(load_project_state(Path::new(&cwd))) +} + +#[tauri::command] +pub fn save_project_calibration(cwd: String, state: ProjectCalibrationState) -> Result<(), String> { + if cwd.trim().is_empty() { + return Err("working directory is empty".to_string()); + } + save_project_state(Path::new(&cwd), &state) +} + +async fn run_captured(binary: &str, args: &[String], cwd: &str) -> Result<(i32, String, String), String> { + let mut cmd = tokio::process::Command::new(binary); + cmd.args(args) + .current_dir(cwd) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .env("ARGYLL_NOT_INTERACTIVE", "1"); + #[cfg(windows)] + { + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + log::info!( + target: "subprocess", + "Running captured {} {:?}", + crate::process_manager::sanitize_arg_for_logging(binary), + crate::process_manager::sanitize_args_for_logging(args) + ); + let output = cmd + .output() + .await + .map_err(|e| format!("Failed to launch {binary}: {e}"))?; + let code = output.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + Ok((code, stdout, stderr)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rgb_target() -> PrintcalTargetConfig { + PrintcalTargetConfig { + colour_space: "rgb".to_string(), + steps_per_channel: 21, + ink_limit_exploration: Some(320), + channels: None, + white_patches: None, + neutral_emphasis: false, + basename: "photo".to_string(), + cwd: "/tmp".to_string(), + } + } + + #[test] + fn test_calibration_basename_prefix() { + assert_eq!(calibration_basename("photo"), "CAL_photo"); + assert_eq!(calibration_basename("CAL_photo"), "CAL_photo"); + assert_eq!(calibration_basename(" "), "CAL_printer"); + assert!(is_calibration_basename("CAL_photo")); + assert!(!is_calibration_basename("photo")); + } + + #[test] + fn test_clamp_steps() { + assert_eq!(clamp_steps(5), 11); + assert_eq!(clamp_steps(21), 21); + assert_eq!(clamp_steps(99), 51); + } + + #[test] + fn test_build_calibration_targen_rgb() { + let args = build_calibration_targen_args(&rgb_target()).unwrap(); + assert!(args.contains(&"-d".to_string())); + assert!(args.contains(&"2".to_string())); + assert!(args.contains(&"-s".to_string())); + assert!(args.contains(&"21".to_string())); + assert!(args.contains(&"-g".to_string())); + assert!(args.contains(&"-f".to_string())); + assert!(args.contains(&"0".to_string())); + assert_eq!(args.last().unwrap(), "photo"); + assert!(!args.contains(&"-l".to_string()), "RGB must not pass -l"); + } + + #[test] + fn test_build_calibration_targen_cmyk_ink_limit() { + let mut cfg = rgb_target(); + cfg.colour_space = "cmyk".to_string(); + cfg.basename = "CAL_press".to_string(); + cfg.neutral_emphasis = true; + let args = build_calibration_targen_args(&cfg).unwrap(); + assert!(args.contains(&"4".to_string())); + assert!(args.contains(&"-l".to_string())); + assert!(args.contains(&"320".to_string())); + assert!(args.contains(&"-n".to_string())); + assert_eq!(args.last().unwrap(), "CAL_press"); + } + + #[test] + fn test_build_calibration_targen_rejects_path() { + let mut cfg = rgb_target(); + cfg.basename = "../escape".to_string(); + assert!(build_calibration_targen_args(&cfg).is_err()); + } + + #[test] + fn test_build_printcal_args_defaults() { + let cfg = PrintcalConfig { + ti3_basename: "CAL_photo".to_string(), + cwd: "/tmp".to_string(), + output_cal: None, + previous_cal: None, + force_overwrite: false, + no_ink_limit: false, + verify: false, + total_ink_limit: None, + channel_limits: vec![], + }; + let args = build_printcal_args(&cfg).unwrap(); + assert_eq!( + args, + vec!["-v", "-e", "-o", "CAL_photo.cal", "CAL_photo"] + ); + } + + #[test] + fn test_build_printcal_args_overrides_and_prev() { + let cfg = PrintcalConfig { + ti3_basename: "CAL_press".to_string(), + cwd: "/tmp".to_string(), + output_cal: Some("press_lin.cal".to_string()), + previous_cal: Some("old.cal".to_string()), + force_overwrite: true, + no_ink_limit: true, + verify: true, + total_ink_limit: Some(280.0), + channel_limits: vec![ChannelLimit { + channel: "C".to_string(), + percent: 95.0, + }], + }; + let args = build_printcal_args(&cfg).unwrap(); + assert!(args.contains(&"-I".to_string())); + assert!(args.contains(&"-z".to_string())); + assert!(args.contains(&"-a".to_string())); + assert!(args.contains(&"old.cal".to_string())); + assert!(args.contains(&"-m".to_string())); + assert!(args.contains(&"280.0".to_string())); + assert!(args.contains(&"-xC".to_string())); + assert!(args.contains(&"95.0".to_string())); + assert!(args.contains(&"press_lin.cal".to_string())); + } + + #[test] + fn test_build_applycal_args() { + let cfg = ApplycalConfig { + cal_path: "lin.cal".to_string(), + input_path: "out.icc".to_string(), + output_path: Some("out_cal.icc".to_string()), + unapply: false, + }; + let args = build_applycal_args(&cfg).unwrap(); + assert_eq!(args, vec!["-v", "-a", "lin.cal", "out.icc", "out_cal.icc"]); + } + + #[test] + fn test_build_applycal_unapply_and_empty() { + let cfg = ApplycalConfig { + cal_path: "".to_string(), + input_path: "out.icc".to_string(), + output_path: None, + unapply: true, + }; + assert!(build_applycal_args(&cfg).is_err()); + let cfg = ApplycalConfig { + cal_path: "lin.cal".to_string(), + input_path: "out.icc".to_string(), + output_path: None, + unapply: true, + }; + let args = build_applycal_args(&cfg).unwrap(); + assert_eq!(args, vec!["-v", "-u", "lin.cal", "out.icc"]); + } + + #[test] + fn test_parse_printcal_stdout_limits() { + let stdout = r#" +printcal: Creating calibration +Ideal power value to apply to the test chart = 1.35 +Ink limits: + Cyan: 96.4% + Magenta: 94.1% + Yellow: 98.0% + Black: 90.2% + Total ink limit: 280.0% +"#; + let (limits, total, power) = parse_printcal_stdout(stdout); + assert_eq!(power, Some(1.35)); + assert_eq!(total, Some(280.0)); + assert_eq!(limits.len(), 4); + assert_eq!(limits[0].channel, "C"); + assert!((limits[0].percent - 96.4).abs() < 0.01); + } + + #[test] + fn test_parse_cal_contents_cmyk_curves() { + let cal = r#" +CAL +DESCRIPTOR "Argyll Device Calibration File" +CREATED "Mon Sep 7 17:00:00 2026" +KEYWORD "COLOR_REP" +COLOR_REP "CMYK" +KEYWORD "MAX_TAC" +MAX_TAC "280.000000" +KEYWORD "NUMBER_OF_FIELDS" +NUMBER_OF_FIELDS 5 +BEGIN_DATA_FORMAT +CMYK_I CMYK_C CMYK_M CMYK_Y CMYK_K +END_DATA_FORMAT +NUMBER_OF_SETS 3 +BEGIN_DATA +0.0 0.00 0.00 0.00 0.00 +0.5 0.42 0.40 0.45 0.38 +1.0 0.95 0.92 0.98 0.90 +END_DATA +"#; + let meta = parse_cal_contents(Path::new("/tmp/demo.cal"), cal).unwrap(); + assert_eq!(meta.color_rep.as_deref(), Some("CMYK")); + assert_eq!(meta.total_ink_limit, Some(280.0)); + assert_eq!(meta.curves.len(), 4); + assert_eq!(meta.curves[0].channel, "C"); + assert_eq!(meta.curves[0].points.len(), 3); + assert!((meta.ink_limits.iter().find(|l| l.channel == "C").unwrap().percent - 95.0).abs() < 0.01); + } + + #[test] + fn test_is_cal_stale() { + assert!(!is_cal_stale(10.0, 30)); + assert!(!is_cal_stale(30.0, 30)); + assert!(is_cal_stale(31.0, 30)); + } + + #[test] + fn test_missing_measurement_error_path_message() { + // The command itself needs a Tauri app; the path construction is covered + // by sanitize + ti3 join used in compute_calibration_curves. + let basename = sanitize_cal_basename("CAL_x").unwrap(); + let ti3 = Path::new("/tmp").join(format!("{basename}.ti3")); + assert_eq!(ti3, Path::new("/tmp/CAL_x.ti3")); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index acbcaba..77cf843 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -777,6 +777,12 @@ pub struct PrinttargConfig { pub no_randomize: bool, // If true, pass -r (raster layout / no randomization) pub basename: String, // Must match the .ti1 basename from Stage 1 pub cwd: String, // Working directory where the .ti1 file resides + /// Optional Argyll `.cal` applied via printtarg `-K` (or `-I` when embed-only). + #[serde(default)] + pub calibration_file: Option, + /// When true, embed the calibration (`-I`) without applying it to printed patches. + #[serde(default)] + pub calibration_embed_only: bool, } pub fn build_targen_args(config: &TargenConfig) -> Vec { @@ -927,6 +933,18 @@ pub fn build_printtarg_args(config: &PrinttargConfig) -> Vec { } args.push(config.dpi.to_string()); + if let Some(ref cal) = config.calibration_file { + let trimmed = cal.trim(); + if !trimmed.is_empty() { + if config.calibration_embed_only { + args.push("-I".to_string()); + } else { + args.push("-K".to_string()); + } + args.push(trimmed.to_string()); + } + } + args.push(config.basename.clone()); args } @@ -1621,6 +1639,8 @@ mod tests { no_randomize: false, basename: "my_profile".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "1", "-t", "100", "my_profile"]); @@ -1638,6 +1658,8 @@ mod tests { no_randomize: false, basename: "cmyk_profile".to_string(), cwd: "/home/user".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "CM", "-p", "Letter", "-R", "1", "-T", "300", "cmyk_profile"]); @@ -1655,6 +1677,8 @@ mod tests { no_randomize: false, basename: "custom_target".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-R", "1", "-t", "150", "custom_target"]); @@ -1672,6 +1696,8 @@ mod tests { no_randomize: false, basename: "my_profile".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!( @@ -1706,6 +1732,8 @@ mod tests { no_randomize: false, basename: "my_profile".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "42", "-t", "300", "my_profile"]); @@ -1723,11 +1751,56 @@ mod tests { no_randomize: true, basename: "my_profile".to_string(), cwd: "/tmp".to_string(), + calibration_file: None, + calibration_embed_only: false, }; let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-r", "-t", "300", "my_profile"]); } + #[test] + fn test_build_printtarg_args_with_calibration_apply() { + let config = PrinttargConfig { + instrument: "i1".to_string(), + page_size: "A4".to_string(), + bit_depth: 8, + dpi: 300, + custom_label: None, + random_seed: Some(1), + no_randomize: false, + basename: "my_profile".to_string(), + cwd: "/tmp".to_string(), + calibration_file: Some("CAL_photo.cal".to_string()), + calibration_embed_only: false, + }; + let args = build_printtarg_args(&config); + assert_eq!( + args, + vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "1", "-t", "300", "-K", "CAL_photo.cal", "my_profile"] + ); + } + + #[test] + fn test_build_printtarg_args_with_calibration_embed_only() { + let config = PrinttargConfig { + instrument: "i1".to_string(), + page_size: "A4".to_string(), + bit_depth: 8, + dpi: 300, + custom_label: None, + random_seed: Some(1), + no_randomize: false, + basename: "my_profile".to_string(), + cwd: "/tmp".to_string(), + calibration_file: Some("lin.cal".to_string()), + calibration_embed_only: true, + }; + let args = build_printtarg_args(&config); + assert!(args.contains(&"-I".to_string())); + assert!(!args.contains(&"-K".to_string())); + assert!(args.contains(&"lin.cal".to_string())); + } + #[test] fn test_build_chartread_args_auto() { let config = ChartreadConfig { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f908b40..0963df5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ use tauri::Manager; mod cgats; mod commands; +mod calibration; mod events; mod macos_webview; mod print; @@ -92,6 +93,15 @@ pub fn run() { commands::show_printer_properties, commands::print_target_native, commands::select_csv_save_path, + calibration::generate_calibration_target, + calibration::compute_calibration_curves, + calibration::apply_calibration, + calibration::parse_cal_file_cmd, + calibration::list_saved_calibrations, + calibration::save_calibration_to_library, + calibration::select_cal_file, + calibration::load_project_calibration, + calibration::save_project_calibration, quality_store::save_verification_record, quality_store::get_verification_history, quality_store::clear_verification_history, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 192a8e6..69442a9 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -58,10 +58,15 @@ pub struct ProfilingPreset { pub random_seed: Option, #[serde(default)] pub no_randomize: Option, + #[serde(default)] + pub calibration_file: Option, + #[serde(default)] + pub apply_calibration: Option, } fn default_delta_e_good_max() -> f64 { 2.0 } fn default_delta_e_warning_max() -> f64 { 5.0 } +fn default_cal_stale_days() -> u32 { 30 } #[derive(Debug, Deserialize, Serialize, Clone)] pub struct AppSettings { @@ -76,6 +81,8 @@ pub struct AppSettings { pub custom_presets: Vec, #[serde(default)] pub enable_i1pro2_leds: bool, + #[serde(default = "default_cal_stale_days")] + pub calibration_stale_days: u32, } impl Default for AppSettings { @@ -88,6 +95,7 @@ impl Default for AppSettings { delta_e_warning_max: default_delta_e_warning_max(), custom_presets: Vec::new(), enable_i1pro2_leds: false, + calibration_stale_days: default_cal_stale_days(), } } } @@ -135,6 +143,8 @@ pub fn get_default_presets() -> Vec { device_power: None, random_seed: Some(1), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -169,6 +179,8 @@ pub fn get_default_presets() -> Vec { device_power: None, random_seed: Some(1), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -203,6 +215,8 @@ pub fn get_default_presets() -> Vec { device_power: None, random_seed: Some(1), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -237,6 +251,8 @@ pub fn get_default_presets() -> Vec { device_power: None, random_seed: Some(1), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -405,6 +421,8 @@ mod tests { device_power: Some(1.2), random_seed: Some(42), no_randomize: Some(false), + calibration_file: None, + apply_calibration: None, colprof_fwa: Some("D50".to_string()), colprof_illuminant: None, colprof_observer: None, @@ -474,6 +492,7 @@ mod tests { fn test_default_enable_i1pro2_leds() { let settings = AppSettings::default(); assert_eq!(settings.enable_i1pro2_leds, false); + assert_eq!(settings.calibration_stale_days, 30); } #[test] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e3e2181..59ae2a1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ICCery", - "version": "0.8.4", + "version": "0.8.5", "identifier": "com.gronod.iccery", "build": { "frontendDist": "../src" diff --git a/src/index.html b/src/index.html index 9e4adc1..649d17a 100644 --- a/src/index.html +++ b/src/index.html @@ -49,6 +49,8 @@ + +
Calibration: None