feat: implement v0.7.0 enhancements (#176, #177, #178, #179) #180

Merged
gronod merged 1 commits from feat/v0.7.0-enhancements into development 2026-09-02 01:37:05 +01:00
15 changed files with 557 additions and 119 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS.
[![Release](https://img.shields.io/badge/version-v0.6.8-blue.svg)](https://git.i3omb.com/gronod/ICCery)
[![Release](https://img.shields.io/badge/version-v0.7.0-blue.svg)](https://git.i3omb.com/gronod/ICCery)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-lightgrey.svg)](https://git.i3omb.com/gronod/ICCery)
[![Framework](https://img.shields.io/badge/framework-Tauri%20v2%20%2B%20Rust-orange.svg)](https://tauri.app)
[![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](LICENCE.md)
+2 -1
View File
@@ -79,7 +79,8 @@ ICCery is a native, cross-platform desktop application built with:
- [x] **Stage 1 Layout Normalization & Stage 2 Deterministic Target Generation (#162, #163)** (`v0.5.5`): Reorganized Stage 1 Advanced Options into structured 2-column grids with normalized heights; enforced deterministic `-R 1` target generation with custom seed and raster order (`-r`) support in Stage 2.
- [x] **macOS Universal Binary Target (#164)** (`v0.5.5`): Added macOS Universal Binary (`universal-apple-darwin`) build target combining Intel (`x86_64`) and Apple Silicon (`arm64`), ArgyllCMS universal sidecar packaging, runtime fallback resolution, and CI release asset automation.
### Milestone 11 — Enterprise Colour Workflow (`v0.6.0` `v0.6.8`)
### Milestone 11 — Enterprise Colour Workflow (`v0.6.0` `v0.7.0`)
- [x] **Workflow & Visualizer Enhancements (#176, #177, #178, #179)** (`v0.7.0`): Stage 4 OBA/FWA compensation and viewing conditions UI (`-c`, `-d`), global button standardization, Stage 3 swatch grid diagonal split rendering for CIEDE2000 visual comparison, and robust gamut `.gam` dual-table face parsing with regex fixes for profcheck.
- [x] **CGATS Dataset Interoperability (#94)** (`v0.6.0`): Native Rust CGATS and Argyll `.ti3` dataset parser, canonical normalizer (0-255 scaling, field aliasing, metadata synthesis), and direct-jump workflow to Stage 4 (Profile Generation) and Stage 5 (Verification) using imported external datasets.
- [x] **Stage 1 Layout Normalization (#162)** (`v0.6.1`): Standardized control heights and structural flexbox auto-margin layout for multi-column Stage 1 advanced settings.
- [x] **Overlay Tooltip Rendering (#171)** (`v0.6.2`): Rendered tooltips as absolute overlay popups on hover/focus to prevent layout jitter while preserving in-flow hints in global tooltip toggle mode.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "iccery",
"private": true,
"version": "0.6.8",
"version": "0.7.0",
"type": "module",
"scripts": {
"fetch-argyll": "node scripts/fetch-argyll.mjs",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "iccery"
version = "0.6.8"
version = "0.7.0"
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
authors = ["Gordon"]
edition = "2021"
+184 -21
View File
@@ -968,38 +968,81 @@ pub struct ColprofConfig {
pub description: Option<String>,
pub basename: String,
pub cwd: String,
#[serde(default)]
pub fwa: Option<String>,
#[serde(default)]
pub illuminant: Option<String>,
#[serde(default)]
pub observer: Option<String>,
#[serde(default)]
pub input_viewing_cond: Option<String>,
#[serde(default)]
pub output_viewing_cond: Option<String>,
}
pub fn build_colprof_args(config: &ColprofConfig) -> Vec<String> {
let mut args = vec![
"-v".to_string(),
"-a".to_string(),
config.algorithm.clone(),
"-q".to_string(),
config.quality.clone(),
];
let mut args = vec!["-v".to_string(), "-a".to_string(), config.algorithm.clone(), "-q".to_string(), config.quality.clone()];
if let Some(ref intent) = config.intent {
if !intent.trim().is_empty() {
args.push("-p".to_string());
if !intent.is_empty() {
args.push("-t".to_string());
args.push(intent.clone());
}
}
if let Some(ref cr) = config.copyright {
if !cr.trim().is_empty() {
args.push("-C".to_string());
args.push(cr.clone());
if let Some(ref fwa) = config.fwa {
if fwa.to_lowercase() != "none" {
if fwa.is_empty() {
args.push("-f".to_string());
} else {
args.push("-f".to_string());
args.push(fwa.clone());
}
}
}
if let Some(ref illuminant) = config.illuminant {
if !illuminant.is_empty() {
args.push("-i".to_string());
args.push(illuminant.clone());
}
}
if let Some(ref observer) = config.observer {
if !observer.is_empty() {
args.push("-o".to_string());
args.push(observer.clone());
}
}
if let Some(ref in_cond) = config.input_viewing_cond {
if !in_cond.is_empty() && in_cond.to_lowercase() != "none" {
args.push("-c".to_string());
args.push(in_cond.clone());
}
}
if let Some(ref out_cond) = config.output_viewing_cond {
if !out_cond.is_empty() && out_cond.to_lowercase() != "none" {
args.push("-d".to_string());
args.push(out_cond.clone());
}
}
if let Some(ref desc) = config.description {
if !desc.trim().is_empty() {
if !desc.is_empty() {
args.push("-D".to_string());
args.push(desc.clone());
}
}
if let Some(ref copyright) = config.copyright {
if !copyright.is_empty() {
args.push("-C".to_string());
args.push(copyright.clone());
}
}
args.push(config.basename.clone());
args
}
@@ -1602,18 +1645,138 @@ mod tests {
#[test]
fn test_build_colprof_args() {
let config = ColprofConfig {
quality: "h".to_string(),
algorithm: "l".to_string(),
intent: None,
description: Some("My Profile".to_string()),
copyright: Some("2026 ACME".to_string()),
basename: "my_profile".to_string(),
cwd: "/home/user".to_string(),
quality: "h".to_string(),
intent: Some("p".to_string()),
copyright: Some("Test".to_string()),
description: Some("Test Desc".to_string()),
basename: "basename".to_string(),
cwd: "".to_string(),
fwa: None,
illuminant: None,
observer: None,
input_viewing_cond: None,
output_viewing_cond: None,
};
let args = build_colprof_args(&config);
assert_eq!(
args,
vec!["-v", "-a", "l", "-q", "h", "-C", "2026 ACME", "-D", "My Profile", "my_profile"]
vec!["-v", "-a", "l", "-q", "h", "-t", "p", "-D", "Test Desc", "-C", "Test", "basename"]
);
}
#[test]
fn test_build_colprof_args_with_fwa_d50() {
let config = ColprofConfig {
algorithm: "l".to_string(),
quality: "h".to_string(),
intent: None,
copyright: None,
description: None,
basename: "basename".to_string(),
cwd: "".to_string(),
fwa: Some("D50".to_string()),
illuminant: None,
observer: None,
input_viewing_cond: None,
output_viewing_cond: None,
};
let args = build_colprof_args(&config);
assert_eq!(
args,
vec!["-v", "-a", "l", "-q", "h", "-f", "D50", "basename"]
);
}
#[test]
fn test_build_colprof_args_with_fwa_none() {
let config = ColprofConfig {
algorithm: "l".to_string(),
quality: "h".to_string(),
intent: None,
copyright: None,
description: None,
basename: "basename".to_string(),
cwd: "".to_string(),
fwa: Some("none".to_string()),
illuminant: None,
observer: None,
input_viewing_cond: None,
output_viewing_cond: None,
};
let args = build_colprof_args(&config);
assert_eq!(
args,
vec!["-v", "-a", "l", "-q", "h", "basename"]
);
}
#[test]
fn test_build_colprof_args_with_custom_sp() {
let config = ColprofConfig {
algorithm: "l".to_string(),
quality: "h".to_string(),
intent: None,
copyright: None,
description: None,
basename: "basename".to_string(),
cwd: "".to_string(),
fwa: Some("/path/to/viewing_booth.sp".to_string()),
illuminant: None,
observer: None,
input_viewing_cond: None,
output_viewing_cond: None,
};
let args = build_colprof_args(&config);
assert_eq!(
args,
vec!["-v", "-a", "l", "-q", "h", "-f", "/path/to/viewing_booth.sp", "basename"]
);
}
#[test]
fn test_build_colprof_args_with_illuminant_observer() {
let config = ColprofConfig {
algorithm: "l".to_string(),
quality: "h".to_string(),
intent: None,
copyright: None,
description: None,
basename: "basename".to_string(),
cwd: "".to_string(),
fwa: None,
illuminant: Some("D65".to_string()),
observer: Some("1964_10".to_string()),
input_viewing_cond: None,
output_viewing_cond: None,
};
let args = build_colprof_args(&config);
assert_eq!(
args,
vec!["-v", "-a", "l", "-q", "h", "-i", "D65", "-o", "1964_10", "basename"]
);
}
#[test]
fn test_build_colprof_args_with_viewing_conditions() {
let config = ColprofConfig {
algorithm: "l".to_string(),
quality: "h".to_string(),
intent: None,
copyright: None,
description: None,
basename: "basename".to_string(),
cwd: "".to_string(),
fwa: None,
illuminant: None,
observer: None,
input_viewing_cond: Some("pp".to_string()),
output_viewing_cond: Some("mt".to_string()),
};
let args = build_colprof_args(&config);
assert_eq!(
args,
vec!["-v", "-a", "l", "-q", "h", "-c", "pp", "-d", "mt", "basename"]
);
}
+15
View File
@@ -18,6 +18,16 @@ pub struct ProfilingPreset {
pub colprof_algorithm: String,
pub colprof_quality: String,
pub colprof_intent: Option<String>,
#[serde(default)]
pub colprof_fwa: Option<String>,
#[serde(default)]
pub colprof_illuminant: Option<String>,
#[serde(default)]
pub colprof_observer: Option<String>,
#[serde(default)]
pub colprof_input_viewing_cond: Option<String>,
#[serde(default)]
pub colprof_output_viewing_cond: Option<String>,
// Advanced Stage 1 fields
#[serde(default)]
@@ -176,6 +186,11 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
device_power: None,
random_seed: Some(1),
no_randomize: Some(false),
colprof_fwa: Some("D50".to_string()),
colprof_illuminant: None,
colprof_observer: None,
colprof_input_viewing_cond: None,
colprof_output_viewing_cond: None,
},
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ICCery",
"version": "0.6.8",
"version": "0.7.0",
"identifier": "com.gronod.iccery",
"build": {
"frontendDist": "../src"
+100 -18
View File
@@ -16,13 +16,13 @@
<div class="brand">
<img src="./assets/ICCery-logo.svg" alt="ICCery" class="main-logo" />
<div class="brand-actions">
<button id="openSettingsBtn" class="icon-btn" title="Settings" style="display: flex; align-items: center; justify-content: center;">
<button id="openSettingsBtn" class="icon-btn" title="Settings">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
<button id="openAboutBtn" class="icon-btn" title="About" style="display: flex; align-items: center; justify-content: center;">
<button id="openAboutBtn" class="icon-btn" title="About">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
@@ -36,9 +36,13 @@
<div class="preset-selector-container" style="padding: 0 16px 12px; border-bottom: 1px solid var(--border-color, #2a2a30);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<label for="presetSelect" style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; font-weight: 600;">Profiling Preset</label>
<div style="display: flex; gap: 4px;">
<button type="button" id="btnSavePresetModal" class="icon-btn" title="Save current settings as Preset" style="font-size: 0.85rem; padding: 2px 4px; border: 1px solid var(--border-color, #333); border-radius: 4px; background: transparent; cursor: pointer;">💾</button>
<button type="button" id="btnOpenPresetsDialog" class="icon-btn" title="Manage Presets (Import/Export/Delete)" style="font-size: 0.85rem; padding: 2px 4px; border: 1px solid var(--border-color, #333); border-radius: 4px; background: transparent; cursor: pointer;">⚙️</button>
<div style="display: flex; gap: 6px; align-items: center; justify-content: flex-end;">
<button type="button" id="btnSavePresetModal" class="icon-btn btn-sm" title="Save current settings as Preset">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
</button>
<button type="button" id="btnOpenPresetsDialog" class="icon-btn btn-sm" title="Manage Presets (Import/Export/Delete)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line></svg>
</button>
</div>
</div>
<select id="presetSelect" style="width: 100%; font-size: 0.85rem; padding: 6px 8px; border-radius: 6px; background: var(--bg-card, #1a1a22); color: var(--text-color, #eee); border: 1px solid var(--border-color, #333); cursor: pointer;">
@@ -65,13 +69,10 @@
<!-- Stage 1: targen -->
<section id="stage-1" class="stage active">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h2>Generate Target</h2>
<p>Configure the colour patches for your profiling target.</p>
</div>
<button type="button" id="btnToggleAllHelp" class="secondary" style="font-size: 0.75rem; padding: 4px 8px;" title="Toggle tooltip hints for all options">💡 Show Tooltip Hints</button>
</div>
<h2 style="display:flex; justify-content:space-between; align-items:center;">
Define Target
<button type="button" id="btnToggleAllHelp" class="secondary btn-sm">Toggle Help Mode</button>
</h2>
<div class="form-container" id="stage1FormContainer">
<!-- Basic Settings -->
@@ -131,9 +132,9 @@
<div class="form-group has-tooltip">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<label style="margin: 0;">Project Directory &amp; Filename</label>
<div>
<button type="button" id="btn-import-dataset" class="secondary" style="font-size: 0.75rem; padding: 2px 8px; margin-right: 4px;" title="Import external dataset (.ti3, .csv) and jump to Profiling">📥 Import Dataset...</button>
<button type="button" id="btnOpenExisting" class="secondary" style="font-size: 0.75rem; padding: 2px 8px;" title="Open existing .ti1 or .ti2 target file">📂 Open Target...</button>
<div style="display:flex; justify-content:flex-end;">
<button type="button" id="btn-import-dataset" class="secondary btn-sm" style="margin-right: 4px;">Import Dataset...</button>
<button type="button" id="btnOpenExisting" class="secondary btn-sm">Open Target (.ti2/.ti3)</button>
</div>
</div>
<div class="input-row has-btn">
@@ -304,7 +305,9 @@
</details>
</div>
<button class="primary" id="btnGenerate" disabled style="margin-top: 14px;">Generate (.ti1)</button>
<div class="stage-actions">
<button class="primary" id="btnGenerate" disabled>Generate (.ti1)</button>
</div>
<details class="log-container hidden" id="targenLogContainer">
<summary>Process Output</summary>
@@ -405,7 +408,7 @@
<div class="form-group" style="margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<label style="margin: 0; font-weight: 600;">Target Label &amp; Metadata (Optional)</label>
<button type="button" class="secondary" id="btnToggleLabelEdit" style="font-size: 0.75rem; padding: 2px 8px;">✏️ Edit Label</button>
<button type="button" class="secondary btn-sm" id="btnToggleLabelEdit">Edit Custom Field</button>
</div>
<p class="sub-label" style="margin-bottom: 10px;">Printed below test patches to identify hardware, ink, and media used for this profiling run.</p>
@@ -439,7 +442,9 @@
</div>
</div>
<div class="stage-actions">
<button class="primary" id="btnCreateLayout">Create Layout (.ti2 + .tif)</button>
</div>
<details class="log-container hidden" id="printtargLogContainer">
<summary>Process Output</summary>
@@ -634,9 +639,84 @@
<option value="m">Matrix Only (Simple)</option>
</select>
</div>
<div class="form-group">
<label>OBA / FWA Compensation</label>
<select id="colprofFwa">
<option value="D50" selected>Calculated M1 — D50 FWA Compensation (Recommended)</option>
<option value="none">None — Native M0 (No Compensation)</option>
<option value="D65">D65 FWA Compensation</option>
<option value="custom">Custom Ambient Spectrum (.sp)...</option>
</select>
</div>
<div class="form-group hidden" id="colprofCustomSpRow">
<label>Custom Spectrum File (.sp)</label>
<div style="display: flex; gap: 8px;">
<input type="text" id="colprofCustomSpPath" placeholder="Select .sp file..." readonly style="flex:1;">
<button class="secondary btn-sm" id="btnBrowseCustomSp">Browse</button>
</div>
</div>
<details class="advanced-section-details">
<summary>
<span>⚙️ Advanced Spectral & Viewing Options</span>
<span style="opacity:0.5; font-size:0.8rem;"></span>
</summary>
<div class="advanced-content">
<div class="advanced-grid-2col">
<div class="form-group">
<label>Standard Illuminant</label>
<select id="colprofIlluminant">
<option value="" selected>Default (D50)</option>
<option value="A">A</option>
<option value="C">C</option>
<option value="D50M2">D50M2</option>
<option value="D65">D65</option>
<option value="F5">F5</option>
<option value="F8">F8</option>
<option value="F10">F10</option>
</select>
</div>
<div class="form-group">
<label>Standard Observer</label>
<select id="colprofObserver">
<option value="" selected>Default (1931 2°)</option>
<option value="1964_10">1964 10°</option>
<option value="2015_2">2015 2°</option>
<option value="2015_10">2015 10°</option>
</select>
</div>
<div class="form-group">
<label>Input Viewing Condition</label>
<select id="colprofInputViewCond">
<option value="none" selected>None</option>
<option value="pc">Critical print evaluation (ISO-3664 P1)</option>
<option value="pp">Practical Reflection Print (ISO-3664 P2)</option>
<option value="pe">Print evaluation (CIE 116-1995)</option>
<option value="pm">Print evaluation (partial mid-tone adaptation)</option>
</select>
</div>
<div class="form-group">
<label>Output Viewing Condition</label>
<select id="colprofOutputViewCond">
<option value="none" selected>None</option>
<option value="mt">Monitor in typical work environment</option>
<option value="mb">Bright monitor in bright environment</option>
<option value="md">Monitor in darkened environment</option>
<option value="jm">Projector in dim environment</option>
<option value="jd">Projector in dark environment</option>
<option value="tv">Television/Film Studio</option>
</select>
</div>
</div>
</div>
</details>
</div>
<div class="stage-actions">
<button class="primary" id="btnCreateProfile">Calculate Profile (.icc)</button>
</div>
<!-- Spinner and Progress Stage -->
<div class="spinner-container hidden" id="colprofSpinnerContainer">
@@ -663,7 +743,9 @@
<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>
<div class="stage-actions">
<button class="primary" id="btnVerify">Verify Profile Accuracy</button>
</div>
<!-- Report Card -->
<div class="report-card hidden" id="profcheckReportCard">
+8
View File
@@ -65,3 +65,11 @@ export function labToCss(lab) {
const [r, g, b] = labToSrgb(lab[0], lab[1], lab[2]);
return `rgb(${r}, ${g}, ${b})`;
}
export function deviceCmykToCss(device) {
const c = device[0] / 100, m = device[1] / 100, y = device[2] / 100, k = device[3] / 100;
const r = Math.round(255 * (1 - c) * (1 - k));
const g = Math.round(255 * (1 - m) * (1 - k));
const b = Math.round(255 * (1 - y) * (1 - k));
return `rgb(${r}, ${g}, ${b})`;
}
+38
View File
@@ -30,9 +30,42 @@ export function initColprof() {
const successCard = document.getElementById("colprofSuccessCard");
const successInfo = document.getElementById("colprofSuccessInfo");
const btnGoToVerify = document.getElementById("btnGoToVerify");
const colprofFwa = document.getElementById("colprofFwa");
const colprofCustomSpRow = document.getElementById("colprofCustomSpRow");
const colprofCustomSpPath = document.getElementById("colprofCustomSpPath");
const btnBrowseCustomSp = document.getElementById("btnBrowseCustomSp");
const colprofIlluminant = document.getElementById("colprofIlluminant");
const colprofObserver = document.getElementById("colprofObserver");
const colprofInputViewCond = document.getElementById("colprofInputViewCond");
const colprofOutputViewCond = document.getElementById("colprofOutputViewCond");
if (!btnCreateProfile) return;
if (colprofFwa && colprofCustomSpRow) {
colprofFwa.addEventListener("change", () => {
if (colprofFwa.value === "custom") {
colprofCustomSpRow.classList.remove("hidden");
} else {
colprofCustomSpRow.classList.add("hidden");
}
});
}
if (btnBrowseCustomSp && colprofCustomSpPath) {
btnBrowseCustomSp.addEventListener("click", async () => {
try {
const selected = await window.__TAURI__.dialog.open({
filters: [{ name: 'Spectrum', extensions: ['sp'] }]
});
if (selected) {
colprofCustomSpPath.value = selected;
}
} catch (err) {
console.error("Failed to open dialog:", err);
}
});
}
btnCreateProfile.addEventListener("click", async () => {
const basename = chartreadBasename || wizardState.basename;
const cwd = chartreadCwd || wizardState.cwd;
@@ -65,6 +98,11 @@ export function initColprof() {
copyright: copyrightInput.value.trim() || null,
basename: basename,
cwd: cwd,
fwa: colprofFwa.value === "custom" ? (colprofCustomSpPath.value || "none") : colprofFwa.value,
illuminant: colprofIlluminant.value || null,
observer: colprofObserver.value || null,
input_viewing_cond: colprofInputViewCond.value !== "none" ? colprofInputViewCond.value : null,
output_viewing_cond: colprofOutputViewCond.value !== "none" ? colprofOutputViewCond.value : null,
};
const processId = `colprof_${basename}`;
+71 -49
View File
@@ -87,61 +87,43 @@ function animate() {
if (renderer && scene && camera) renderer.render(scene, camera);
}
export function parseCGATS(text) {
export function parseGamutFile(text) {
const lines = text.split('\n');
const vertices = []; // [[L, a, b], ...]
const faces = []; // [[v0, v1, v2], ...]
let dataStarted = false;
const points = [];
let dataBlock = 0; // 0 = not in data, 1 = vertices, 2 = faces
let fieldCount = 0;
for (const line of lines) {
if (line.startsWith('BEGIN_DATA')) {
const trimmed = line.trim();
if (trimmed.startsWith('NUMBER_OF_FIELDS')) {
fieldCount = parseInt(trimmed.split(/\s+/)[1], 10);
}
if (trimmed === 'BEGIN_DATA') {
dataBlock++;
dataStarted = true;
continue;
}
if (line.startsWith('END_DATA')) break;
if (trimmed === 'END_DATA') {
dataStarted = false;
continue;
}
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 });
const parts = trimmed.split(/\s+/).map(Number);
if (dataBlock === 1 && parts.length >= 4) {
// Vertex: VERTEX_NO LAB_L LAB_A LAB_B
vertices.push([parts[1], parts[2], parts[3]]); // [L, a, b]
} else if (dataBlock === 2 && parts.length >= 3) {
// Face: VERTEX_0 VERTEX_1 VERTEX_2
faces.push([parts[0], parts[1], parts[2]]);
}
}
}
}
return points;
}
/**
* 3D Convex Hull (QuickHull in 3D) for CIELAB point clouds.
* Input: points array of { L, a, b }
* Output: { vertices: Float32Array, indices: Uint32Array } or null
*/
export function compute3DConvexHull(pts) {
const faces = computeQuickHull(pts);
if (!faces || faces.length === 0) return null;
const verticesList = [];
const indices = [];
const ptMap = new Map();
for (const f of faces) {
for (const p of [f.a, f.b, f.c]) {
const key = `${p.x}_${p.y}_${p.z}`;
if (!ptMap.has(key)) {
ptMap.set(key, verticesList.length / 3);
verticesList.push(p.x, p.y, p.z);
}
indices.push(ptMap.get(key));
}
}
return {
vertices: new Float32Array(verticesList),
indices: new Uint32Array(indices)
};
return { vertices, faces };
}
export function renderGamutFromText(text, color, isWireframe, previousMesh) {
@@ -153,16 +135,56 @@ export function renderGamutFromText(text, color, isWireframe, previousMesh) {
if (previousMesh.material) previousMesh.material.dispose();
}
const points = parseCGATS(text);
if (points.length < 4) return null;
const { vertices, faces } = parseGamutFile(text);
if (vertices.length < 4) return null;
const hull = compute3DConvexHull(points);
if (!hull) return null;
let geometry = new THREE.BufferGeometry();
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(hull.vertices, 3));
geometry.setIndex(Array.from(hull.indices));
if (faces.length > 0) {
// We have a pre-triangulated mesh
const positionArray = new Float32Array(vertices.length * 3);
for (let i = 0; i < vertices.length; i++) {
const [L, a, b] = vertices[i];
positionArray[i * 3] = a; // x = a*
positionArray[i * 3 + 1] = L; // y = L*
positionArray[i * 3 + 2] = b; // z = b*
}
const indexArray = new Uint32Array(faces.length * 3);
for (let i = 0; i < faces.length; i++) {
indexArray[i * 3] = faces[i][0];
indexArray[i * 3 + 1] = faces[i][1];
indexArray[i * 3 + 2] = faces[i][2];
}
geometry.setAttribute('position', new THREE.BufferAttribute(positionArray, 3));
geometry.setIndex(new THREE.BufferAttribute(indexArray, 1));
geometry.computeVertexNormals();
} else {
// Fallback to point cloud hull if no faces are present
const pts = vertices.map(v => ({ x: v[1], y: v[0], z: v[2] })); // { x: a, y: L, z: b }
const hullFaces = computeQuickHull(pts);
if (!hullFaces || hullFaces.length === 0) return null;
const verticesList = [];
const indices = [];
const ptMap = new Map();
for (const f of hullFaces) {
for (const p of [f.a, f.b, f.c]) {
const key = `${p.x}_${p.y}_${p.z}`;
if (!ptMap.has(key)) {
ptMap.set(key, verticesList.length / 3);
verticesList.push(p.x, p.y, p.z);
}
indices.push(ptMap.get(key));
}
}
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(verticesList), 3));
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1));
geometry.computeVertexNormals();
}
const material = new THREE.MeshLambertMaterial({
color: color,
+54
View File
@@ -244,6 +244,32 @@ export async function initPresets() {
const colprofAlgorithm = document.getElementById("colprofAlgorithm");
if (colprofAlgorithm && preset.colprof_algorithm) colprofAlgorithm.value = preset.colprof_algorithm;
const colprofFwa = document.getElementById("colprofFwa");
const colprofCustomSpRow = document.getElementById("colprofCustomSpRow");
const colprofCustomSpPath = document.getElementById("colprofCustomSpPath");
if (colprofFwa) {
if (preset.colprof_fwa && preset.colprof_fwa.endsWith(".sp")) {
colprofFwa.value = "custom";
if (colprofCustomSpPath) colprofCustomSpPath.value = preset.colprof_fwa;
if (colprofCustomSpRow) colprofCustomSpRow.classList.remove("hidden");
} else {
colprofFwa.value = preset.colprof_fwa || "D50";
if (colprofCustomSpRow) colprofCustomSpRow.classList.add("hidden");
}
}
const colprofIlluminant = document.getElementById("colprofIlluminant");
if (colprofIlluminant) colprofIlluminant.value = preset.colprof_illuminant || "";
const colprofObserver = document.getElementById("colprofObserver");
if (colprofObserver) colprofObserver.value = preset.colprof_observer || "";
const colprofInputViewCond = document.getElementById("colprofInputViewCond");
if (colprofInputViewCond) colprofInputViewCond.value = preset.colprof_input_viewing_cond || "none";
const colprofOutputViewCond = document.getElementById("colprofOutputViewCond");
if (colprofOutputViewCond) colprofOutputViewCond.value = preset.colprof_output_viewing_cond || "none";
}
function collectCurrentSettingsAsPreset(name, description) {
@@ -335,6 +361,29 @@ export async function initPresets() {
const colprofAlgorithm = document.getElementById("colprofAlgorithm");
const colprof_algorithm = colprofAlgorithm ? colprofAlgorithm.value : "l";
const colprofFwa = document.getElementById("colprofFwa");
const colprofCustomSpPath = document.getElementById("colprofCustomSpPath");
let colprof_fwa = "D50";
if (colprofFwa) {
if (colprofFwa.value === "custom") {
colprof_fwa = colprofCustomSpPath ? colprofCustomSpPath.value : "none";
} else {
colprof_fwa = colprofFwa.value;
}
}
const colprofIlluminant = document.getElementById("colprofIlluminant");
const colprof_illuminant = colprofIlluminant && colprofIlluminant.value ? colprofIlluminant.value : null;
const colprofObserver = document.getElementById("colprofObserver");
const colprof_observer = colprofObserver && colprofObserver.value ? colprofObserver.value : null;
const colprofInputViewCond = document.getElementById("colprofInputViewCond");
const colprof_input_viewing_cond = colprofInputViewCond && colprofInputViewCond.value !== "none" ? colprofInputViewCond.value : null;
const colprofOutputViewCond = document.getElementById("colprofOutputViewCond");
const colprof_output_viewing_cond = colprofOutputViewCond && colprofOutputViewCond.value !== "none" ? colprofOutputViewCond.value : null;
return {
id: `custom-${Date.now()}`,
name: name || "Custom Preset",
@@ -363,6 +412,11 @@ export async function initPresets() {
colprof_algorithm,
colprof_quality,
colprof_intent: null,
colprof_fwa,
colprof_illuminant,
colprof_observer,
colprof_input_viewing_cond,
colprof_output_viewing_cond,
};
}
+3 -3
View File
@@ -133,9 +133,9 @@ export function initProfcheck() {
}
} 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);
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]);
+34 -11
View File
@@ -1,5 +1,5 @@
import { computeDeltaE00 } from './delta_e.js';
import { labToCss, deviceRgbToCss } from './color_convert.js';
import { labToCss, deviceRgbToCss, deviceCmykToCss } from './color_convert.js';
const { listen } = window.__TAURI__.event;
@@ -63,26 +63,47 @@ export async function startSwatchListener(processId, onRowComplete) {
rowPatches.className = "swatch-row-patches";
for (const patch of data.patches) {
if (patch.is_pad) continue; // Skip spacer patches
if (patch.is_pad && !patch.measured && (!patch.device || patch.device.every(v => v === 0))) continue;
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);
let intendedCss = "#888";
if (patch.expected && patch.expected.Lab) {
intendedCss = labToCss(patch.expected.Lab);
} else if (patch.device && patch.device.length === 3) {
bgColor = deviceRgbToCss(patch.device);
} else {
bgColor = "#888";
intendedCss = deviceRgbToCss(patch.device);
} else if (patch.device && patch.device.length === 4) {
intendedCss = deviceCmykToCss(patch.device);
}
let measuredCss = null;
if (patch.measured && patch.measured.Lab) {
measuredCss = labToCss(patch.measured.Lab);
}
const swatch = document.createElement("div");
swatch.className = "swatch-color";
swatch.style.backgroundColor = bgColor;
if (measuredCss) {
swatch.style.background = `linear-gradient(135deg, ${intendedCss} 50%, ${measuredCss} 50%)`;
} else {
swatch.style.backgroundColor = intendedCss;
}
patchEl.appendChild(swatch);
let titleStr = `${patch.loc} (ID: ${patch.id})`;
if (patch.expected && patch.expected.Lab) {
titleStr += `\nIntended Lab: ${patch.expected.Lab.map(v => v.toFixed(1)).join(', ')}`;
} else if (patch.device) {
titleStr += `\nDevice: ${patch.device.map(v => v.toFixed(1)).join(', ')}`;
}
if (patch.measured && patch.measured.Lab) {
titleStr += `\nMeasured Lab: ${patch.measured.Lab.map(v => v.toFixed(1)).join(', ')}`;
}
// 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);
@@ -91,6 +112,8 @@ export async function startSwatchListener(processId, onRowComplete) {
deLabel.className = "swatch-de";
deLabel.textContent = deltaE.toFixed(1);
titleStr += `\nΔE₀₀: ${deltaE.toFixed(2)}`;
// Traffic light classification
if (deltaE < 2) {
patchEl.classList.add("de-good"); // Green
@@ -108,7 +131,7 @@ export async function startSwatchListener(processId, onRowComplete) {
if (deltaE > maxDeltaE) maxDeltaE = deltaE;
}
patchEl.title = `${patch.loc} (ID: ${patch.id})`;
patchEl.title = titleStr;
rowPatches.appendChild(patchEl);
}
+38 -6
View File
@@ -124,19 +124,43 @@ h2 {
}
button.primary {
background-color: var(--accent-color);
color: white;
background: linear-gradient(135deg, var(--accent-color), #2d73a8);
color: #fff;
border: none;
border-radius: 6px;
padding: 0 18px;
height: 36px;
font-size: 0.95rem;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
margin-top: 14px;
transition: opacity 0.2s, transform 0.1s;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
button.primary:hover {
background-color: #005f9e;
opacity: 0.9;
}
/* Button sizing system */
.btn-sm { height: 28px; padding: 0 10px; font-size: 0.78rem; border-radius: 4px; }
.btn-md { height: 36px; padding: 0 16px; font-size: 0.875rem; border-radius: 6px; }
.btn-lg { height: 40px; padding: 0 20px; font-size: 0.95rem; font-weight: 600; border-radius: 6px; }
.btn-icon-sq { width: 36px; height: 36px; min-width: 36px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
/* Action containers */
.stage-actions {
margin-top: 20px;
display: flex;
gap: 10px;
align-items: center;
}
.modal-actions {
margin-top: 20px;
display: flex;
gap: 10px;
align-items: center;
justify-content: flex-end;
}
.form-group {
@@ -558,6 +582,8 @@ button.danger:hover {
.swatch-color {
width: 100%;
height: 28px;
background-size: 100% 100%;
image-rendering: crisp-edges;
}
.swatch-de {
@@ -752,10 +778,16 @@ button.danger:hover {
border: none;
font-size: 1.2rem;
cursor: pointer;
padding: 4px;
padding: 0;
border-radius: 4px;
color: #d1d5db;
transition: color 0.15s ease, background-color 0.15s ease;
width: 28px;
height: 28px;
min-width: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover {