diff --git a/LICENSE-delaunator b/LICENSE-delaunator new file mode 100644 index 0000000..d552272 --- /dev/null +++ b/LICENSE-delaunator @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2017, Mapbox + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 61cd4d4..0ab1ad3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1579,6 +1579,7 @@ dependencies = [ name = "iccery" version = "0.1.0" dependencies = [ + "base64 0.22.1", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0ba1c25..060753c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,4 +24,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } tauri-plugin-dialog = "2.7.2" +base64 = "0.22" diff --git a/src-tauri/argyll/linux-x86_64/chartread.mock b/src-tauri/argyll/linux-x86_64/chartread.mock new file mode 100755 index 0000000..b05aada --- /dev/null +++ b/src-tauri/argyll/linux-x86_64/chartread.mock @@ -0,0 +1,30 @@ +#!/bin/bash +# Mock script for chartread -u +# This script simulates the behaviour of chartread for testing purposes. + +echo "Place instrument on calibration tile and hit [Space] to calibrate." + +# We don't really wait for input, just wait 1 second +sleep 1 +echo "Calibration successful." +echo "Hit [Space] to read strip A (or 's' to skip)." + +sleep 1 +echo "Reading strip A..." + +# Emit mock JSON for strip A +cat << 'EOF' +ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]} +EOF + +echo "Hit [Space] to read strip B (or 's' to skip)." +sleep 1 +echo "Reading strip B..." + +# Emit mock JSON for strip B +cat << 'EOF' +ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]} +EOF + +echo "Ready to read... done." +exit 0 diff --git a/src-tauri/argyll/linux-x86_64/colprof.mock b/src-tauri/argyll/linux-x86_64/colprof.mock new file mode 100755 index 0000000..8bbd22c --- /dev/null +++ b/src-tauri/argyll/linux-x86_64/colprof.mock @@ -0,0 +1,20 @@ +#!/bin/bash +# Mock script for colprof +# Simulates colprof execution and outputs progress log + +basename="$1" +# Find last argument if -D or other flags are used +for arg in "$@"; do + basename="$arg" +done + +echo "colprof: Starting profile calculation for $basename" +sleep 1 +echo "Gamut mapping calculation..." +sleep 1 +echo "Fitting cLUT grid points..." +sleep 1 +echo "Writing ICC profile $basename.icc..." +touch "$basename.icc" +echo "Done." +exit 0 diff --git a/src-tauri/argyll/linux-x86_64/profcheck.mock b/src-tauri/argyll/linux-x86_64/profcheck.mock new file mode 100755 index 0000000..78da1f3 --- /dev/null +++ b/src-tauri/argyll/linux-x86_64/profcheck.mock @@ -0,0 +1,14 @@ +#!/bin/bash +# Mock script for profcheck +# Simulates profcheck verification output + +echo "profcheck: Checking profile accuracy..." +sleep 1 +cat << 'EOF' +{"event": "profcheck_complete", "avg_de": 0.85, "max_de": 2.41, "rms_de": 1.02} +EOF +echo "Summary:" +echo " avg. dE = 0.85" +echo " max. dE = 2.41" +echo " rms. dE = 1.02" +exit 0 diff --git a/src-tauri/argyll/reference_gamuts/sRGB.gam b/src-tauri/argyll/reference_gamuts/sRGB.gam new file mode 100644 index 0000000..9826a02 --- /dev/null +++ b/src-tauri/argyll/reference_gamuts/sRGB.gam @@ -0,0 +1,16 @@ +CGATS.17 +NUMBER_OF_FIELDS 4 +BEGIN_DATA_FORMAT +INDEX LAB_L LAB_A LAB_B +END_DATA_FORMAT +NUMBER_OF_SETS 8 +BEGIN_DATA +0 0.0 0.0 0.0 +1 100.0 0.0 0.0 +2 53.2 80.1 67.2 +3 87.7 -86.2 83.2 +4 97.1 -21.6 94.5 +5 32.3 79.2 -107.9 +6 60.3 98.2 -60.8 +7 91.1 -48.1 -14.1 +END_DATA diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 314cdc1..1cd540d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -32,27 +32,62 @@ pub async fn kill_process( #[tauri::command] pub async fn resolve_binary(app: AppHandle, binary_name: String) -> Result { - // Resolve sidecar resource path + let settings = crate::settings::load_settings(app.clone()).unwrap_or_default(); + if let Some(dir) = settings.argyll_binary_dir { + if !dir.trim().is_empty() { + let custom_path = std::path::Path::new(&dir).join(&binary_name); + if custom_path.exists() { + return Ok(custom_path.to_string_lossy().to_string()); + } + } + } + + let platform = match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => "linux-x86_64", + ("windows", "x86_64") => "windows-x86_64", + ("macos", "aarch64") => "macos-aarch64", + _ => "linux-x86_64", + }; + let resource_path = app .path() .resolve( - format!("argyll/linux-x86_64/{}", binary_name), + format!("argyll/{}/{}", platform, binary_name), tauri::path::BaseDirectory::Resource, ) .map_err(|e| e.to_string())?; - // In a real app we'd switch based on target_os, but for Milestone 1 scaffolding we'll mock the linux path. Ok(resource_path.to_string_lossy().to_string()) } #[tauri::command] -pub async fn list_instruments(app: AppHandle, state: State<'_, ProcessManager>) -> Result<(), String> { - // We would use spawn_process directly with instlist binary and parse JSON output. - // For scaffolding, this is a placeholder. +pub async fn detect_instruments(app: AppHandle, state: State<'_, ProcessManager>) -> Result<(), String> { let binary = resolve_binary(app.clone(), "instlist".to_string()).await?; state.spawn(app, "instlist".to_string(), binary, vec![], None).await } +#[tauri::command] +pub async fn extract_gamut( + app: AppHandle, + state: State<'_, ProcessManager>, + icc_path: String, +) -> Result<(), String> { + let binary = resolve_binary(app.clone(), "iccgamut".to_string()).await?; + let path = std::path::Path::new(&icc_path); + let basename = path.file_stem().unwrap().to_str().unwrap(); + let parent_dir = path.parent().map(|p| p.to_str().unwrap()).unwrap_or(""); + + let mut args = vec!["-w".to_string()]; + if !parent_dir.is_empty() { + args.push("-d".to_string()); + args.push(parent_dir.to_string()); + } + args.push(icc_path.clone()); + + let id = format!("iccgamut_{}", basename); + state.spawn(app, id, binary, args, None).await +} + #[derive(Debug, Deserialize, Serialize)] pub struct TargenConfig { pub colour_space: String, @@ -63,6 +98,16 @@ pub struct TargenConfig { pub cwd: String, } +#[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 + pub basename: String, // Must match the .ti1 basename from Stage 1 + pub cwd: String, // Working directory where the .ti1 file resides +} + pub fn build_targen_args(config: &TargenConfig) -> Vec { let mut args = vec![ "-v".to_string(), @@ -92,6 +137,27 @@ pub fn build_targen_args(config: &TargenConfig) -> Vec { args } +pub fn build_printtarg_args(config: &PrinttargConfig) -> Vec { + let mut args = vec![ + "-v".to_string(), + "-u".to_string(), + "-i".to_string(), + config.instrument.clone(), + "-p".to_string(), + config.page_size.clone(), + ]; + + if config.bit_depth == 16 { + args.push("-T".to_string()); + } else { + args.push("-t".to_string()); + } + args.push(config.dpi.to_string()); + + args.push(config.basename.clone()); + args +} + #[tauri::command] pub async fn run_targen( app: AppHandle, @@ -113,6 +179,154 @@ pub async fn run_targen( state.spawn(app, id, binary, args, cwd).await } +#[tauri::command] +pub async fn run_printtarg( + app: AppHandle, + state: State<'_, ProcessManager>, + config: PrinttargConfig, +) -> Result<(), String> { + let binary = resolve_binary(app.clone(), "printtarg".to_string()).await?; + let args = build_printtarg_args(&config); + let id = format!("printtarg_{}", config.basename); + + let cwd = if config.cwd.trim().is_empty() { + None + } else { + Some(config.cwd.clone()) + }; + + state.spawn(app, id, binary, args, cwd).await +} + +#[tauri::command] +pub async fn read_file_base64(path: String) -> Result { + use std::fs; + let bytes = fs::read(&path).map_err(|e| format!("Failed to read {}: {}", path, e))?; + use base64::Engine; + Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ChartreadConfig { + pub basename: String, + pub cwd: String, +} + +pub fn build_chartread_args(config: &ChartreadConfig) -> Vec { + vec![ + "-v".to_string(), + "-u".to_string(), + config.basename.clone(), + ] +} + +#[tauri::command] +pub async fn run_chartread( + app: AppHandle, + state: State<'_, ProcessManager>, + config: ChartreadConfig, +) -> Result<(), String> { + let binary = resolve_binary(app.clone(), "chartread".to_string()).await?; + let args = build_chartread_args(&config); + let id = format!("chartread_{}", config.basename); + + let cwd = if config.cwd.trim().is_empty() { + None + } else { + Some(config.cwd.clone()) + }; + + state.spawn(app, id, binary, args, cwd).await +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ColprofConfig { + pub quality: String, + pub algorithm: String, + pub description: String, + pub copyright: Option, + pub basename: String, + pub cwd: String, +} + +pub fn build_colprof_args(config: &ColprofConfig) -> Vec { + let mut args = vec![ + "-v".to_string(), + "-u".to_string(), + "-q".to_string(), + config.quality.clone(), + "-a".to_string(), + config.algorithm.clone(), + "-D".to_string(), + config.description.clone(), + ]; + + if let Some(copyright) = &config.copyright { + if !copyright.trim().is_empty() { + args.push("-C".to_string()); + args.push(copyright.clone()); + } + } + + args.push(config.basename.clone()); + args +} + +#[tauri::command] +pub async fn run_colprof( + app: AppHandle, + state: State<'_, ProcessManager>, + config: ColprofConfig, +) -> 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 cwd = if config.cwd.trim().is_empty() { + None + } else { + Some(config.cwd.clone()) + }; + + state.spawn(app, id, binary, args, cwd).await +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ProfcheckConfig { + pub ti3_path: String, + pub icc_path: String, + pub cwd: String, +} + +pub fn build_profcheck_args(config: &ProfcheckConfig) -> Vec { + vec![ + "-v".to_string(), + "-k".to_string(), + "-u".to_string(), + config.ti3_path.clone(), + config.icc_path.clone(), + ] +} + +#[tauri::command] +pub async fn run_profcheck( + app: AppHandle, + state: State<'_, ProcessManager>, + config: ProfcheckConfig, +) -> Result<(), String> { + let binary = resolve_binary(app.clone(), "profcheck".to_string()).await?; + let args = build_profcheck_args(&config); + let id = format!("profcheck_{}", config.ti3_path); + + let cwd = if config.cwd.trim().is_empty() { + None + } else { + Some(config.cwd.clone()) + }; + + state.spawn(app, id, binary, args, cwd).await +} + #[cfg(test)] mod tests { use super::*; @@ -144,4 +358,84 @@ mod tests { let args = build_targen_args(&config); assert_eq!(args, vec!["-v", "-d", "4", "-f", "1500", "-B", "8", "cmyk_profile"]); } + + #[test] + fn test_build_printtarg_args_i1_a4_8bit() { + let config = PrinttargConfig { + instrument: "i1".to_string(), + page_size: "A4".to_string(), + bit_depth: 8, + dpi: 100, + basename: "my_profile".to_string(), + cwd: "/tmp".to_string(), + }; + let args = build_printtarg_args(&config); + assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-t", "100", "my_profile"]); + } + + #[test] + fn test_build_printtarg_args_cm_letter_16bit() { + let config = PrinttargConfig { + instrument: "CM".to_string(), + page_size: "Letter".to_string(), + bit_depth: 16, + dpi: 300, + basename: "cmyk_profile".to_string(), + cwd: "/home/user".to_string(), + }; + let args = build_printtarg_args(&config); + assert_eq!(args, vec!["-v", "-u", "-i", "CM", "-p", "Letter", "-T", "300", "cmyk_profile"]); + } + + #[test] + fn test_build_printtarg_args_custom_page_size() { + let config = PrinttargConfig { + instrument: "SS".to_string(), + page_size: "200x400".to_string(), + bit_depth: 8, + dpi: 150, + basename: "custom_target".to_string(), + cwd: "/tmp".to_string(), + }; + let args = build_printtarg_args(&config); + assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-t", "150", "custom_target"]); + } + + #[test] + fn test_build_chartread_args() { + let config = ChartreadConfig { + basename: "my_profile".to_string(), + cwd: "/home/user".to_string(), + }; + let args = build_chartread_args(&config); + assert_eq!(args, vec!["-v", "-u", "my_profile"]); + } + + #[test] + fn test_build_colprof_args() { + let config = ColprofConfig { + quality: "h".to_string(), + algorithm: "l".to_string(), + description: "My Profile".to_string(), + copyright: Some("2026 ACME".to_string()), + basename: "my_profile".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"] + ); + } + + #[test] + fn test_build_profcheck_args() { + let config = ProfcheckConfig { + ti3_path: "my_profile.ti3".to_string(), + icc_path: "my_profile.icc".to_string(), + cwd: "/home/user".to_string(), + }; + let args = build_profcheck_args(&config); + assert_eq!(args, vec!["-v", "-k", "-u", "my_profile.ti3", "my_profile.icc"]); + } } diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index cd1292f..9e665b7 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -45,3 +45,16 @@ pub fn emit_error(app: &AppHandle, id: &str, error: String) { error: Some(error), }); } + +#[derive(Clone, Serialize)] +pub struct JsonRowPayload { + pub id: String, + pub json: String, +} + +pub fn emit_json_row(app: &AppHandle, id: &str, json: String) { + let _ = app.emit("process:json_row", JsonRowPayload { + id: id.to_string(), + json, + }); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9d1f622..15b596c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod events; mod process_manager; +mod settings; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -13,8 +14,16 @@ pub fn run() { commands::send_stdin, commands::kill_process, commands::resolve_binary, - commands::list_instruments, + commands::detect_instruments, + commands::extract_gamut, commands::run_targen, + commands::run_printtarg, + commands::read_file_base64, + commands::run_chartread, + commands::run_colprof, + commands::run_profcheck, + settings::load_settings, + settings::save_settings, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/process_manager.rs b/src-tauri/src/process_manager.rs index 2c8ae27..eb8d715 100644 --- a/src-tauri/src/process_manager.rs +++ b/src-tauri/src/process_manager.rs @@ -38,9 +38,15 @@ impl ProcessManager { let id_clone = id.clone(); let app_clone = app.clone(); tokio::spawn(async move { + const JSON_ROW_PREFIX: &str = "ROW_COLORS_JSON: "; let mut reader = BufReader::new(stdout).lines(); while let Ok(Some(line)) = reader.next_line().await { - emit_stdout(&app_clone, &id_clone, line); + if line.starts_with(JSON_ROW_PREFIX) { + let json_str = line[JSON_ROW_PREFIX.len()..].to_string(); + crate::events::emit_json_row(&app_clone, &id_clone, json_str); + } else { + emit_stdout(&app_clone, &id_clone, line); + } } }); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs new file mode 100644 index 0000000..974d911 --- /dev/null +++ b/src-tauri/src/settings.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use tauri::{AppHandle, Manager}; + +#[derive(Debug, Deserialize, Serialize, Default, Clone)] +pub struct AppSettings { + pub argyll_binary_dir: Option, + pub default_instrument: Option, +} + +#[tauri::command] +pub fn load_settings(app: AppHandle) -> Result { + let path = app.path().app_data_dir().unwrap().join("settings.json"); + if let Ok(data) = fs::read_to_string(path) { + Ok(serde_json::from_str(&data).unwrap_or_default()) + } else { + Ok(AppSettings::default()) + } +} + +#[tauri::command] +pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> { + let path = app.path().app_data_dir().unwrap(); + fs::create_dir_all(&path).map_err(|e| e.to_string())?; + let json = serde_json::to_string_pretty(&settings).unwrap(); + fs::write(path.join("settings.json"), json).map_err(|e| e.to_string()) +} diff --git a/src/index.html b/src/index.html index c2aae67..0d394f9 100644 --- a/src/index.html +++ b/src/index.html @@ -5,6 +5,9 @@ ICCery + + + @@ -12,6 +15,7 @@