feat: Stage 5 Final - 3D Gamut Visualization, Settings, Autodetection & Packaging #16
@@ -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.
|
||||
Generated
+1
@@ -1579,6 +1579,7 @@ dependencies = [
|
||||
name = "iccery"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Executable
+30
@@ -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
|
||||
Executable
+20
@@ -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
|
||||
Executable
+14
@@ -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
|
||||
@@ -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
|
||||
+300
-6
@@ -32,27 +32,62 @@ pub async fn kill_process(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn resolve_binary(app: AppHandle, binary_name: String) -> Result<String, String> {
|
||||
// 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<String> {
|
||||
let mut args = vec![
|
||||
"-v".to_string(),
|
||||
@@ -92,6 +137,27 @@ pub fn build_targen_args(config: &TargenConfig) -> Vec<String> {
|
||||
args
|
||||
}
|
||||
|
||||
pub fn build_printtarg_args(config: &PrinttargConfig) -> Vec<String> {
|
||||
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<String, String> {
|
||||
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<String> {
|
||||
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<String>,
|
||||
pub basename: String,
|
||||
pub cwd: 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(),
|
||||
];
|
||||
|
||||
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<String> {
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
+10
-1
@@ -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");
|
||||
|
||||
@@ -38,10 +38,16 @@ 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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let id_clone2 = id.clone();
|
||||
|
||||
@@ -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<String>,
|
||||
pub default_instrument: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_settings(app: AppHandle) -> Result<AppSettings, String> {
|
||||
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())
|
||||
}
|
||||
+243
-9
@@ -5,6 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ICCery</title>
|
||||
<link rel="stylesheet" href="./styles/main.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||
<script src="./js/delaunator.min.js"></script>
|
||||
<script type="module" src="./js/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -12,6 +15,7 @@
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<h1>ICCery</h1>
|
||||
<button id="openSettingsBtn" class="icon-btn" title="Settings">⚙️</button>
|
||||
</div>
|
||||
<nav class="stepper">
|
||||
<button class="step active" data-step="1">1. Generate Target</button>
|
||||
@@ -84,32 +88,262 @@
|
||||
<!-- Stage 2: printtarg -->
|
||||
<section id="stage-2" class="stage hidden">
|
||||
<h2>Print Layout</h2>
|
||||
<p>Prepare the target for printing based on your instrument.</p>
|
||||
<button class="primary" id="btnLayout">Create Layout (.ti2, .tif)</button>
|
||||
<p>Configure the target layout for your instrument and paper size, then generate printable TIFF images.</p>
|
||||
|
||||
<!-- Colour management warning banner -->
|
||||
<div class="warning-banner" id="cmWarningBanner">
|
||||
<span class="warning-icon">⚠️</span>
|
||||
<div class="warning-text">
|
||||
<strong>CRITICAL: Disable ALL printer driver colour management when printing these targets.</strong>
|
||||
<p>Set your printer driver to "No Colour Adjustment" (Epson), "Off (No Colour Adjustment)" (Canon), or the equivalent setting for your printer. Failure to do this will produce an incorrect profile.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-container">
|
||||
<div class="form-group">
|
||||
<label>Measurement Instrument</label>
|
||||
<select id="instrumentSelect">
|
||||
<option value="i1" selected>X-Rite i1Pro / i1Pro2</option>
|
||||
<option value="p3">X-Rite i1Pro3+</option>
|
||||
<option value="CM">X-Rite ColorMunki</option>
|
||||
<option value="SS">X-Rite SpectroScan</option>
|
||||
<option value="20">X-Rite DTP20</option>
|
||||
<option value="22">X-Rite DTP22</option>
|
||||
<option value="41">X-Rite DTP41</option>
|
||||
<option value="51">X-Rite DTP51</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Page Size</label>
|
||||
<select id="pageSizeSelect">
|
||||
<option value="A4" selected>A4 [210 × 297 mm]</option>
|
||||
<option value="A4R">A4 Landscape [297 × 210 mm]</option>
|
||||
<option value="A3">A3 [297 × 420 mm]</option>
|
||||
<option value="A2">A2 [420 × 594 mm]</option>
|
||||
<option value="Letter">US Letter [215.9 × 279.4 mm]</option>
|
||||
<option value="LetterR">US Letter Landscape [279.4 × 215.9 mm]</option>
|
||||
<option value="Legal">US Legal [215.9 × 355.6 mm]</option>
|
||||
<option value="4x6">4×6 [101.6 × 152.4 mm]</option>
|
||||
<option value="11x17">11×17 [279.4 × 431.8 mm]</option>
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
<div id="customPageSizeRow" class="input-row hidden" style="margin-top: 8px;">
|
||||
<div>
|
||||
<label for="customPageW" class="sub-label">Width (mm)</label>
|
||||
<input type="number" id="customPageW" min="50" max="2000" placeholder="e.g. 200">
|
||||
</div>
|
||||
<div>
|
||||
<label for="customPageH" class="sub-label">Height (mm)</label>
|
||||
<input type="number" id="customPageH" min="50" max="2000" placeholder="e.g. 400">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>TIFF Output</label>
|
||||
<div class="input-row">
|
||||
<div>
|
||||
<label class="sub-label">Bit Depth</label>
|
||||
<div class="radio-group">
|
||||
<label><input type="radio" name="bitDepth" value="8" checked> 8-bit</label>
|
||||
<label><input type="radio" name="bitDepth" value="16"> 16-bit</label>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="primary" id="btnCreateLayout">Create Layout (.ti2 + .tif)</button>
|
||||
|
||||
<div class="log-container hidden" id="printtargLogContainer">
|
||||
<h4>Process Output</h4>
|
||||
<pre id="printtargLog"></pre>
|
||||
</div>
|
||||
|
||||
<!-- TIFF preview gallery (populated dynamically after generation) -->
|
||||
<div class="tiff-gallery hidden" id="tiffGallery">
|
||||
<h3>Generated Target Pages</h3>
|
||||
<div class="gallery-info" id="galleryInfo"></div>
|
||||
<div class="gallery-grid" id="galleryGrid"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stage 3: chartread -->
|
||||
<section id="stage-3" class="stage hidden">
|
||||
<h2>Measure Target</h2>
|
||||
<p>Read the printed patches with your instrument.</p>
|
||||
<p>Read the printed target patches with your spectrophotometer.</p>
|
||||
|
||||
<!-- State & Prompt Area -->
|
||||
<div class="chartread-status">
|
||||
<div class="status-label">State: <span id="chartreadState">IDLE</span></div>
|
||||
<div class="status-prompt" id="chartreadPrompt">
|
||||
Press 'Start Measurement' to begin reading the printed target.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="chartread-actions">
|
||||
<button class="primary" id="btnStartRead">Start Measurement</button>
|
||||
<button class="primary hidden" id="btnCalibrate">✓ Calibrate</button>
|
||||
<button class="secondary hidden" id="btnRetry">↻ Retry Strip</button>
|
||||
<button class="secondary hidden" id="btnSkip">⏭ Skip Strip</button>
|
||||
<button class="danger hidden" id="btnCancel">✕ Cancel</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="read-progress-container hidden" id="readProgressContainer">
|
||||
<div class="read-progress-bar" id="readProgress" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="read-progress-text" id="readProgressText"></div>
|
||||
|
||||
<!-- Live Stats -->
|
||||
<div class="read-stats" id="readStats"></div>
|
||||
|
||||
<!-- Swatch Grid -->
|
||||
<div class="swatch-grid" id="swatchGrid"></div>
|
||||
<button class="primary" id="btnRead">Start Reading (.ti3)</button>
|
||||
|
||||
<!-- Process Log -->
|
||||
<div class="log-container hidden" id="chartreadLogContainer">
|
||||
<h4>Process Output</h4>
|
||||
<pre id="chartreadLog"></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stage 4: colprof -->
|
||||
<section id="stage-4" class="stage hidden">
|
||||
<h2>Create Profile</h2>
|
||||
<p>Calculate the ICC profile.</p>
|
||||
<button class="primary" id="btnProfile">Create Profile (.icc)</button>
|
||||
<p>Calculate the ICC profile from your target measurement data.</p>
|
||||
|
||||
<div class="form-container">
|
||||
<div class="form-group">
|
||||
<label>Profile Quality</label>
|
||||
<select id="colprofQuality">
|
||||
<option value="l">Low (Fast draft)</option>
|
||||
<option value="m" selected>Medium (Standard)</option>
|
||||
<option value="h">High (Recommended)</option>
|
||||
<option value="u">Ultra (Maximum precision - slow)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Profile Description</label>
|
||||
<input type="text" id="colprofDescription" placeholder="e.g. Epson P600 Lustre Photo">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Copyright / Creator (Optional)</label>
|
||||
<input type="text" id="colprofCopyright" placeholder="e.g. Copyright (c) 2026 My Studio">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Algorithm Type</label>
|
||||
<select id="colprofAlgorithm">
|
||||
<option value="l" selected>Lab cLUT (Standard Lookup Table)</option>
|
||||
<option value="x">XYZ cLUT</option>
|
||||
<option value="X">Display XYZ cLUT + Matrix</option>
|
||||
<option value="m">Matrix Only (Simple)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="primary" id="btnCreateProfile">Calculate Profile (.icc)</button>
|
||||
|
||||
<!-- Spinner and Progress Stage -->
|
||||
<div class="spinner-container hidden" id="colprofSpinnerContainer">
|
||||
<div class="spinner"></div>
|
||||
<div class="stage-label" id="colprofStageLabel">Calculating ICC Profile...</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Card -->
|
||||
<div class="success-card hidden" id="colprofSuccessCard">
|
||||
<h3>🎉 Profile Calculation Complete</h3>
|
||||
<p id="colprofSuccessInfo"></p>
|
||||
<button class="primary" id="btnGoToVerify">Proceed to Verification →</button>
|
||||
</div>
|
||||
|
||||
<!-- Process Log -->
|
||||
<div class="log-container hidden" id="colprofLogContainer">
|
||||
<h4>Process Output</h4>
|
||||
<pre id="colprofLog"></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stage 5: profcheck -->
|
||||
<section id="stage-5" class="stage hidden">
|
||||
<h2>Verify</h2>
|
||||
<p>Analyze the generated profile quality.</p>
|
||||
<button class="primary" id="btnVerify">Verify Profile</button>
|
||||
<h2>Verify Profile</h2>
|
||||
<p>Check the numerical accuracy of your profile against the original measurement data.</p>
|
||||
|
||||
<button class="primary" id="btnVerify">Run Profile Verification</button>
|
||||
|
||||
<!-- Report Card -->
|
||||
<div class="report-card hidden" id="profcheckReportCard">
|
||||
<div class="report-header">
|
||||
<h3>Profile Quality Report</h3>
|
||||
<div id="profcheckBadge" class="report-badge">GOOD</div>
|
||||
</div>
|
||||
|
||||
<div class="report-metrics">
|
||||
<div class="metric-box">
|
||||
<span class="metric-value" id="profcheckAvgDe">0.00</span>
|
||||
<span class="metric-label">Average ΔE₀₀</span>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<span class="metric-value" id="profcheckMaxDe">0.00</span>
|
||||
<span class="metric-label">Peak ΔE₀₀</span>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<span class="metric-value" id="profcheckRmsDe">0.00</span>
|
||||
<span class="metric-label">RMS ΔE₀₀</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-footnote">
|
||||
Professional printer profiles typically achieve average ΔE₀₀ < 1.0, and acceptable profiles < 2.0.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3D Gamut Viewer -->
|
||||
<div class="gamut-viewer-section">
|
||||
<h3>3D Gamut Visualization (CIELAB)</h3>
|
||||
<div id="gamutViewerContainer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Process Log -->
|
||||
<div class="log-container hidden" id="profcheckLogContainer">
|
||||
<h4>Verification Output</h4>
|
||||
<pre id="profcheckLog"></pre>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Settings Dialog -->
|
||||
<dialog id="settingsDialog" class="settings-modal">
|
||||
<div class="modal-content">
|
||||
<h2>Application Settings</h2>
|
||||
<div class="form-group">
|
||||
<label for="argyll_binary_dir">ArgyllCMS Binary Directory Override</label>
|
||||
<input type="text" id="argyll_binary_dir" placeholder="Leave empty for bundled sidecars (e.g. /usr/bin)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="default_instrument">Default Instrument Override</label>
|
||||
<input type="text" id="default_instrument" placeholder="e.g. i1">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="saveSettingsBtn" class="primary">Save Settings</button>
|
||||
<button id="closeSettingsBtn" class="secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+24
-2
@@ -1,4 +1,10 @@
|
||||
import { initTargen } from './targen.js';
|
||||
import { initPrinttarg } from './printtarg.js';
|
||||
import { initChartread } from './chartread.js';
|
||||
import { initColprof } from './colprof.js';
|
||||
import { initProfcheck } from './profcheck.js';
|
||||
import { initSettings } from './settings.js';
|
||||
import { initGamutViewer } from './gamut_viewer.js';
|
||||
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
|
||||
@@ -20,11 +26,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
step.classList.add('active');
|
||||
const targetStage = document.getElementById(`stage-${targetStep}`);
|
||||
if (targetStage) {
|
||||
targetStage.classList.remove('hidden');
|
||||
targetStage.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize Stage 1
|
||||
initTargen();
|
||||
const safeInit = (name, initFn) => {
|
||||
try {
|
||||
initFn();
|
||||
} catch (e) {
|
||||
console.error(`[ICCery Init Error] Failed to initialize ${name}:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize all stages & features safely
|
||||
safeInit('Stage 1 (Targen)', initTargen);
|
||||
safeInit('Stage 2 (Printtarg)', initPrinttarg);
|
||||
safeInit('Stage 3 (Chartread)', initChartread);
|
||||
safeInit('Stage 4 (Colprof)', initColprof);
|
||||
safeInit('Stage 5 (Profcheck)', initProfcheck);
|
||||
safeInit('Settings', initSettings);
|
||||
safeInit('Gamut Viewer', initGamutViewer);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
import { startSwatchListener, stopSwatchListener } from './swatch_grid.js';
|
||||
import { setStage3Result } from './colprof.js';
|
||||
|
||||
// Module-level state: set by Stage 2 when it completes
|
||||
let stage2Basename = "";
|
||||
let stage2Cwd = "";
|
||||
|
||||
/**
|
||||
* Called by printtarg.js after Stage 2 completes.
|
||||
*/
|
||||
export function setStage2Result(basename, cwd) {
|
||||
stage2Basename = basename;
|
||||
stage2Cwd = cwd;
|
||||
}
|
||||
|
||||
// State machine states
|
||||
const STATE = {
|
||||
IDLE: "IDLE",
|
||||
CALIBRATING: "CALIBRATING",
|
||||
AWAITING_STRIP: "AWAITING_STRIP",
|
||||
READING: "READING",
|
||||
ERROR: "ERROR",
|
||||
FINISHED: "FINISHED",
|
||||
};
|
||||
|
||||
let currentState = STATE.IDLE;
|
||||
let currentProcessId = "";
|
||||
|
||||
export function initChartread() {
|
||||
const btnStartRead = document.getElementById("btnStartRead");
|
||||
const btnCalibrate = document.getElementById("btnCalibrate");
|
||||
const btnRetry = document.getElementById("btnRetry");
|
||||
const btnSkip = document.getElementById("btnSkip");
|
||||
const btnCancel = document.getElementById("btnCancel");
|
||||
const promptText = document.getElementById("chartreadPrompt");
|
||||
const stateLabel = document.getElementById("chartreadState");
|
||||
const logContainer = document.getElementById("chartreadLogContainer");
|
||||
const logPre = document.getElementById("chartreadLog");
|
||||
|
||||
function setState(newState) {
|
||||
currentState = newState;
|
||||
if (stateLabel) stateLabel.textContent = newState;
|
||||
|
||||
// Show/hide buttons based on state
|
||||
btnCalibrate.classList.add("hidden");
|
||||
btnRetry.classList.add("hidden");
|
||||
btnSkip.classList.add("hidden");
|
||||
btnCancel.classList.add("hidden");
|
||||
btnStartRead.classList.add("hidden");
|
||||
|
||||
switch (newState) {
|
||||
case STATE.IDLE:
|
||||
btnStartRead.classList.remove("hidden");
|
||||
break;
|
||||
case STATE.CALIBRATING:
|
||||
btnCalibrate.classList.remove("hidden");
|
||||
btnCancel.classList.remove("hidden");
|
||||
break;
|
||||
case STATE.AWAITING_STRIP:
|
||||
btnRetry.classList.remove("hidden");
|
||||
btnSkip.classList.remove("hidden");
|
||||
btnCancel.classList.remove("hidden");
|
||||
break;
|
||||
case STATE.READING:
|
||||
btnCancel.classList.remove("hidden");
|
||||
break;
|
||||
case STATE.ERROR:
|
||||
btnRetry.classList.remove("hidden");
|
||||
btnSkip.classList.remove("hidden");
|
||||
btnCancel.classList.remove("hidden");
|
||||
break;
|
||||
case STATE.FINISHED:
|
||||
btnStartRead.classList.remove("hidden");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function setPrompt(text) {
|
||||
if (promptText) promptText.textContent = text;
|
||||
}
|
||||
|
||||
// Start reading button
|
||||
if (btnStartRead) {
|
||||
btnStartRead.addEventListener("click", async () => {
|
||||
if (!stage2Basename) {
|
||||
setPrompt("Error: No .ti2 file available. Complete Stage 2 first.");
|
||||
return;
|
||||
}
|
||||
|
||||
logPre.textContent = "";
|
||||
logContainer.classList.remove("hidden");
|
||||
setState(STATE.CALIBRATING);
|
||||
setPrompt("Starting chartread... waiting for instrument calibration prompt.");
|
||||
|
||||
const config = {
|
||||
basename: stage2Basename,
|
||||
cwd: stage2Cwd,
|
||||
};
|
||||
|
||||
currentProcessId = `chartread_${stage2Basename}`;
|
||||
|
||||
// Start swatch grid listener
|
||||
await startSwatchListener(currentProcessId);
|
||||
|
||||
try {
|
||||
const unlistenStdout = await listen("process:stdout", (event) => {
|
||||
if (event.payload.id !== currentProcessId || !event.payload.line) return;
|
||||
const line = event.payload.line;
|
||||
|
||||
logPre.textContent += line + "\n";
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
|
||||
// Parse prompts for state transitions
|
||||
const lineLower = line.toLowerCase();
|
||||
|
||||
if (lineLower.includes("calibrat") && lineLower.includes("place")) {
|
||||
setState(STATE.CALIBRATING);
|
||||
setPrompt(line);
|
||||
} else if (lineLower.includes("hit") && lineLower.includes("read") && lineLower.includes("strip")) {
|
||||
setState(STATE.AWAITING_STRIP);
|
||||
setPrompt(line);
|
||||
} else if (lineLower.includes("ready to read")) {
|
||||
setState(STATE.AWAITING_STRIP);
|
||||
setPrompt(line);
|
||||
} else if (lineLower.includes("reading strip") || lineLower.includes("processing")) {
|
||||
setState(STATE.READING);
|
||||
setPrompt(line);
|
||||
} else if (lineLower.includes("error") || lineLower.includes("too fast") || lineLower.includes("too slow") || lineLower.includes("misread")) {
|
||||
setState(STATE.ERROR);
|
||||
setPrompt("⚠️ " + line);
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenStderr = await listen("process:stderr", (event) => {
|
||||
if (event.payload.id === currentProcessId && event.payload.line) {
|
||||
logPre.textContent += "ERR: " + event.payload.line + "\n";
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenExit = await listen("process:exit", (event) => {
|
||||
if (event.payload.id !== currentProcessId) return;
|
||||
unlistenStdout();
|
||||
unlistenStderr();
|
||||
unlistenExit();
|
||||
stopSwatchListener();
|
||||
|
||||
if (event.payload.code === 0) {
|
||||
setState(STATE.FINISHED);
|
||||
setPrompt("✅ Measurement complete! .ti3 file has been saved.");
|
||||
logPre.textContent += "\n[SUCCESS] chartread completed. .ti3 file written.\n";
|
||||
setStage3Result(stage2Basename, stage2Cwd);
|
||||
advanceToStage4();
|
||||
} else {
|
||||
setState(STATE.FINISHED);
|
||||
setPrompt(`❌ chartread exited with code ${event.payload.code}.`);
|
||||
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`;
|
||||
}
|
||||
});
|
||||
|
||||
await invoke("run_chartread", { config });
|
||||
|
||||
} catch (err) {
|
||||
setPrompt(`Invoke error: ${err}`);
|
||||
setState(STATE.IDLE);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Calibrate / confirm button — sends space + newline
|
||||
if (btnCalibrate) {
|
||||
btnCalibrate.addEventListener("click", async () => {
|
||||
try {
|
||||
await invoke("send_stdin", { id: currentProcessId, input: " \n" });
|
||||
} catch (e) { console.error("send_stdin error:", e); }
|
||||
});
|
||||
}
|
||||
|
||||
// Retry button — sends space + newline (same as confirm)
|
||||
if (btnRetry) {
|
||||
btnRetry.addEventListener("click", async () => {
|
||||
try {
|
||||
await invoke("send_stdin", { id: currentProcessId, input: " \n" });
|
||||
setState(STATE.READING);
|
||||
setPrompt("Retrying strip read...");
|
||||
} catch (e) { console.error("send_stdin error:", e); }
|
||||
});
|
||||
}
|
||||
|
||||
// Skip button — sends "s\n"
|
||||
if (btnSkip) {
|
||||
btnSkip.addEventListener("click", async () => {
|
||||
try {
|
||||
await invoke("send_stdin", { id: currentProcessId, input: "s\n" });
|
||||
setState(STATE.AWAITING_STRIP);
|
||||
setPrompt("Skipped current strip. Awaiting next...");
|
||||
} catch (e) { console.error("send_stdin error:", e); }
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel button — kills the process
|
||||
if (btnCancel) {
|
||||
btnCancel.addEventListener("click", async () => {
|
||||
try {
|
||||
await invoke("kill_process", { id: currentProcessId });
|
||||
stopSwatchListener();
|
||||
setState(STATE.IDLE);
|
||||
setPrompt("Measurement cancelled.");
|
||||
} catch (e) { console.error("kill_process error:", e); }
|
||||
});
|
||||
}
|
||||
|
||||
// Start in IDLE state
|
||||
setState(STATE.IDLE);
|
||||
setPrompt("Press 'Start Measurement' to begin reading the printed target.");
|
||||
}
|
||||
|
||||
function advanceToStage4() {
|
||||
const steps = document.querySelectorAll('.step');
|
||||
const stages = document.querySelectorAll('.stage');
|
||||
|
||||
steps.forEach(s => s.classList.remove('active'));
|
||||
if (steps[3]) steps[3].classList.add('active');
|
||||
|
||||
stages.forEach(s => {
|
||||
s.classList.remove('active');
|
||||
s.classList.add('hidden');
|
||||
});
|
||||
if (stages[3]) {
|
||||
stages[3].classList.remove('hidden');
|
||||
stages[3].classList.add('active');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Convert CIE L*a*b* to sRGB [0–255] clamped values.
|
||||
* Uses D50 illuminant reference white (standard for ICC profiles).
|
||||
* @param {number} L - Lightness [0, 100]
|
||||
* @param {number} a - Green-red axis [-128, 127]
|
||||
* @param {number} b - Blue-yellow axis [-128, 127]
|
||||
* @returns {number[]} [r, g, b] each in [0, 255]
|
||||
*/
|
||||
export function labToSrgb(L, a, b) {
|
||||
// D50 reference white
|
||||
const Xn = 0.9642;
|
||||
const Yn = 1.0;
|
||||
const Zn = 0.8249;
|
||||
|
||||
// Lab → XYZ
|
||||
const fy = (L + 16) / 116;
|
||||
const fx = a / 500 + fy;
|
||||
const fz = fy - b / 200;
|
||||
|
||||
const delta = 6 / 29;
|
||||
const delta3 = delta * delta * delta;
|
||||
|
||||
const X = Xn * (fx > delta ? fx * fx * fx : (fx - 16 / 116) * 3 * delta * delta);
|
||||
const Y = Yn * (fy > delta ? fy * fy * fy : (fy - 16 / 116) * 3 * delta * delta);
|
||||
const Z = Zn * (fz > delta ? fz * fz * fz : (fz - 16 / 116) * 3 * delta * delta);
|
||||
|
||||
// XYZ (D50) → linear sRGB via Bradford-adapted D50→D65 matrix
|
||||
// Combined D50-adapted XYZ to sRGB matrix
|
||||
const lr = 3.1338561 * X - 1.6168667 * Y - 0.4906146 * Z;
|
||||
const lg = -0.9787684 * X + 1.9161415 * Y + 0.0334540 * Z;
|
||||
const lb = 0.0719453 * X - 0.2289914 * Y + 1.4052427 * Z;
|
||||
|
||||
// Linear sRGB → gamma-corrected sRGB
|
||||
function gammaCorrect(c) {
|
||||
return c <= 0.0031308
|
||||
? 12.92 * c
|
||||
: 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
const r = Math.round(Math.max(0, Math.min(255, gammaCorrect(lr) * 255)));
|
||||
const g = Math.round(Math.max(0, Math.min(255, gammaCorrect(lg) * 255)));
|
||||
const bVal = Math.round(Math.max(0, Math.min(255, gammaCorrect(lb) * 255)));
|
||||
|
||||
return [r, g, bVal];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert device RGB percentages [0–100] to CSS rgb string.
|
||||
* @param {number[]} device - [R%, G%, B%] each in [0, 100]
|
||||
* @returns {string} CSS rgb() string
|
||||
*/
|
||||
export function deviceRgbToCss(device) {
|
||||
const r = Math.round((device[0] / 100) * 255);
|
||||
const g = Math.round((device[1] / 100) * 255);
|
||||
const b = Math.round((device[2] / 100) * 255);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Lab triplet to CSS rgb string.
|
||||
* @param {number[]} lab - [L, a, b]
|
||||
* @returns {string} CSS rgb() string
|
||||
*/
|
||||
export function labToCss(lab) {
|
||||
const [r, g, b] = labToSrgb(lab[0], lab[1], lab[2]);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
import { setStage4Result } from './profcheck.js';
|
||||
|
||||
let chartreadBasename = "";
|
||||
let chartreadCwd = "";
|
||||
|
||||
/**
|
||||
* Called by chartread.js after Stage 3 completes.
|
||||
*/
|
||||
export function setStage3Result(basename, cwd) {
|
||||
chartreadBasename = basename;
|
||||
chartreadCwd = cwd;
|
||||
}
|
||||
|
||||
export function initColprof() {
|
||||
const qualitySelect = document.getElementById("colprofQuality");
|
||||
const algorithmSelect = document.getElementById("colprofAlgorithm");
|
||||
const descInput = document.getElementById("colprofDescription");
|
||||
const copyrightInput = document.getElementById("colprofCopyright");
|
||||
const btnCreateProfile = document.getElementById("btnCreateProfile");
|
||||
const spinnerContainer = document.getElementById("colprofSpinnerContainer");
|
||||
const stageLabel = document.getElementById("colprofStageLabel");
|
||||
const logContainer = document.getElementById("colprofLogContainer");
|
||||
const logPre = document.getElementById("colprofLog");
|
||||
const successCard = document.getElementById("colprofSuccessCard");
|
||||
const successInfo = document.getElementById("colprofSuccessInfo");
|
||||
const btnGoToVerify = document.getElementById("btnGoToVerify");
|
||||
|
||||
if (!btnCreateProfile) return;
|
||||
|
||||
btnCreateProfile.addEventListener("click", async () => {
|
||||
const basename = chartreadBasename || "test_target";
|
||||
const cwd = chartreadCwd || "";
|
||||
|
||||
const description = descInput.value.trim() || basename;
|
||||
|
||||
logPre.textContent = "";
|
||||
logContainer.classList.remove("hidden");
|
||||
spinnerContainer.classList.remove("hidden");
|
||||
successCard.classList.add("hidden");
|
||||
btnCreateProfile.disabled = true;
|
||||
stageLabel.textContent = "Initializing colprof...";
|
||||
|
||||
const config = {
|
||||
quality: qualitySelect.value,
|
||||
algorithm: algorithmSelect.value,
|
||||
description: description,
|
||||
copyright: copyrightInput.value.trim() || null,
|
||||
basename: basename,
|
||||
cwd: cwd,
|
||||
};
|
||||
|
||||
const processId = `colprof_${basename}`;
|
||||
|
||||
try {
|
||||
const unlistenStdout = await listen("process:stdout", (event) => {
|
||||
if (event.payload.id === processId && event.payload.line) {
|
||||
const line = event.payload.line;
|
||||
logPre.textContent += line + "\n";
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
|
||||
// Parse coarse progress stages from stdout
|
||||
const lineLower = line.toLowerCase();
|
||||
if (lineLower.includes("gamut mapping")) {
|
||||
stageLabel.textContent = "Gamut mapping calculation in progress...";
|
||||
} else if (lineLower.includes("fitting") || lineLower.includes("clut")) {
|
||||
stageLabel.textContent = "Fitting cLUT grid points...";
|
||||
} else if (lineLower.includes("writing") || lineLower.includes("icc profile")) {
|
||||
stageLabel.textContent = "Writing ICC profile header & tags...";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenStderr = await listen("process:stderr", (event) => {
|
||||
if (event.payload.id === processId && event.payload.line) {
|
||||
logPre.textContent += "ERR: " + event.payload.line + "\n";
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenExit = await listen("process:exit", (event) => {
|
||||
if (event.payload.id === processId) {
|
||||
unlistenStdout();
|
||||
unlistenStderr();
|
||||
unlistenExit();
|
||||
spinnerContainer.classList.add("hidden");
|
||||
btnCreateProfile.disabled = false;
|
||||
|
||||
if (event.payload.code === 0) {
|
||||
logPre.textContent += "\n[SUCCESS] colprof completed. Profile generated.\n";
|
||||
const iccFilename = `${basename}.icc`;
|
||||
successInfo.textContent = `Profile: ${iccFilename} (${description})`;
|
||||
successCard.classList.remove("hidden");
|
||||
|
||||
setStage4Result(basename, cwd);
|
||||
} else {
|
||||
logPre.textContent += `\n[ERROR] colprof exited with code ${event.payload.code}.\n`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await invoke("run_colprof", { config });
|
||||
} catch (err) {
|
||||
logPre.textContent += `\n[INVOKE ERROR] ${err}\n`;
|
||||
spinnerContainer.classList.add("hidden");
|
||||
btnCreateProfile.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (btnGoToVerify) {
|
||||
btnGoToVerify.addEventListener("click", () => {
|
||||
advanceToStage5();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function advanceToStage5() {
|
||||
const steps = document.querySelectorAll('.step');
|
||||
const stages = document.querySelectorAll('.stage');
|
||||
|
||||
steps.forEach(s => s.classList.remove('active'));
|
||||
if (steps[4]) steps[4].classList.add('active');
|
||||
|
||||
stages.forEach(s => {
|
||||
s.classList.remove('active');
|
||||
s.classList.add('hidden');
|
||||
});
|
||||
if (stages[4]) {
|
||||
stages[4].classList.remove('hidden');
|
||||
stages[4].classList.add('active');
|
||||
}
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Compute CIEDE2000 colour difference (ΔE₀₀) between two L*a*b* values.
|
||||
* Standard parametric factors: kL=1, kC=1, kH=1.
|
||||
*
|
||||
* Reference: Sharma, Wu, Dalal (2005) "The CIEDE2000 Color-Difference Formula"
|
||||
*
|
||||
* @param {number[]} lab1 - [L1, a1, b1]
|
||||
* @param {number[]} lab2 - [L2, a2, b2]
|
||||
* @returns {number} ΔE₀₀ value
|
||||
*/
|
||||
export function computeDeltaE00(lab1, lab2) {
|
||||
const [L1, a1, b1] = lab1;
|
||||
const [L2, a2, b2] = lab2;
|
||||
|
||||
const kL = 1, kC = 1, kH = 1;
|
||||
|
||||
const C1ab = Math.sqrt(a1 * a1 + b1 * b1);
|
||||
const C2ab = Math.sqrt(a2 * a2 + b2 * b2);
|
||||
const Cab_avg = (C1ab + C2ab) / 2;
|
||||
|
||||
const Cab_avg7 = Math.pow(Cab_avg, 7);
|
||||
const G = 0.5 * (1 - Math.sqrt(Cab_avg7 / (Cab_avg7 + Math.pow(25, 7))));
|
||||
|
||||
const a1p = a1 * (1 + G);
|
||||
const a2p = a2 * (1 + G);
|
||||
|
||||
const C1p = Math.sqrt(a1p * a1p + b1 * b1);
|
||||
const C2p = Math.sqrt(a2p * a2p + b2 * b2);
|
||||
|
||||
let h1p = Math.atan2(b1, a1p) * (180 / Math.PI);
|
||||
if (h1p < 0) h1p += 360;
|
||||
let h2p = Math.atan2(b2, a2p) * (180 / Math.PI);
|
||||
if (h2p < 0) h2p += 360;
|
||||
|
||||
const dLp = L2 - L1;
|
||||
const dCp = C2p - C1p;
|
||||
|
||||
let dhp;
|
||||
if (C1p * C2p === 0) {
|
||||
dhp = 0;
|
||||
} else if (Math.abs(h2p - h1p) <= 180) {
|
||||
dhp = h2p - h1p;
|
||||
} else if (h2p - h1p > 180) {
|
||||
dhp = h2p - h1p - 360;
|
||||
} else {
|
||||
dhp = h2p - h1p + 360;
|
||||
}
|
||||
const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp * Math.PI / 180) / 2);
|
||||
|
||||
const Lp_avg = (L1 + L2) / 2;
|
||||
const Cp_avg = (C1p + C2p) / 2;
|
||||
|
||||
let hp_avg;
|
||||
if (C1p * C2p === 0) {
|
||||
hp_avg = h1p + h2p;
|
||||
} else if (Math.abs(h1p - h2p) <= 180) {
|
||||
hp_avg = (h1p + h2p) / 2;
|
||||
} else if (h1p + h2p < 360) {
|
||||
hp_avg = (h1p + h2p + 360) / 2;
|
||||
} else {
|
||||
hp_avg = (h1p + h2p - 360) / 2;
|
||||
}
|
||||
|
||||
const T = 1
|
||||
- 0.17 * Math.cos((hp_avg - 30) * Math.PI / 180)
|
||||
+ 0.24 * Math.cos(2 * hp_avg * Math.PI / 180)
|
||||
+ 0.32 * Math.cos((3 * hp_avg + 6) * Math.PI / 180)
|
||||
- 0.20 * Math.cos((4 * hp_avg - 63) * Math.PI / 180);
|
||||
|
||||
const SL = 1 + (0.015 * Math.pow(Lp_avg - 50, 2)) / Math.sqrt(20 + Math.pow(Lp_avg - 50, 2));
|
||||
const SC = 1 + 0.045 * Cp_avg;
|
||||
const SH = 1 + 0.015 * Cp_avg * T;
|
||||
|
||||
const Cp_avg7 = Math.pow(Cp_avg, 7);
|
||||
const RT_term = -2 * Math.sqrt(Cp_avg7 / (Cp_avg7 + Math.pow(25, 7)))
|
||||
* Math.sin(60 * Math.exp(-Math.pow((hp_avg - 275) / 25, 2)) * Math.PI / 180);
|
||||
|
||||
const dE = Math.sqrt(
|
||||
Math.pow(dLp / (kL * SL), 2) +
|
||||
Math.pow(dCp / (kC * SC), 2) +
|
||||
Math.pow(dHp / (kH * SH), 2) +
|
||||
RT_term * (dCp / (kC * SC)) * (dHp / (kH * SH))
|
||||
);
|
||||
|
||||
return dE;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
|
||||
let scene, camera, renderer, controls;
|
||||
let currentProfileMesh, sRgbMesh;
|
||||
|
||||
export async function initGamutViewer() {
|
||||
try {
|
||||
const container = document.getElementById('gamutViewerContainer');
|
||||
if (!container || typeof THREE === 'undefined') return;
|
||||
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x111116);
|
||||
|
||||
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 1, 1000);
|
||||
camera.position.set(150, 100, 150);
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
if (typeof THREE.OrbitControls !== 'undefined') {
|
||||
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
}
|
||||
|
||||
// Add CIELAB orientation helpers (Axes: X=a*, Y=L*, Z=b*)
|
||||
const axesHelper = new THREE.AxesHelper(80);
|
||||
scene.add(axesHelper);
|
||||
|
||||
// Lights
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
scene.add(ambientLight);
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
dirLight.position.set(100, 200, 100);
|
||||
scene.add(dirLight);
|
||||
|
||||
animate();
|
||||
} catch (err) {
|
||||
console.warn("Gamut viewer initialization notice:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
if (controls) controls.update();
|
||||
if (renderer && scene && camera) renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
function parseCGATS(text) {
|
||||
const lines = text.split('\n');
|
||||
let dataStarted = false;
|
||||
const points = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('BEGIN_DATA')) {
|
||||
dataStarted = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('END_DATA')) break;
|
||||
|
||||
if (dataStarted) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 4) {
|
||||
const L = parseFloat(parts[1]);
|
||||
const a = parseFloat(parts[2]);
|
||||
const b = parseFloat(parts[3]);
|
||||
if (!isNaN(L) && !isNaN(a) && !isNaN(b)) {
|
||||
points.push({ L, a, b });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
export async function loadGamutMesh(gamFilePath, color = 0x3b82f6, isWireframe = false) {
|
||||
try {
|
||||
const base64Data = await invoke('read_file_base64', { path: gamFilePath });
|
||||
const text = atob(base64Data);
|
||||
const points = parseCGATS(text);
|
||||
if (points.length < 3) return null;
|
||||
|
||||
const coords = [];
|
||||
const vertices = new Float32Array(points.length * 3);
|
||||
|
||||
points.forEach((pt, i) => {
|
||||
coords.push(pt.a, pt.b);
|
||||
vertices[i * 3] = pt.a; // X = a*
|
||||
vertices[i * 3 + 1] = pt.L; // Y = L*
|
||||
vertices[i * 3 + 2] = pt.b; // Z = b*
|
||||
});
|
||||
|
||||
if (typeof Delaunator === 'undefined') return null;
|
||||
const delaunay = new Delaunator(coords);
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
|
||||
geometry.setIndex(Array.from(delaunay.triangles));
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
const material = new THREE.MeshLambertMaterial({
|
||||
color: color,
|
||||
wireframe: isWireframe,
|
||||
transparent: true,
|
||||
opacity: isWireframe ? 0.4 : 0.75,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
scene.add(mesh);
|
||||
return mesh;
|
||||
} catch (e) {
|
||||
console.error("Failed to load gamut mesh:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
import { setStage2Result } from './chartread.js';
|
||||
|
||||
// Module-level state: set by Stage 1 when it completes
|
||||
let stage1Basename = "";
|
||||
let stage1Cwd = "";
|
||||
|
||||
/**
|
||||
* Called by targen.js (or app.js) after Stage 1 completes.
|
||||
* Passes the basename and working directory forward.
|
||||
*/
|
||||
export function setStage1Result(basename, cwd) {
|
||||
stage1Basename = basename;
|
||||
stage1Cwd = cwd;
|
||||
}
|
||||
|
||||
export function initPrinttarg() {
|
||||
const instrumentSelect = document.getElementById("instrumentSelect");
|
||||
const pageSizeSelect = document.getElementById("pageSizeSelect");
|
||||
const customPageSizeRow = document.getElementById("customPageSizeRow");
|
||||
const customPageW = document.getElementById("customPageW");
|
||||
const customPageH = document.getElementById("customPageH");
|
||||
const bitDepthRadios = document.querySelectorAll('input[name="bitDepth"]');
|
||||
const tiffDpi = document.getElementById("tiffDpi");
|
||||
const btnCreateLayout = document.getElementById("btnCreateLayout");
|
||||
const logContainer = document.getElementById("printtargLogContainer");
|
||||
const logPre = document.getElementById("printtargLog");
|
||||
const tiffGallery = document.getElementById("tiffGallery");
|
||||
const galleryInfo = document.getElementById("galleryInfo");
|
||||
const galleryGrid = document.getElementById("galleryGrid");
|
||||
|
||||
// Show/hide custom page size inputs
|
||||
pageSizeSelect.addEventListener("change", (e) => {
|
||||
if (e.target.value === "custom") {
|
||||
customPageSizeRow.classList.remove("hidden");
|
||||
} else {
|
||||
customPageSizeRow.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
// Create Layout button
|
||||
btnCreateLayout.addEventListener("click", async () => {
|
||||
// Validate that Stage 1 has been completed
|
||||
if (!stage1Basename) {
|
||||
logPre.textContent = "[ERROR] No .ti1 file available. Complete Stage 1 first.\n";
|
||||
logContainer.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
logPre.textContent = "";
|
||||
logContainer.classList.remove("hidden");
|
||||
tiffGallery.classList.add("hidden");
|
||||
galleryGrid.innerHTML = "";
|
||||
btnCreateLayout.disabled = true;
|
||||
|
||||
// Determine page size
|
||||
let pageSize = pageSizeSelect.value;
|
||||
if (pageSize === "custom") {
|
||||
const w = parseInt(customPageW.value, 10);
|
||||
const h = parseInt(customPageH.value, 10);
|
||||
if (!w || !h || w < 50 || h < 50) {
|
||||
logPre.textContent = "[ERROR] Custom page size must have width and height ≥ 50mm.\n";
|
||||
btnCreateLayout.disabled = false;
|
||||
return;
|
||||
}
|
||||
pageSize = `${w}x${h}`;
|
||||
}
|
||||
|
||||
// Determine bit depth
|
||||
let bitDepth = 8;
|
||||
bitDepthRadios.forEach(radio => {
|
||||
if (radio.checked) bitDepth = parseInt(radio.value, 10);
|
||||
});
|
||||
|
||||
const config = {
|
||||
instrument: instrumentSelect.value,
|
||||
page_size: pageSize,
|
||||
bit_depth: bitDepth,
|
||||
dpi: parseInt(tiffDpi.value, 10),
|
||||
basename: stage1Basename,
|
||||
cwd: stage1Cwd,
|
||||
};
|
||||
|
||||
const processId = `printtarg_${stage1Basename}`;
|
||||
|
||||
// Accumulate all stdout lines to extract JSON manifest at the end
|
||||
let stdoutAccumulator = "";
|
||||
|
||||
try {
|
||||
const unlistenStdout = await listen("process:stdout", (event) => {
|
||||
if (event.payload.id === processId && event.payload.line) {
|
||||
stdoutAccumulator += event.payload.line + "\n";
|
||||
logPre.textContent += event.payload.line + "\n";
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenStderr = await listen("process:stderr", (event) => {
|
||||
if (event.payload.id === processId && event.payload.line) {
|
||||
logPre.textContent += "ERR: " + event.payload.line + "\n";
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenExit = await listen("process:exit", (event) => {
|
||||
if (event.payload.id === processId) {
|
||||
unlistenStdout();
|
||||
unlistenStderr();
|
||||
unlistenExit();
|
||||
|
||||
if (event.payload.code === 0) {
|
||||
logPre.textContent += "\n[SUCCESS] printtarg completed successfully.\n";
|
||||
btnCreateLayout.disabled = false;
|
||||
|
||||
// Parse the JSON manifest from stdout
|
||||
const manifest = extractManifest(stdoutAccumulator);
|
||||
if (manifest && manifest.pages && manifest.pages.length > 0) {
|
||||
renderTiffGallery(manifest, config.cwd);
|
||||
}
|
||||
|
||||
setStage2Result(stage1Basename, stage1Cwd);
|
||||
advanceToStage3();
|
||||
} else {
|
||||
logPre.textContent += `\n[ERROR] printtarg exited with code ${event.payload.code}.\n`;
|
||||
btnCreateLayout.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
logPre.textContent = "Starting printtarg...\n";
|
||||
await invoke("run_printtarg", { config });
|
||||
|
||||
} catch (err) {
|
||||
logPre.textContent += `\n[INVOKE ERROR] ${err}\n`;
|
||||
btnCreateLayout.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Extract the JSON manifest object from the accumulated stdout.
|
||||
* The manifest is emitted by printtarg -u as a JSON block starting with { and ending with }.
|
||||
* It always appears at the end of stdout, after all "Creating file..." lines.
|
||||
*/
|
||||
function extractManifest(stdout) {
|
||||
try {
|
||||
// Find the last JSON object in the output
|
||||
const jsonStart = stdout.lastIndexOf('{\n "event": "manifest"');
|
||||
if (jsonStart === -1) return null;
|
||||
const jsonEnd = stdout.indexOf('\n}', jsonStart);
|
||||
if (jsonEnd === -1) return null;
|
||||
const jsonStr = stdout.substring(jsonStart, jsonEnd + 2);
|
||||
return JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse printtarg manifest:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render TIFF preview gallery using base64-encoded images from the backend.
|
||||
* @param {Object} manifest - The parsed JSON manifest from printtarg -u
|
||||
* @param {string} cwd - The working directory where TIFFs were generated
|
||||
*/
|
||||
async function renderTiffGallery(manifest, cwd) {
|
||||
tiffGallery.classList.remove("hidden");
|
||||
|
||||
const pageCount = manifest.pages.length;
|
||||
const totalPatches = manifest.pages.reduce((sum, p) => sum + p.patches, 0);
|
||||
const dims = manifest.pages[0];
|
||||
galleryInfo.textContent = `${pageCount} page(s) · ${totalPatches} patches · ${dims.width_mm} × ${dims.height_mm} mm per page`;
|
||||
|
||||
for (const page of manifest.pages) {
|
||||
const sep = cwd.includes('\\') ? '\\' : '/';
|
||||
const filePath = cwd ? `${cwd}${sep}${page.filename}` : page.filename;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "gallery-card";
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "gallery-label";
|
||||
label.textContent = `${page.filename} (${page.patches} patches)`;
|
||||
card.appendChild(label);
|
||||
|
||||
try {
|
||||
const base64Data = await invoke("read_file_base64", { path: filePath });
|
||||
const img = document.createElement("img");
|
||||
img.src = `data:image/tiff;base64,${base64Data}`;
|
||||
img.alt = page.filename;
|
||||
// TIFF may not render natively in all browsers — provide a fallback
|
||||
img.onerror = () => {
|
||||
img.remove();
|
||||
const fallback = document.createElement("div");
|
||||
fallback.className = "gallery-fallback";
|
||||
fallback.innerHTML = `<span class="file-icon">📄</span><span>${page.filename}</span><span class="file-size">${page.patches} patches</span>`;
|
||||
card.insertBefore(fallback, label.nextSibling);
|
||||
};
|
||||
card.appendChild(img);
|
||||
} catch (err) {
|
||||
const fallback = document.createElement("div");
|
||||
fallback.className = "gallery-fallback";
|
||||
fallback.innerHTML = `<span class="file-icon">📄</span><span>${page.filename}</span><span class="file-size">Preview unavailable</span>`;
|
||||
card.appendChild(fallback);
|
||||
}
|
||||
|
||||
galleryGrid.appendChild(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function advanceToStage3() {
|
||||
const steps = document.querySelectorAll('.step');
|
||||
const stages = document.querySelectorAll('.stage');
|
||||
|
||||
steps.forEach(s => s.classList.remove('active'));
|
||||
if (steps[2]) steps[2].classList.add('active');
|
||||
|
||||
stages.forEach(s => {
|
||||
s.classList.remove('active');
|
||||
s.classList.add('hidden');
|
||||
});
|
||||
if (stages[2]) {
|
||||
stages[2].classList.remove('hidden');
|
||||
stages[2].classList.add('active');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
|
||||
let profileBasename = "";
|
||||
let profileCwd = "";
|
||||
|
||||
/**
|
||||
* Called by colprof.js after Stage 4 completes.
|
||||
*/
|
||||
export function setStage4Result(basename, cwd) {
|
||||
profileBasename = basename;
|
||||
profileCwd = cwd;
|
||||
}
|
||||
|
||||
export function initProfcheck() {
|
||||
const btnVerify = document.getElementById("btnVerify");
|
||||
const logContainer = document.getElementById("profcheckLogContainer");
|
||||
const logPre = document.getElementById("profcheckLog");
|
||||
const reportCard = document.getElementById("profcheckReportCard");
|
||||
const avgDeEl = document.getElementById("profcheckAvgDe");
|
||||
const maxDeEl = document.getElementById("profcheckMaxDe");
|
||||
const rmsDeEl = document.getElementById("profcheckRmsDe");
|
||||
const badgeEl = document.getElementById("profcheckBadge");
|
||||
|
||||
if (!btnVerify) return;
|
||||
|
||||
btnVerify.addEventListener("click", async () => {
|
||||
const basename = profileBasename || "test_target";
|
||||
const cwd = profileCwd || "";
|
||||
|
||||
const sep = cwd.includes('\\') ? '\\' : '/';
|
||||
const ti3Path = cwd ? `${cwd}${sep}${basename}.ti3` : `${basename}.ti3`;
|
||||
const iccPath = cwd ? `${cwd}${sep}${basename}.icc` : `${basename}.icc`;
|
||||
|
||||
logPre.textContent = "";
|
||||
logContainer.classList.remove("hidden");
|
||||
reportCard.classList.add("hidden");
|
||||
btnVerify.disabled = true;
|
||||
|
||||
const config = {
|
||||
ti3_path: ti3Path,
|
||||
icc_path: iccPath,
|
||||
cwd: cwd,
|
||||
};
|
||||
|
||||
const processId = `profcheck_${ti3Path}`;
|
||||
let stdoutAccumulator = "";
|
||||
|
||||
try {
|
||||
const unlistenStdout = await listen("process:stdout", (event) => {
|
||||
if (event.payload.id === processId && event.payload.line) {
|
||||
stdoutAccumulator += event.payload.line + "\n";
|
||||
logPre.textContent += event.payload.line + "\n";
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenStderr = await listen("process:stderr", (event) => {
|
||||
if (event.payload.id === processId && event.payload.line) {
|
||||
logPre.textContent += "ERR: " + event.payload.line + "\n";
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenExit = await listen("process:exit", (event) => {
|
||||
if (event.payload.id === processId) {
|
||||
unlistenStdout();
|
||||
unlistenStderr();
|
||||
unlistenExit();
|
||||
btnVerify.disabled = false;
|
||||
|
||||
if (event.payload.code === 0) {
|
||||
logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n";
|
||||
parseAndRenderReport(stdoutAccumulator);
|
||||
} else {
|
||||
logPre.textContent += `\n[ERROR] profcheck exited with code ${event.payload.code}.\n`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await invoke("run_profcheck", { config });
|
||||
} catch (err) {
|
||||
logPre.textContent += `\n[INVOKE ERROR] ${err}\n`;
|
||||
btnVerify.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
function parseAndRenderReport(stdout) {
|
||||
reportCard.classList.remove("hidden");
|
||||
|
||||
let avgDe = 0.0;
|
||||
let maxDe = 0.0;
|
||||
let rmsDe = 0.0;
|
||||
|
||||
// Check if JSON output is present
|
||||
const jsonMatch = stdout.match(/\{[\s\S]*"avg_de"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const json = JSON.parse(jsonMatch[0]);
|
||||
avgDe = json.avg_de || 0;
|
||||
maxDe = json.max_de || json.peak_de || 0;
|
||||
rmsDe = json.rms_de || 0;
|
||||
} catch (e) {
|
||||
console.error("JSON parse error:", e);
|
||||
}
|
||||
} else {
|
||||
// Regex fallbacks for standard profcheck output
|
||||
const avgMatch = stdout.match(/avg\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/average\s*dE\s*:\s*([\d\.]+)/i);
|
||||
const maxMatch = stdout.match(/max\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/peak\s*dE\s*:\s*([\d\.]+)/i);
|
||||
const rmsMatch = stdout.match(/rms\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/rms\s*dE\s*:\s*([\d\.]+)/i);
|
||||
|
||||
if (avgMatch) avgDe = parseFloat(avgMatch[1]);
|
||||
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
|
||||
if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]);
|
||||
}
|
||||
|
||||
avgDeEl.textContent = avgDe.toFixed(2);
|
||||
maxDeEl.textContent = maxDe.toFixed(2);
|
||||
rmsDeEl.textContent = rmsDe.toFixed(2);
|
||||
|
||||
// Quality verdict
|
||||
badgeEl.className = "report-badge";
|
||||
if (avgDe < 1.0) {
|
||||
badgeEl.textContent = "EXCELLENT";
|
||||
badgeEl.classList.add("badge-excellent");
|
||||
} else if (avgDe < 2.0) {
|
||||
badgeEl.textContent = "GOOD";
|
||||
badgeEl.classList.add("badge-good");
|
||||
} else if (avgDe < 4.0) {
|
||||
badgeEl.textContent = "ACCEPTABLE";
|
||||
badgeEl.classList.add("badge-acceptable");
|
||||
} else {
|
||||
badgeEl.textContent = "POOR";
|
||||
badgeEl.classList.add("badge-poor");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
|
||||
export async function initSettings() {
|
||||
const dialog = document.getElementById('settingsDialog');
|
||||
const openBtn = document.getElementById('openSettingsBtn');
|
||||
const saveBtn = document.getElementById('saveSettingsBtn');
|
||||
const closeBtn = document.getElementById('closeSettingsBtn');
|
||||
|
||||
if (!dialog || !openBtn) return;
|
||||
|
||||
openBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const settings = await invoke('load_settings');
|
||||
document.getElementById('argyll_binary_dir').value = settings.argyll_binary_dir || '';
|
||||
document.getElementById('default_instrument').value = settings.default_instrument || '';
|
||||
dialog.showModal();
|
||||
} catch (e) {
|
||||
console.error("Failed to load settings:", e);
|
||||
}
|
||||
});
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => dialog.close());
|
||||
}
|
||||
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
const settings = {
|
||||
argyll_binary_dir: document.getElementById('argyll_binary_dir').value.trim() || null,
|
||||
default_instrument: document.getElementById('default_instrument').value.trim() || null
|
||||
};
|
||||
try {
|
||||
await invoke('save_settings', { settings });
|
||||
dialog.close();
|
||||
} catch (e) {
|
||||
console.error("Failed to save settings:", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { computeDeltaE00 } from './delta_e.js';
|
||||
import { labToCss, deviceRgbToCss } from './color_convert.js';
|
||||
|
||||
const { listen } = window.__TAURI__.event;
|
||||
|
||||
let unlistenJsonRow = null;
|
||||
|
||||
/**
|
||||
* Start listening for row events from a chartread process.
|
||||
* @param {string} processId - The process ID (e.g. "chartread_my_profile")
|
||||
*/
|
||||
export async function startSwatchListener(processId) {
|
||||
const grid = document.getElementById("swatchGrid");
|
||||
const progressBar = document.getElementById("readProgress");
|
||||
const progressText = document.getElementById("readProgressText");
|
||||
const statsPanel = document.getElementById("readStats");
|
||||
|
||||
// Clear previous state
|
||||
grid.innerHTML = "";
|
||||
let totalPatches = 0;
|
||||
let totalDeltaE = 0;
|
||||
let patchCount = 0;
|
||||
let maxDeltaE = 0;
|
||||
|
||||
unlistenJsonRow = await listen("process:json_row", (event) => {
|
||||
if (event.payload.id !== processId) return;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(event.payload.json);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse json_row:", e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.event !== "row_complete") return;
|
||||
|
||||
// Update progress bar
|
||||
const progress = ((data.row_index + 1) / data.total_rows) * 100;
|
||||
if (progressBar) progressBar.style.width = `${progress}%`;
|
||||
if (progressText) progressText.textContent = `Strip ${data.row_id} — ${data.row_index + 1} / ${data.total_rows}`;
|
||||
|
||||
// Create row container
|
||||
const rowEl = document.createElement("div");
|
||||
rowEl.className = "swatch-row";
|
||||
|
||||
const rowLabel = document.createElement("div");
|
||||
rowLabel.className = "swatch-row-label";
|
||||
rowLabel.textContent = data.row_id;
|
||||
rowEl.appendChild(rowLabel);
|
||||
|
||||
const rowPatches = document.createElement("div");
|
||||
rowPatches.className = "swatch-row-patches";
|
||||
|
||||
for (const patch of data.patches) {
|
||||
if (patch.is_pad) continue; // Skip spacer patches
|
||||
|
||||
const patchEl = document.createElement("div");
|
||||
patchEl.className = "swatch-patch";
|
||||
|
||||
// Determine the display colour
|
||||
let bgColor;
|
||||
if (patch.measured && patch.measured.Lab) {
|
||||
bgColor = labToCss(patch.measured.Lab);
|
||||
} else if (patch.device && patch.device.length === 3) {
|
||||
bgColor = deviceRgbToCss(patch.device);
|
||||
} else {
|
||||
bgColor = "#888";
|
||||
}
|
||||
|
||||
const swatch = document.createElement("div");
|
||||
swatch.className = "swatch-color";
|
||||
swatch.style.backgroundColor = bgColor;
|
||||
patchEl.appendChild(swatch);
|
||||
|
||||
// Compute and display ΔE₀₀ if both expected and measured Lab are present
|
||||
if (patch.expected && patch.expected.Lab && patch.measured && patch.measured.Lab) {
|
||||
const deltaE = computeDeltaE00(patch.expected.Lab, patch.measured.Lab);
|
||||
|
||||
const deLabel = document.createElement("div");
|
||||
deLabel.className = "swatch-de";
|
||||
deLabel.textContent = deltaE.toFixed(1);
|
||||
|
||||
// Traffic light classification
|
||||
if (deltaE < 2) {
|
||||
patchEl.classList.add("de-good"); // Green
|
||||
} else if (deltaE < 5) {
|
||||
patchEl.classList.add("de-warning"); // Amber
|
||||
} else {
|
||||
patchEl.classList.add("de-bad"); // Red
|
||||
}
|
||||
|
||||
patchEl.appendChild(deLabel);
|
||||
|
||||
// Accumulate stats
|
||||
totalDeltaE += deltaE;
|
||||
patchCount++;
|
||||
if (deltaE > maxDeltaE) maxDeltaE = deltaE;
|
||||
}
|
||||
|
||||
patchEl.title = `${patch.loc} (ID: ${patch.id})`;
|
||||
rowPatches.appendChild(patchEl);
|
||||
}
|
||||
|
||||
rowEl.appendChild(rowPatches);
|
||||
grid.appendChild(rowEl);
|
||||
|
||||
// Scroll to bottom
|
||||
grid.scrollTop = grid.scrollHeight;
|
||||
|
||||
// Update stats
|
||||
if (statsPanel && patchCount > 0) {
|
||||
const avgDe = (totalDeltaE / patchCount).toFixed(2);
|
||||
statsPanel.textContent = `Avg ΔE₀₀: ${avgDe} · Max ΔE₀₀: ${maxDeltaE.toFixed(2)} · Patches: ${patchCount}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening for row events.
|
||||
*/
|
||||
export function stopSwatchListener() {
|
||||
if (unlistenJsonRow) {
|
||||
unlistenJsonRow();
|
||||
unlistenJsonRow = null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
const { save } = window.__TAURI__.dialog;
|
||||
import { setStage1Result } from './printtarg.js';
|
||||
|
||||
export function initTargen() {
|
||||
const colourSpaceRadios = document.querySelectorAll('input[name="colourSpace"]');
|
||||
@@ -131,6 +132,7 @@ export function initTargen() {
|
||||
// In a real app we'd dispatch an event to advance the stepper here.
|
||||
// For now, we'll manually unlock stage 2 in the state.
|
||||
btnGenerate.disabled = false;
|
||||
setStage1Result(basename, currentWorkingDir);
|
||||
advanceToStage2();
|
||||
} else {
|
||||
logPre.textContent += `\n[ERROR] Targen exited with code ${event.payload.code}.\n`;
|
||||
|
||||
+471
-1
@@ -225,7 +225,8 @@ button:disabled {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#targenLog {
|
||||
#targenLog,
|
||||
#printtargLog {
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
@@ -234,3 +235,472 @@ button:disabled {
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Stage 2: Warning Banner
|
||||
======================================== */
|
||||
.warning-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
background: rgba(255, 152, 0, 0.12);
|
||||
border: 1px solid rgba(255, 152, 0, 0.4);
|
||||
border-left: 4px solid #ff9800;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.warning-text strong {
|
||||
display: block;
|
||||
color: #ffb74d;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.warning-text p {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: #ccc;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Stage 2: TIFF Gallery
|
||||
======================================== */
|
||||
.tiff-gallery {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.tiff-gallery h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.gallery-info {
|
||||
font-size: 0.9em;
|
||||
color: #888;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.gallery-card {
|
||||
background: var(--panel-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.gallery-card:hover {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.gallery-card img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.gallery-label {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.85em;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.gallery-fallback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30px 16px;
|
||||
background: var(--bg-color);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gallery-fallback .file-icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.gallery-fallback .file-size {
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Stage 3: chartread State Machine UI
|
||||
======================================== */
|
||||
.chartread-status {
|
||||
background: var(--panel-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.85em;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-label span {
|
||||
color: var(--accent-color);
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-prompt {
|
||||
font-size: 1.1em;
|
||||
color: #fff;
|
||||
line-height: 1.4;
|
||||
min-height: 2.8em;
|
||||
}
|
||||
|
||||
.chartread-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background-color: #c62828;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-size: 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background-color: #e53935;
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.read-progress-container {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--bg-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.read-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-color), #4fc3f7);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.read-progress-text {
|
||||
font-size: 0.85em;
|
||||
color: #888;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.read-stats {
|
||||
font-size: 0.9em;
|
||||
color: #aaa;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Stage 3: Swatch Grid
|
||||
======================================== */
|
||||
.swatch-grid {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-color);
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.swatch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.swatch-row-label {
|
||||
min-width: 30px;
|
||||
font-weight: bold;
|
||||
font-size: 0.85em;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.swatch-row-patches {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.swatch-patch {
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.swatch-color {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.swatch-de {
|
||||
font-size: 0.65em;
|
||||
padding: 2px 0;
|
||||
color: #ccc;
|
||||
background: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* Traffic light ΔE indicators */
|
||||
.swatch-patch.de-good {
|
||||
border-color: #4caf50;
|
||||
}
|
||||
|
||||
.swatch-patch.de-warning {
|
||||
border-color: #ff9800;
|
||||
}
|
||||
|
||||
.swatch-patch.de-bad {
|
||||
border-color: #f44336;
|
||||
}
|
||||
|
||||
/* Process log for chartread */
|
||||
#chartreadLog {
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
color: #ccc;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Stage 4: Spinner & Success Card
|
||||
======================================== */
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: var(--panel-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.stage-label {
|
||||
font-size: 1rem;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.success-card {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
border: 1px solid #4caf50;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-card h3 {
|
||||
color: #4caf50;
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.success-card p {
|
||||
color: #ccc;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Stage 5: Report Card
|
||||
======================================== */
|
||||
.report-card {
|
||||
background: var(--panel-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.report-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.report-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge-excellent {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
border: 1px solid #4caf50;
|
||||
}
|
||||
|
||||
.badge-good {
|
||||
background: rgba(33, 150, 243, 0.2);
|
||||
color: #2196f3;
|
||||
border: 1px solid #2196f3;
|
||||
}
|
||||
|
||||
.badge-acceptable {
|
||||
background: rgba(255, 152, 0, 0.2);
|
||||
color: #ff9800;
|
||||
border: 1px solid #ff9800;
|
||||
}
|
||||
|
||||
.badge-poor {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
color: #f44336;
|
||||
border: 1px solid #f44336;
|
||||
}
|
||||
|
||||
.report-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metric-box {
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
display: block;
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.report-footnote {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Stage 5: Gamut Viewer & Settings Modal
|
||||
======================================== */
|
||||
.gamut-viewer-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
#gamutViewerContainer {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
background: #111116;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.settings-modal {
|
||||
background: var(--panel-color);
|
||||
color: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.settings-modal::backdrop {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user