feat: Stage 5 Final - 3D Gamut Visualization, Settings, Autodetection & Packaging #16

Merged
gronod merged 7 commits from feature/stage-5-final into main 2026-08-22 18:27:51 +01:00
26 changed files with 2365 additions and 22 deletions
+15
View File
@@ -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.
+1
View File
@@ -1579,6 +1579,7 @@ dependencies = [
name = "iccery"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"serde",
"serde_json",
"tauri",
+1
View File
@@ -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"
+30
View File
@@ -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
+20
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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"]);
}
}
+13
View File
@@ -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
View File
@@ -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");
+6
View File
@@ -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();
+27
View File
@@ -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
View File
@@ -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₀₀ &lt; 1.0, and acceptable profiles &lt; 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
View File
@@ -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);
});
+235
View File
@@ -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');
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Convert CIE L*a*b* to sRGB [0255] 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 [0100] 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})`;
}
+133
View File
@@ -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');
}
}
+1
View File
File diff suppressed because one or more lines are too long
+86
View File
@@ -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;
}
+117
View File
@@ -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;
}
}
+226
View File
@@ -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');
}
}
+137
View File
@@ -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");
}
}
}
+40
View File
@@ -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);
}
});
}
}
+127
View File
@@ -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;
}
}
+2
View File
@@ -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
View File
@@ -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;
}