feat(cal): printer linearization via printcal / applycal (#224) #227

Merged
gronod merged 1 commits from feat/224-printer-calibration-curves into development 2026-09-07 18:31:11 +01:00
21 changed files with 2274 additions and 13 deletions
+11
View File
@@ -1,5 +1,15 @@
# ICCery Agent Notes
## Printer Calibration (`printcal` / `applycal`) (#224)
- Optional Stage 0 dashboard, opened from **Calibrate Printer**. The 15 wizard is unchanged when calibration is skipped.
- Calibration charts use a `CAL_` basename so they never collide with the profiling `.ti1`/`.ti2`/`.ti3`.
- `printtarg -K file.cal` is applied only to **profiling** layouts, never to the calibration chart itself.
- After Stage 4 `colprof`, `applycal` embeds the curves into the ICC/ICM when Apply Calibration is on.
- `.cal` overwrite requires an explicit Overwrite / Rename / Cancel choice.
- Warn when a loaded `.cal` is older than `calibration_stale_days` (default 30) or the stored printer name differs.
- Tests: `src-tauri/src/calibration.rs` (arg builders + `.cal` parser) and `src/js/calibration.test.js`.
## Stage 5 Verification / Profcheck
- `profcheck` output is parsed from both JSON summaries (preferred) and legacy plain-text report formats.
@@ -64,6 +74,7 @@ The frontend uses a tiered button sizing system defined in `src/styles/main.css`
- Verification & drift tests: `node src/js/profcheck.test.js` (21 tests)
- Chartread classifier & XY table tests: `node src/js/chartread.test.js` (39 tests)
- Gamut viewer tests: `node src/js/gamut_viewer.test.js`
- Calibration helpers: `node src/js/calibration.test.js`
- Browser devtools console: `import('./profcheck.test.js').then(m => m.runAll())`
- **Frontend development server**: `npm run tauri dev`
- **Production package build**: `npm run tauri build`
+8 -3
View File
@@ -2,19 +2,20 @@
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS.
[![Release](https://img.shields.io/badge/version-v0.8.4-blue.svg)](https://git.i3omb.com/gronod/ICCery)
[![Release](https://img.shields.io/badge/version-v0.8.5-blue.svg)](https://git.i3omb.com/gronod/ICCery)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://git.i3omb.com/gronod/ICCery)
[![Framework](https://img.shields.io/badge/framework-Tauri%20v2%20%2B%20Rust-orange.svg)](https://tauri.app)
[![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](LICENCE.md)
**ICCery** is a native GUI frontend designed to make creating custom ICC/ICM printer profiles seamless, visual, and reliable. It wraps the powerful color management capabilities of [ArgyllCMS](https://www.argyllcms.com/) within an intuitive, artefact-gated 5-stage wizard.
**ICCery** is a native GUI frontend designed to make creating custom ICC/ICM printer profiles seamless, visual, and reliable. It wraps the powerful color management capabilities of [ArgyllCMS](https://www.argyllcms.com/) within an intuitive, artefact-gated 5-stage wizard, with an optional printer calibration (linearization) workflow.
---
## Key Features
- 🪄 **Linear 5-Stage Wizard Workflow**:
1. **Stage 1 — Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, neutral/grey axis boosting, and 11 advanced generation parameters with contextual guidance tooltips. Supports direct-resume from existing `.ti2` target files to jump straight to measurement.
0. **Optional — Printer Calibration (`printcal` / `applycal`)** (#224): Per-channel linearization and ink-limit discovery before a full profile. Generate a short `CAL_` chart, print and measure it with the existing Stage 2/3 engines, compute `.cal` curves, inspect channel-response plots, and toggle **Apply Calibration** so subsequent `printtarg` (`-K`) and `colprof` (`applycal`) runs consume the curves. Skip entirely for simple RGB photo printers.
1. **Stage 1 — Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, neutral/grey axis boosting, and 11 advanced generation parameters with contextual guidance tooltips. Supports direct-resume from existing `.ti2` target files to jump straight to measurement. CMYK / RIP workflows show a reminder when no calibration is applied.
2. **Stage 2 — Target Creation & Raw Printing (`printtarg`)**: Format patch targets for handheld spectrophotometers (i1Pro, i1Pro2, ColorMunki, SpyderPrint) and automated XY tables (i1iO, SpectroScan). View high-resolution downscaled TIFF previews and print directly using native OS unmanaged pathways:
- **macOS**: Native `NSPrintPanel` driver preferences with automatic ColorSync suppression (`AP_ColorMatchingMode=AP_ApplicationColorMatching`), CUPS media type selection, and driver-specific color adjustment bypass detection (Canon `CNIJIntent2`, Epson `ColorCorrection`, Gutenprint).
- **Windows**: GDI uncorrected raw printing and DEVMODE preferences.
@@ -54,10 +55,12 @@ flowchart TD
QualityStore[Verification History & Drift Analytics]
PrintEngine["Raw Print Subsystem (GDI / CUPS / NSPrintPanel)"]
ProcMgr[Async Subprocess IPC Manager]
CalStore[Calibration .cal library]
UI <--> State
State <--> ProcMgr
State <--> QualityStore
State <--> CalStore
ProcMgr --> ThreeJS
UI --> PrintEngine
QualityStore --> UI
@@ -67,6 +70,7 @@ flowchart TD
BIN_TAR[targen]
BIN_PRT[printtarg]
BIN_CHR[chartread]
BIN_CAL[printcal / applycal]
BIN_COL[colprof]
BIN_CHK[profcheck]
BIN_GAM[iccgamut]
@@ -75,6 +79,7 @@ flowchart TD
ProcMgr -- stdin/stdout/stderr pipes --> BIN_TAR
ProcMgr -- stdin/stdout/stderr pipes --> BIN_PRT
ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHR
ProcMgr -- stdin/stdout/stderr pipes --> BIN_CAL
ProcMgr -- stdin/stdout/stderr pipes --> BIN_COL
ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHK
ProcMgr -- stdin/stdout/stderr pipes --> BIN_GAM
+4 -1
View File
@@ -14,7 +14,7 @@ ICCery is a native, cross-platform desktop application built with:
- **Frontend**: Vanilla JS (ES Modules) + HTML5/CSS3 with a modern dark theme and responsive layout.
- **Visualization**: Three.js WebGL engine for 3D CIELAB color gamut volumes and sRGB reference comparisons.
- **Engine**: ArgyllCMS command-line utilities orchestrated over isolated standard stream IPC (`stdin`, `stdout`, `stderr`).
- **Current Version**: `v0.8.4` (Production release).
- **Current Version**: `v0.8.5` (Production release).
---
@@ -118,6 +118,9 @@ ICCery is a native, cross-platform desktop application built with:
- [x] **Frontend Unit Testing & CI Integration (#215)**: Added standard `npm test` script executing the 3 frontend test suites (`profcheck`, `chartread`, and `gamut_viewer`) and integrated automated frontend test validation into macOS, Linux, and Windows CI workflows.
- [x] **macOS Monterey WKWebView survival (#225)**: Deferred Stage 5 WebGL until the gamut viewer is shown, hid the main window until first paint, painted a dark WKWebView backing, logged Web Content termination, and raised `minimumSystemVersion` to 12.0.
### Printer Calibration Release (`v0.8.5`)
- [x] **Printer Calibration Curves (#224)**: Optional Stage 0 dashboard for `printcal` linearization and ink limits. `CAL_` artefacts, Apply Calibration toggle feeding `printtarg -K` and `applycal`, channel-response plots, stale-cal warnings, and project/library persistence.
---
## 3. Future Roadmap
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "iccery",
"private": true,
"version": "0.8.4",
"version": "0.8.5",
"type": "module",
"scripts": {
"fetch-argyll": "node scripts/fetch-argyll.mjs",
"tauri": "tauri",
"test": "node src/js/profcheck.test.js && node src/js/chartread.test.js && node src/js/gamut_viewer.test.js"
"test": "node src/js/profcheck.test.js && node src/js/chartread.test.js && node src/js/gamut_viewer.test.js && node src/js/calibration.test.js"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
+1 -1
View File
@@ -1423,7 +1423,7 @@ dependencies = [
[[package]]
name = "iccery"
version = "0.8.4"
version = "0.8.5"
dependencies = [
"base64 0.22.1",
"image",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "iccery"
version = "0.8.4"
version = "0.8.5"
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
authors = ["Gordon"]
edition = "2021"
@@ -21,7 +21,7 @@ include = [
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
# This is only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "iccery_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
File diff suppressed because it is too large Load Diff
+73
View File
@@ -777,6 +777,12 @@ pub struct PrinttargConfig {
pub no_randomize: bool, // If true, pass -r (raster layout / no randomization)
pub basename: String, // Must match the .ti1 basename from Stage 1
pub cwd: String, // Working directory where the .ti1 file resides
/// Optional Argyll `.cal` applied via printtarg `-K` (or `-I` when embed-only).
#[serde(default)]
pub calibration_file: Option<String>,
/// When true, embed the calibration (`-I`) without applying it to printed patches.
#[serde(default)]
pub calibration_embed_only: bool,
}
pub fn build_targen_args(config: &TargenConfig) -> Vec<String> {
@@ -927,6 +933,18 @@ pub fn build_printtarg_args(config: &PrinttargConfig) -> Vec<String> {
}
args.push(config.dpi.to_string());
if let Some(ref cal) = config.calibration_file {
let trimmed = cal.trim();
if !trimmed.is_empty() {
if config.calibration_embed_only {
args.push("-I".to_string());
} else {
args.push("-K".to_string());
}
args.push(trimmed.to_string());
}
}
args.push(config.basename.clone());
args
}
@@ -1621,6 +1639,8 @@ mod tests {
no_randomize: false,
basename: "my_profile".to_string(),
cwd: "/tmp".to_string(),
calibration_file: None,
calibration_embed_only: false,
};
let args = build_printtarg_args(&config);
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "1", "-t", "100", "my_profile"]);
@@ -1638,6 +1658,8 @@ mod tests {
no_randomize: false,
basename: "cmyk_profile".to_string(),
cwd: "/home/user".to_string(),
calibration_file: None,
calibration_embed_only: false,
};
let args = build_printtarg_args(&config);
assert_eq!(args, vec!["-v", "-u", "-i", "CM", "-p", "Letter", "-R", "1", "-T", "300", "cmyk_profile"]);
@@ -1655,6 +1677,8 @@ mod tests {
no_randomize: false,
basename: "custom_target".to_string(),
cwd: "/tmp".to_string(),
calibration_file: None,
calibration_embed_only: false,
};
let args = build_printtarg_args(&config);
assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-R", "1", "-t", "150", "custom_target"]);
@@ -1672,6 +1696,8 @@ mod tests {
no_randomize: false,
basename: "my_profile".to_string(),
cwd: "/tmp".to_string(),
calibration_file: None,
calibration_embed_only: false,
};
let args = build_printtarg_args(&config);
assert_eq!(
@@ -1706,6 +1732,8 @@ mod tests {
no_randomize: false,
basename: "my_profile".to_string(),
cwd: "/tmp".to_string(),
calibration_file: None,
calibration_embed_only: false,
};
let args = build_printtarg_args(&config);
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "42", "-t", "300", "my_profile"]);
@@ -1723,11 +1751,56 @@ mod tests {
no_randomize: true,
basename: "my_profile".to_string(),
cwd: "/tmp".to_string(),
calibration_file: None,
calibration_embed_only: false,
};
let args = build_printtarg_args(&config);
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-r", "-t", "300", "my_profile"]);
}
#[test]
fn test_build_printtarg_args_with_calibration_apply() {
let config = PrinttargConfig {
instrument: "i1".to_string(),
page_size: "A4".to_string(),
bit_depth: 8,
dpi: 300,
custom_label: None,
random_seed: Some(1),
no_randomize: false,
basename: "my_profile".to_string(),
cwd: "/tmp".to_string(),
calibration_file: Some("CAL_photo.cal".to_string()),
calibration_embed_only: false,
};
let args = build_printtarg_args(&config);
assert_eq!(
args,
vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "1", "-t", "300", "-K", "CAL_photo.cal", "my_profile"]
);
}
#[test]
fn test_build_printtarg_args_with_calibration_embed_only() {
let config = PrinttargConfig {
instrument: "i1".to_string(),
page_size: "A4".to_string(),
bit_depth: 8,
dpi: 300,
custom_label: None,
random_seed: Some(1),
no_randomize: false,
basename: "my_profile".to_string(),
cwd: "/tmp".to_string(),
calibration_file: Some("lin.cal".to_string()),
calibration_embed_only: true,
};
let args = build_printtarg_args(&config);
assert!(args.contains(&"-I".to_string()));
assert!(!args.contains(&"-K".to_string()));
assert!(args.contains(&"lin.cal".to_string()));
}
#[test]
fn test_build_chartread_args_auto() {
let config = ChartreadConfig {
+10
View File
@@ -2,6 +2,7 @@ use tauri::Manager;
mod cgats;
mod commands;
mod calibration;
mod events;
mod macos_webview;
mod print;
@@ -92,6 +93,15 @@ pub fn run() {
commands::show_printer_properties,
commands::print_target_native,
commands::select_csv_save_path,
calibration::generate_calibration_target,
calibration::compute_calibration_curves,
calibration::apply_calibration,
calibration::parse_cal_file_cmd,
calibration::list_saved_calibrations,
calibration::save_calibration_to_library,
calibration::select_cal_file,
calibration::load_project_calibration,
calibration::save_project_calibration,
quality_store::save_verification_record,
quality_store::get_verification_history,
quality_store::clear_verification_history,
+19
View File
@@ -58,10 +58,15 @@ pub struct ProfilingPreset {
pub random_seed: Option<u32>,
#[serde(default)]
pub no_randomize: Option<bool>,
#[serde(default)]
pub calibration_file: Option<String>,
#[serde(default)]
pub apply_calibration: Option<bool>,
}
fn default_delta_e_good_max() -> f64 { 2.0 }
fn default_delta_e_warning_max() -> f64 { 5.0 }
fn default_cal_stale_days() -> u32 { 30 }
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AppSettings {
@@ -76,6 +81,8 @@ pub struct AppSettings {
pub custom_presets: Vec<ProfilingPreset>,
#[serde(default)]
pub enable_i1pro2_leds: bool,
#[serde(default = "default_cal_stale_days")]
pub calibration_stale_days: u32,
}
impl Default for AppSettings {
@@ -88,6 +95,7 @@ impl Default for AppSettings {
delta_e_warning_max: default_delta_e_warning_max(),
custom_presets: Vec::new(),
enable_i1pro2_leds: false,
calibration_stale_days: default_cal_stale_days(),
}
}
}
@@ -135,6 +143,8 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
device_power: None,
random_seed: Some(1),
no_randomize: Some(false),
calibration_file: None,
apply_calibration: None,
colprof_fwa: Some("D50".to_string()),
colprof_illuminant: None,
colprof_observer: None,
@@ -169,6 +179,8 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
device_power: None,
random_seed: Some(1),
no_randomize: Some(false),
calibration_file: None,
apply_calibration: None,
colprof_fwa: Some("D50".to_string()),
colprof_illuminant: None,
colprof_observer: None,
@@ -203,6 +215,8 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
device_power: None,
random_seed: Some(1),
no_randomize: Some(false),
calibration_file: None,
apply_calibration: None,
colprof_fwa: Some("D50".to_string()),
colprof_illuminant: None,
colprof_observer: None,
@@ -237,6 +251,8 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
device_power: None,
random_seed: Some(1),
no_randomize: Some(false),
calibration_file: None,
apply_calibration: None,
colprof_fwa: Some("D50".to_string()),
colprof_illuminant: None,
colprof_observer: None,
@@ -405,6 +421,8 @@ mod tests {
device_power: Some(1.2),
random_seed: Some(42),
no_randomize: Some(false),
calibration_file: None,
apply_calibration: None,
colprof_fwa: Some("D50".to_string()),
colprof_illuminant: None,
colprof_observer: None,
@@ -474,6 +492,7 @@ mod tests {
fn test_default_enable_i1pro2_leds() {
let settings = AppSettings::default();
assert_eq!(settings.enable_i1pro2_leds, false);
assert_eq!(settings.calibration_stale_days, 30);
}
#[test]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ICCery",
"version": "0.8.4",
"version": "0.8.5",
"identifier": "com.gronod.iccery",
"build": {
"frontendDist": "../src"
+138 -1
View File
@@ -49,6 +49,8 @@
<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;">
<option value="" disabled selected>Loading presets...</option>
</select>
<button type="button" id="btnCalibratePrinter" class="secondary btn-md" style="width:100%; margin-top:10px;" title="Optional printer linearization via printcal before profiling">Calibrate Printer</button>
<div id="calStatusChip" class="cal-status-chip cal-status-none">Calibration: None</div>
</div>
<nav class="stepper">
@@ -68,12 +70,112 @@
<button type="button" id="wizardNotificationClose" class="notification-close" title="Dismiss notice"></button>
</div>
<!-- Stage 0: Printer calibration (optional, parallel to the 5-stage wizard) -->
<section id="stage-cal" class="stage hidden">
<h2>Printer Calibration</h2>
<p>Linearize per-channel response and establish ink limits with Argyll <code>printcal</code> before building an ICC profile. Optional for RGB photo printers; strongly recommended for CMYK / RIP workflows.</p>
<div class="cal-status-banner cal-banner-active" data-cal-banner="always">
<span data-cal-banner-text>Calibration: None</span>
<label class="cal-apply-toggle" title="When enabled, subsequent Stage 2 printtarg runs use -K and Stage 4 applycal embeds the curves">
<input type="checkbox" id="calApplyToggleDash" data-cal-apply>
Apply Calibration
</label>
</div>
<div class="cal-dashboard-grid">
<div class="cal-card">
<h3>1. Calibration Chart</h3>
<div class="form-group has-tooltip">
<label>Colour Space</label>
<div class="radio-group">
<label><input type="radio" name="calColourSpace" value="rgb"> RGB (driver-managed)</label>
<label><input type="radio" name="calColourSpace" value="cmyk" checked> CMYK (RIP)</label>
</div>
<div class="tooltip-text">Calibration is most valuable on native CMYK devices. RGB printers already apply driver curves; results are often modest.</div>
</div>
<p id="calRgbHint" class="help-hint hidden">RGB driver-managed printers are typically already linearized. Calibration is optional here.</p>
<div class="input-row">
<div class="form-group has-tooltip">
<label for="calSteps">Steps per channel</label>
<input type="number" id="calSteps" min="11" max="51" value="21">
<div class="tooltip-text">Wedge density for each ink channel (1151). 21 is a good default; 33 for high-end inkjets.</div>
</div>
<div class="form-group has-tooltip">
<label for="calInkExplore">Ink-limit exploration (TAC %)</label>
<input type="number" id="calInkExplore" min="200" max="400" value="320">
<div class="tooltip-text">CMYK only. Total area coverage range passed to targen -l so printcal can recommend a TAC.</div>
</div>
</div>
<label class="checkbox-label" for="calNeutralEmphasis">
<input type="checkbox" id="calNeutralEmphasis">
Neutral-axis emphasis
</label>
<div class="stage-actions">
<button type="button" class="primary btn-lg" id="btnCalGenerate">Generate Calibration Target</button>
<button type="button" class="secondary btn-md" id="btnCalLayout">Create Layout & Print</button>
<button type="button" class="secondary btn-md" id="btnCalMeasure">Measure Chart</button>
</div>
</div>
<div class="cal-card">
<h3>2. Saved curves</h3>
<p class="help-hint" id="calCurrentFile">No .cal loaded</p>
<div class="btn-row wrap">
<button type="button" class="secondary btn-md" id="btnCalLoad">Load Existing…</button>
<button type="button" class="secondary btn-md" id="btnCalLibrary">Save to Library</button>
<button type="button" class="danger btn-md" id="btnCalClear">Clear</button>
</div>
<select id="calSavedSelect" style="width:100%; margin-top:10px;"></select>
<div class="stage-actions">
<button type="button" class="primary btn-lg" id="btnCalCompute">Compute Curves</button>
</div>
<p class="help-hint">Requires a measured <code>CAL_*.ti3</code> in the working directory. Existing <code>.cal</code> files are never overwritten without confirmation.</p>
</div>
</div>
<div class="cal-dashboard-grid">
<div class="cal-card">
<h3>Channel response</h3>
<svg id="calCurveSvg" class="cal-curve-svg" role="img" aria-label="Channel response curves"></svg>
<div id="calCurveLegend" class="cal-curve-legend"></div>
<p class="help-hint">Solid lines are post-linearization device response. Dashed line is identity (already linear).</p>
</div>
<div class="cal-card">
<h3>Ink limits</h3>
<div class="cal-tac-card">Total area coverage: <strong id="calTacValue"></strong></div>
<div class="form-group">
<label for="calTacOverride">Override TAC %</label>
<input type="number" id="calTacOverride" min="150" max="400" placeholder="use recommended">
</div>
<div id="calInkLimitControls"></div>
<p class="help-hint">Recommended power (targen -p): <strong id="calRecommendedPower"></strong>. Re-run Compute Curves after editing limits.</p>
</div>
</div>
<div class="stage-actions">
<button type="button" class="secondary btn-md" id="btnCalBackToWizard">← Back to profiling wizard</button>
</div>
<details class="log-container hidden" id="calLogContainer">
<summary>Process Output</summary>
<pre id="calLog"></pre>
</details>
</section>
<!-- Stage 1: targen -->
<section id="stage-1" class="stage active">
<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 id="calStage1Recommend" class="cal-status-banner cal-banner-stale hidden">
<span>No calibration applied — recommended for CMYK / RIP workflows.</span>
<button type="button" class="secondary btn-sm" id="btnCalRecalibrate">Calibrate Printer</button>
</div>
<div class="cal-status-banner hidden" data-cal-banner>
<span data-cal-banner-text>Calibration: None</span>
<label class="cal-apply-toggle"><input type="checkbox" data-cal-apply> Apply Calibration</label>
</div>
<div class="form-container" id="stage1FormContainer">
<!-- Basic Settings -->
@@ -319,6 +421,10 @@
<!-- Stage 2: printtarg -->
<section id="stage-2" class="stage hidden">
<h2>Print Layout</h2>
<div class="cal-status-banner hidden" data-cal-banner>
<span data-cal-banner-text>Calibration: None</span>
<label class="cal-apply-toggle"><input type="checkbox" data-cal-apply> Apply Calibration</label>
</div>
<p>Configure the target layout for your instrument and paper size, then generate printable TIFF images.</p>
<!-- Colour management warning banner -->
@@ -634,6 +740,10 @@
<!-- Stage 4: colprof -->
<section id="stage-4" class="stage hidden">
<h2>Create Profile</h2>
<div class="cal-status-banner hidden" data-cal-banner>
<span data-cal-banner-text>Calibration: None</span>
<label class="cal-apply-toggle"><input type="checkbox" data-cal-apply> Apply Calibration</label>
</div>
<p>Calculate the ICC profile from your target measurement data.</p>
<div class="form-container">
@@ -790,6 +900,10 @@
<!-- Stage 5: profcheck -->
<section id="stage-5" class="stage hidden">
<h2>Verify Profile</h2>
<div class="cal-status-banner hidden" data-cal-banner>
<span data-cal-banner-text>Calibration: None</span>
<label class="cal-apply-toggle"><input type="checkbox" data-cal-apply> Apply Calibration</label>
</div>
<p>Check the numerical accuracy of your profile against the original measurement data.</p>
<div class="stage-actions">
@@ -828,6 +942,7 @@
<summary>Verification History &amp; Drift Tracking</summary>
<div class="notification-banner warning hidden" id="driftAlertCard">
<span id="driftAlertIcon">⚠️</span><span id="driftAlertText"></span>
<button type="button" class="secondary btn-sm" id="btnDriftRecalibrate">Re-calibrate</button>
</div>
<div class="drift-filter-row hidden" id="driftFilterRow">
<label for="driftPrinterFilter">Filter by Printer:</label>
@@ -971,6 +1086,15 @@
<small id="deltaEThresholdError" class="help-hint" style="display:none; color: var(--error-color, #ff5f5f); margin-top: 6px;">Good threshold must be less than warning threshold.</small>
</div>
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
<label style="font-weight: 600;">Printer Calibration</label>
<div style="margin-top: 8px;">
<label for="calibrationStaleDays" class="sub-label">Warn when loaded .cal is older than (days)</label>
<input type="number" id="calibrationStaleDays" min="1" max="365" value="30">
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">Also warns when the stored printer name no longer matches the current destination. Default 30 days.</small>
</div>
</div>
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
<label style="font-weight: 600;">Diagnostics &amp; Logging</label>
<div class="input-row" style="margin-top: 8px;">
@@ -1000,6 +1124,19 @@
</div>
</dialog>
<dialog id="calCollisionDialog" class="settings-modal">
<div class="modal-content">
<h2>Calibration file exists</h2>
<p id="calCollisionMessage" style="white-space:pre-wrap;"></p>
<p class="help-hint">ICCery never overwrites a .cal without an explicit choice.</p>
<div class="modal-actions">
<button type="button" id="calOverwriteBtn" class="danger">Overwrite</button>
<button type="button" id="calRenameBtn" class="primary">Rename (timestamp)</button>
<button type="button" id="calCancelCollisionBtn" class="secondary">Cancel</button>
</div>
</div>
</dialog>
<!-- About Dialog -->
<dialog id="aboutDialog" class="settings-modal about-modal">
<div class="modal-content">
@@ -1007,7 +1144,7 @@
<img src="./assets/ICCery-logo.svg" alt="ICCery Logo" class="about-logo" />
</div>
<h2>About ICCery</h2>
<p><strong>Version:</strong> <span id="aboutVersion">v0.8.2</span> &bull; <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
<p><strong>Version:</strong> <span id="aboutVersion">v0.8.5</span> &bull; <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
<p><strong>Copyright &copy; 2026 Gordon Bolton. All rights reserved.</strong></p>
<h3>Licences & EULA</h3>
<div class="license-text-container">
+2
View File
@@ -9,6 +9,7 @@ import { initPresets } from './presets.js';
import { wizardState } from './state.js';
import { logger } from './logger.js';
import { CgatsInterop } from './cgats_interop.js';
import { initCalibration } from './calibration.js';
const { invoke } = window.__TAURI__.core;
@@ -135,6 +136,7 @@ document.addEventListener('DOMContentLoaded', () => {
safeInit('Stage 5 (Profcheck)', initProfcheck);
safeInit('Settings', initSettings);
safeInit('Presets', initPresets);
safeInit('Calibration', initCalibration);
// Double-rAF waits for layout + first paint of the dark CSS.
requestAnimationFrame(() => {
+658
View File
@@ -0,0 +1,658 @@
/**
* Printer calibration (linearization & ink limits) via printcal / applycal (#224).
*
* Optional Stage 0 workflow. The 5-stage wizard is unchanged when calibration
* is skipped. Curves feed subsequent printtarg (-K) and colprof (applycal).
*/
import { wizardState } from './state.js';
import { logger } from './logger.js';
const invoke = (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke)
? window.__TAURI__.core.invoke.bind(window.__TAURI__.core)
: async () => { throw new Error('Tauri invoke unavailable'); };
const listen = (window.__TAURI__ && window.__TAURI__.event && window.__TAURI__.event.listen)
? window.__TAURI__.event.listen.bind(window.__TAURI__.event)
: async () => () => {};
export const CAL_PREFIX = 'CAL_';
export const DEFAULT_STALE_DAYS = 30;
export const CHANNEL_COLORS = {
C: '#00b4d8',
M: '#e63980',
Y: '#f4d35e',
K: '#c5c5c5',
R: '#e74c3c',
G: '#2ecc71',
B: '#3498db',
};
const calState = {
status: 'none', // none | active | stale
calPath: null,
filename: null,
created: null,
applyEnabled: true,
printerName: null,
colourSpace: null,
inkLimits: [],
totalInkLimit: null,
curves: [],
calBasename: null,
recommendedPower: null,
ageDays: 0,
staleDays: DEFAULT_STALE_DAYS,
};
export function makeCalibrationBasename(basename) {
const trimmed = String(basename || '').trim();
const base = trimmed.startsWith(CAL_PREFIX) ? trimmed.slice(CAL_PREFIX.length) : trimmed;
return `${CAL_PREFIX}${base || 'printer'}`;
}
export function isCalibrationBasename(basename) {
return String(basename || '').trim().startsWith(CAL_PREFIX);
}
export function isCalibrationStale(ageDays, staleDays = DEFAULT_STALE_DAYS) {
return Number(ageDays) > Math.max(1, Number(staleDays) || DEFAULT_STALE_DAYS);
}
export function totalAreaCoverage(limits) {
if (!Array.isArray(limits) || limits.length === 0) return 0;
return limits.reduce((sum, item) => sum + (Number(item.percent) || 0), 0);
}
export function classifyCalibrationStatus({
calPath,
applyEnabled,
ageDays,
staleDays,
printerName,
currentPrinter,
} = {}) {
if (!calPath) return 'none';
if (printerName && currentPrinter && printerName !== currentPrinter) return 'stale';
if (isCalibrationStale(ageDays || 0, staleDays)) return 'stale';
if (applyEnabled === false) return 'active';
return 'active';
}
export function downsampleCurve(points, maxPoints = 48) {
if (!Array.isArray(points) || points.length <= maxPoints) return points || [];
const out = [];
const last = points.length - 1;
for (let i = 0; i < maxPoints; i += 1) {
const idx = Math.round((i / (maxPoints - 1)) * last);
out.push(points[idx]);
}
return out;
}
export function buildCurvePolyline(points, width = 320, height = 160, padding = 18) {
const pts = downsampleCurve(points);
if (!pts.length) return '';
const innerW = width - padding * 2;
const innerH = height - padding * 2;
return pts.map((p, i) => {
const x = padding + (Number(p[0]) || 0) * innerW;
const y = padding + innerH - (Number(p[1]) || 0) * innerH;
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
}
export function getActiveCalibration() {
return { ...calState };
}
export function getPrinttargCalibrationFields(basename) {
if (isCalibrationBasename(basename)) {
return { calibration_file: null, calibration_embed_only: false };
}
if (!calState.applyEnabled || !calState.calPath) {
return { calibration_file: null, calibration_embed_only: false };
}
return { calibration_file: calState.calPath, calibration_embed_only: false };
}
export async function applyCalibrationToProfile(profilePath) {
if (!calState.applyEnabled || !calState.calPath || !profilePath) return null;
try {
const result = await invoke('apply_calibration', {
config: {
cal_path: calState.calPath,
input_path: profilePath,
output_path: null,
unapply: false,
},
});
logger.info(`applycal: ${result.message}`, 'Calibration');
return result;
} catch (err) {
logger.error(`applycal failed: ${err}`, 'Calibration');
wizardState.showNotice(`Could not embed calibration curves into the profile: ${err}`, 'warning', 7000);
return null;
}
}
function statusLabel() {
if (calState.status === 'none') return 'Calibration: None';
const name = calState.filename || 'curves.cal';
if (calState.status === 'stale') return `Calibration: Stale (${name})`;
if (!calState.applyEnabled) return `Calibration: Loaded, not applied (${name})`;
return `Calibration: Active (${name})`;
}
function persistLocal() {
try {
localStorage.setItem('iccery.calibration', JSON.stringify({
calPath: calState.calPath,
applyEnabled: calState.applyEnabled,
printerName: calState.printerName,
colourSpace: calState.colourSpace,
created: calState.created,
calBasename: calState.calBasename,
}));
} catch (_) { /* private mode */ }
}
function restoreLocal() {
try {
const raw = localStorage.getItem('iccery.calibration');
if (!raw) return;
const parsed = JSON.parse(raw);
if (parsed && parsed.calPath) {
calState.calPath = parsed.calPath;
calState.applyEnabled = parsed.applyEnabled !== false;
calState.printerName = parsed.printerName || null;
calState.colourSpace = parsed.colourSpace || null;
calState.created = parsed.created || null;
calState.calBasename = parsed.calBasename || makeCalibrationBasename(wizardState.basename);
calState.filename = String(parsed.calPath).split(/[\\/]/).pop();
calState.status = 'active';
}
} catch (_) { /* ignore */ }
}
async function persistProject() {
if (!wizardState.cwd) return;
try {
await invoke('save_project_calibration', {
cwd: wizardState.cwd,
state: {
cal_path: calState.calPath,
apply_enabled: calState.applyEnabled,
printer_name: calState.printerName,
colour_space: calState.colourSpace,
created: calState.created,
cal_basename: calState.calBasename,
ink_limit_overrides: calState.inkLimits,
total_ink_override: calState.totalInkLimit,
},
});
} catch (err) {
logger.warn(`Could not persist project calibration: ${err}`, 'Calibration');
}
}
function refreshStatusFromMeta(meta, currentPrinter) {
if (!meta) {
calState.status = calState.calPath ? 'active' : 'none';
return;
}
calState.filename = meta.filename;
calState.created = meta.created;
calState.ageDays = meta.age_days || 0;
calState.curves = meta.curves || [];
if (meta.ink_limits && meta.ink_limits.length) calState.inkLimits = meta.ink_limits;
if (meta.total_ink_limit != null) calState.totalInkLimit = meta.total_ink_limit;
calState.status = classifyCalibrationStatus({
calPath: calState.calPath,
applyEnabled: calState.applyEnabled,
ageDays: calState.ageDays,
staleDays: calState.staleDays,
printerName: calState.printerName,
currentPrinter,
});
}
function renderBanners() {
const text = statusLabel();
document.querySelectorAll('[data-cal-banner-text]').forEach((el) => {
el.textContent = text;
});
document.querySelectorAll('[data-cal-banner]').forEach((el) => {
el.classList.toggle('hidden', calState.status === 'none' && el.dataset.calBanner !== 'always');
el.classList.toggle('cal-banner-stale', calState.status === 'stale');
el.classList.toggle('cal-banner-active', calState.status === 'active');
});
document.querySelectorAll('[data-cal-apply]').forEach((el) => {
el.checked = !!calState.applyEnabled && !!calState.calPath;
el.disabled = !calState.calPath;
});
const chip = document.getElementById('calStatusChip');
if (chip) {
chip.textContent = text;
chip.className = `cal-status-chip cal-status-${calState.status}`;
}
const rgbHint = document.getElementById('calRgbHint');
if (rgbHint) {
const cs = (document.querySelector('input[name="calColourSpace"]:checked') || {}).value
|| (document.querySelector('input[name="colourSpace"]:checked') || {}).value
|| 'rgb';
rgbHint.classList.toggle('hidden', cs !== 'rgb');
}
const stage1Banner = document.getElementById('calStage1Recommend');
if (stage1Banner) {
const cs = (document.querySelector('input[name="colourSpace"]:checked') || {}).value || 'rgb';
const show = calState.status === 'none' && cs === 'cmyk';
stage1Banner.classList.toggle('hidden', !show);
}
}
function renderPlots() {
const svg = document.getElementById('calCurveSvg');
const legend = document.getElementById('calCurveLegend');
if (!svg) return;
const width = 360;
const height = 180;
const padding = 22;
const grid = [0, 0.25, 0.5, 0.75, 1].map((t) => {
const x = padding + t * (width - padding * 2);
const y = padding + (1 - t) * (height - padding * 2);
return `<line x1="${padding}" y1="${y}" x2="${width - padding}" y2="${y}" class="cal-grid"/>`
+ `<line x1="${x}" y1="${padding}" x2="${x}" y2="${height - padding}" class="cal-grid"/>`;
}).join('');
const identity = buildCurvePolyline([[0, 0], [1, 1]], width, height, padding);
const paths = (calState.curves || []).map((curve) => {
const d = buildCurvePolyline(curve.points, width, height, padding);
const color = CHANNEL_COLORS[curve.channel] || '#7aa2f7';
return `<path d="${d}" fill="none" stroke="${color}" stroke-width="2"/>`;
}).join('');
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
svg.innerHTML = `${grid}<path d="${identity}" fill="none" stroke="#555" stroke-dasharray="4 3" stroke-width="1"/>${paths}`
+ `<text x="${padding}" y="${height - 4}" class="cal-axis-label">Input</text>`
+ `<text x="4" y="${padding}" class="cal-axis-label">Out</text>`;
if (legend) {
legend.innerHTML = (calState.curves || []).map((c) => {
const color = CHANNEL_COLORS[c.channel] || '#7aa2f7';
return `<span class="cal-legend-item"><i style="background:${color}"></i>${c.channel}</span>`;
}).join('') || '<span class="help-hint">No curves yet — compute after measuring the calibration chart.</span>';
}
}
function renderInkLimits() {
const wrap = document.getElementById('calInkLimitControls');
const tacEl = document.getElementById('calTacValue');
if (tacEl) {
const tac = calState.totalInkLimit != null ? calState.totalInkLimit : totalAreaCoverage(calState.inkLimits);
tacEl.textContent = tac ? `${tac.toFixed(0)} %` : '—';
}
if (!wrap) return;
if (!calState.inkLimits.length) {
wrap.innerHTML = '<p class="help-hint">Ink-limit recommendations appear after printcal runs. Editable overrides can be re-computed.</p>';
return;
}
wrap.innerHTML = calState.inkLimits.map((lim) => {
const color = CHANNEL_COLORS[lim.channel] || '#888';
return `<label class="cal-ink-row"><span style="color:${color}">${lim.channel}</span>`
+ `<input type="range" min="50" max="100" step="0.5" value="${lim.percent}" data-cal-ink="${lim.channel}">`
+ `<input type="number" min="50" max="100" step="0.5" value="${Number(lim.percent).toFixed(1)}" data-cal-ink-num="${lim.channel}">`
+ `<span>%</span></label>`;
}).join('');
}
function renderDashboardMeta() {
const fileEl = document.getElementById('calCurrentFile');
if (fileEl) fileEl.textContent = calState.filename || 'No .cal loaded';
const powerEl = document.getElementById('calRecommendedPower');
if (powerEl) powerEl.textContent = calState.recommendedPower != null ? calState.recommendedPower.toFixed(2) : '—';
renderBanners();
renderPlots();
renderInkLimits();
}
async function loadCalPath(path) {
const meta = await invoke('parse_cal_file_cmd', { path });
calState.calPath = path;
calState.applyEnabled = true;
refreshStatusFromMeta(meta, wizardState.printerName);
if (!calState.calBasename) {
calState.calBasename = makeCalibrationBasename(wizardState.basename || 'printer');
}
persistLocal();
await persistProject();
renderDashboardMeta();
wizardState.showNotice(`Loaded calibration ${meta.filename}`, 'success', 4000);
}
async function enterCalibrationSession() {
const calBase = calState.calBasename || makeCalibrationBasename(wizardState.basename || 'printer');
calState.calBasename = calBase;
if (wizardState.basename && !isCalibrationBasename(wizardState.basename)) {
wizardState.profileBasename = wizardState.basename;
}
wizardState.sessionMode = 'calibration';
wizardState.basename = calBase;
wizardState.setTarget(calBase, wizardState.cwd);
const { setStage1Result } = await import('./printtarg.js');
const { setStage2Result } = await import('./chartread.js');
setStage1Result(calBase, wizardState.cwd);
setStage2Result(calBase, wizardState.cwd);
}
async function exitCalibrationSession() {
wizardState.sessionMode = 'profile';
if (wizardState.profileBasename) {
wizardState.basename = wizardState.profileBasename;
wizardState.setTarget(wizardState.profileBasename, wizardState.cwd);
const { setStage1Result } = await import('./printtarg.js');
setStage1Result(wizardState.profileBasename, wizardState.cwd);
}
}
async function generateTarget() {
const cwd = wizardState.cwd;
if (!cwd) {
wizardState.showNotice('Set a working directory in Stage 1 before generating a calibration chart.', 'warning');
return;
}
const cs = (document.querySelector('input[name="calColourSpace"]:checked') || {}).value
|| (document.querySelector('input[name="colourSpace"]:checked') || {}).value
|| 'rgb';
const steps = Math.max(11, Math.min(51, parseInt(document.getElementById('calSteps')?.value, 10) || 21));
const ink = parseInt(document.getElementById('calInkExplore')?.value, 10);
const basename = makeCalibrationBasename(wizardState.profileBasename || wizardState.basename || 'printer');
calState.calBasename = basename;
calState.colourSpace = cs;
const btn = document.getElementById('btnCalGenerate');
const logPre = document.getElementById('calLog');
const logBox = document.getElementById('calLogContainer');
if (logBox) logBox.classList.remove('hidden');
if (btn) btn.disabled = true;
const processId = `targen_${basename}`;
if (logPre) logPre.textContent = 'Starting targen (calibration chart)...\n';
try {
const unlistenStdout = await listen('process:stdout', (event) => {
if (event.payload.id === processId && event.payload.line && logPre) {
logPre.textContent += `${event.payload.line}\n`;
}
});
const unlistenStderr = await listen('process:stderr', (event) => {
if (event.payload.id === processId && event.payload.line && logPre) {
logPre.textContent += `ERR: ${event.payload.line}\n`;
}
});
const unlistenExit = await listen('process:exit', (event) => {
if (event.payload.id !== processId) return;
unlistenStdout();
unlistenStderr();
unlistenExit();
if (btn) btn.disabled = false;
if (event.payload.code === 0) {
if (logPre) logPre.textContent += '\n[SUCCESS] Calibration .ti1 generated.\n';
wizardState.showNotice(`Calibration chart ${basename}.ti1 is ready. Create a layout and print it uncalibrated.`, 'success', 6000);
} else if (logPre) {
logPre.textContent += `\n[ERROR] targen exited with code ${event.payload.code}.\n`;
}
});
await invoke('generate_calibration_target', {
config: {
colour_space: cs,
steps_per_channel: steps,
ink_limit_exploration: Number.isFinite(ink) ? ink : null,
channels: null,
white_patches: 4,
neutral_emphasis: !!(document.getElementById('calNeutralEmphasis') || {}).checked,
basename,
cwd,
},
});
} catch (err) {
if (btn) btn.disabled = false;
logger.error(`generate_calibration_target failed: ${err}`, 'Calibration');
wizardState.showNotice(`Could not generate calibration chart: ${err}`, 'error');
}
}
function collisionChoice(existingPath) {
return new Promise((resolve) => {
const dialog = document.getElementById('calCollisionDialog');
const msg = document.getElementById('calCollisionMessage');
if (msg) msg.textContent = `A calibration file already exists:\n${existingPath}`;
if (!dialog || typeof dialog.showModal !== 'function') {
const ok = window.confirm(`Overwrite existing calibration ${existingPath}?`);
resolve(ok ? 'overwrite' : 'cancel');
return;
}
const finish = (choice) => {
dialog.close();
resolve(choice);
};
document.getElementById('calOverwriteBtn')?.addEventListener('click', () => finish('overwrite'), { once: true });
document.getElementById('calRenameBtn')?.addEventListener('click', () => finish('rename'), { once: true });
document.getElementById('calCancelCollisionBtn')?.addEventListener('click', () => finish('cancel'), { once: true });
dialog.showModal();
});
}
async function computeCurves(forceOverwrite = false, outputName = null) {
const cwd = wizardState.cwd;
const basename = calState.calBasename || makeCalibrationBasename(wizardState.basename || 'printer');
if (!cwd) {
wizardState.showNotice('Working directory is not set.', 'warning');
return;
}
const btn = document.getElementById('btnCalCompute');
if (btn) {
btn.disabled = true;
btn.textContent = 'Computing…';
}
const channelLimits = [];
document.querySelectorAll('[data-cal-ink-num]').forEach((el) => {
channelLimits.push({ channel: el.getAttribute('data-cal-ink-num'), percent: parseFloat(el.value) });
});
const tac = parseFloat(document.getElementById('calTacOverride')?.value);
try {
const result = await invoke('compute_calibration_curves', {
config: {
ti3_basename: basename,
cwd,
output_cal: outputName,
previous_cal: calState.calPath,
force_overwrite: forceOverwrite,
no_ink_limit: false,
verify: false,
total_ink_limit: Number.isFinite(tac) ? tac : null,
channel_limits: channelLimits,
},
});
calState.calPath = result.cal_path;
calState.filename = String(result.cal_path).split(/[\\/]/).pop();
calState.inkLimits = result.ink_limits || [];
calState.totalInkLimit = result.total_ink_limit;
calState.recommendedPower = result.recommended_power;
calState.applyEnabled = true;
calState.printerName = wizardState.printerName || calState.printerName;
calState.created = new Date().toISOString();
refreshStatusFromMeta(result.metadata, wizardState.printerName);
persistLocal();
await persistProject();
renderDashboardMeta();
wizardState.showNotice(result.message, 'success', 6000);
} catch (err) {
const message = String(err);
if (/already exists/i.test(message) && !forceOverwrite) {
const choice = await collisionChoice(message);
if (choice === 'overwrite') {
await computeCurves(true, outputName);
} else if (choice === 'rename') {
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
await computeCurves(true, `${basename}_${stamp}.cal`);
}
} else {
wizardState.showNotice(`printcal failed: ${err}`, 'error', 8000);
}
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = 'Compute Curves';
}
}
}
async function refreshSavedList() {
const select = document.getElementById('calSavedSelect');
if (!select) return;
try {
const list = await invoke('list_saved_calibrations', { cwd: wizardState.cwd || null });
const current = select.value;
select.innerHTML = '<option value="">Recent calibrations…</option>';
list.forEach((item) => {
const opt = document.createElement('option');
opt.value = item.path;
const stale = isCalibrationStale(item.age_days, calState.staleDays) ? ' (stale)' : '';
opt.textContent = `${item.filename}${stale}`;
select.appendChild(opt);
});
if (current) select.value = current;
} catch (err) {
logger.warn(`list_saved_calibrations: ${err}`, 'Calibration');
}
}
export function initCalibration() {
restoreLocal();
renderDashboardMeta();
const openBtn = document.getElementById('btnCalibratePrinter');
if (openBtn) {
openBtn.addEventListener('click', () => {
wizardState.navigateToStage(0);
refreshSavedList();
});
}
document.getElementById('btnCalBackToWizard')?.addEventListener('click', () => {
exitCalibrationSession();
wizardState.navigateToStage(1);
});
document.getElementById('btnCalGenerate')?.addEventListener('click', generateTarget);
document.getElementById('btnCalLayout')?.addEventListener('click', async () => {
await enterCalibrationSession();
wizardState.showNotice('Printing a calibration chart: color management stays bypassed and curves are not applied to this target.', 'info', 7000);
wizardState.navigateToStage(2);
});
document.getElementById('btnCalMeasure')?.addEventListener('click', async () => {
await enterCalibrationSession();
wizardState.showNotice('Measuring the calibration chart. After Finish, return here and compute curves.', 'info', 7000);
wizardState.navigateToStage(3);
});
document.getElementById('btnCalCompute')?.addEventListener('click', () => computeCurves(false, null));
document.getElementById('btnCalLoad')?.addEventListener('click', async () => {
try {
const picked = await invoke('select_cal_file', { defaultDir: wizardState.cwd || null });
if (picked) await loadCalPath(picked);
} catch (err) {
wizardState.showNotice(`Could not open .cal file: ${err}`, 'error');
}
});
document.getElementById('btnCalClear')?.addEventListener('click', async () => {
calState.status = 'none';
calState.calPath = null;
calState.filename = null;
calState.curves = [];
calState.inkLimits = [];
calState.totalInkLimit = null;
persistLocal();
await persistProject();
renderDashboardMeta();
wizardState.showNotice('Calibration cleared. Profiling will run without printcal curves.', 'info');
});
document.getElementById('btnCalLibrary')?.addEventListener('click', async () => {
if (!calState.calPath) return;
try {
const dest = await invoke('save_calibration_to_library', { calPath: calState.calPath });
wizardState.showNotice(`Copied to library: ${dest}`, 'success');
refreshSavedList();
} catch (err) {
wizardState.showNotice(`Library save failed: ${err}`, 'error');
}
});
document.getElementById('calSavedSelect')?.addEventListener('change', async (ev) => {
if (ev.target.value) await loadCalPath(ev.target.value);
});
document.getElementById('calApplyToggleDash')?.addEventListener('change', async (ev) => {
calState.applyEnabled = !!ev.target.checked;
persistLocal();
await persistProject();
renderBanners();
});
document.querySelectorAll('[data-cal-apply]').forEach((el) => {
el.addEventListener('change', async (ev) => {
calState.applyEnabled = !!ev.target.checked;
persistLocal();
await persistProject();
renderBanners();
});
});
document.getElementById('btnCalRecalibrate')?.addEventListener('click', () => {
wizardState.navigateToStage(0);
});
document.getElementById('btnDriftRecalibrate')?.addEventListener('click', () => {
wizardState.navigateToStage(0);
});
document.querySelectorAll('input[name="calColourSpace"]').forEach((el) => {
el.addEventListener('change', renderBanners);
});
document.querySelectorAll('input[name="colourSpace"]').forEach((el) => {
el.addEventListener('change', renderBanners);
});
document.getElementById('calInkLimitControls')?.addEventListener('input', (ev) => {
const ch = ev.target.getAttribute('data-cal-ink') || ev.target.getAttribute('data-cal-ink-num');
if (!ch) return;
const val = parseFloat(ev.target.value);
const range = document.querySelector(`[data-cal-ink="${ch}"]`);
const num = document.querySelector(`[data-cal-ink-num="${ch}"]`);
if (range && ev.target !== range) range.value = val;
if (num && ev.target !== num) num.value = val;
const item = calState.inkLimits.find((l) => l.channel === ch);
if (item) item.percent = val;
const tacEl = document.getElementById('calTacValue');
if (tacEl) tacEl.textContent = `${totalAreaCoverage(calState.inkLimits).toFixed(0)} %`;
});
window.addEventListener('stage-changed', (event) => {
if (event.detail && event.detail.stage !== 0 && event.detail.stage !== 2 && event.detail.stage !== 3) {
if (wizardState.sessionMode === 'calibration') {
exitCalibrationSession();
}
}
renderBanners();
});
window.addEventListener('settings-saved', (event) => {
const days = event.detail && event.detail.calibration_stale_days;
if (days) calState.staleDays = days;
refreshStatusFromMeta({
filename: calState.filename,
created: calState.created,
age_days: calState.ageDays,
curves: calState.curves,
ink_limits: calState.inkLimits,
total_ink_limit: calState.totalInkLimit,
}, wizardState.printerName);
renderBanners();
});
if (wizardState.cwd) {
invoke('load_project_calibration', { cwd: wizardState.cwd }).then(async (state) => {
if (state && state.cal_path) {
try { await loadCalPath(state.cal_path); } catch (_) { /* missing file */ }
calState.applyEnabled = state.apply_enabled !== false;
renderBanners();
}
}).catch(() => {});
}
}
+109
View File
@@ -0,0 +1,109 @@
// Unit tests for calibration helpers (#224).
// node src/js/calibration.test.js
if (typeof window === 'undefined') {
globalThis.window = {
__TAURI__: {
core: { invoke: () => Promise.resolve() },
event: { listen: () => Promise.resolve(() => {}) }
},
addEventListener: () => {},
dispatchEvent: () => {},
devicePixelRatio: 1
};
globalThis.document = {
getElementById: () => null,
querySelectorAll: () => [],
querySelector: () => null,
createElement: () => ({
style: {},
appendChild() {},
addEventListener() {},
textContent: '',
className: '',
setAttribute() {}
})
};
globalThis.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {}
};
}
const {
makeCalibrationBasename,
isCalibrationBasename,
isCalibrationStale,
totalAreaCoverage,
classifyCalibrationStatus,
downsampleCurve,
buildCurvePolyline,
getPrinttargCalibrationFields,
CAL_PREFIX,
} = await import('./calibration.js');
let passed = 0;
let total = 0;
function assert(cond, name) {
total += 1;
if (cond) {
passed += 1;
console.log(` ok ${name}`);
} else {
console.error(` FAIL ${name}`);
}
}
export function runAll() {
passed = 0;
total = 0;
console.log('calibration.test.js');
assert(makeCalibrationBasename('photo') === 'CAL_photo', 'prefix profile basename');
assert(makeCalibrationBasename('CAL_photo') === 'CAL_photo', 'do not double-prefix');
assert(makeCalibrationBasename('') === 'CAL_printer', 'empty falls back to CAL_printer');
assert(isCalibrationBasename('CAL_x') === true, 'detects CAL_ prefix');
assert(isCalibrationBasename('photo') === false, 'profile basename is not cal');
assert(CAL_PREFIX === 'CAL_', 'prefix constant');
assert(isCalibrationStale(10, 30) === false, 'fresh cal is not stale');
assert(isCalibrationStale(30, 30) === false, 'equal to threshold is not stale');
assert(isCalibrationStale(31, 30) === true, 'older than threshold is stale');
assert(totalAreaCoverage([]) === 0, 'empty TAC');
assert(totalAreaCoverage([{ percent: 90 }, { percent: 80 }, { percent: 70 }, { percent: 60 }]) === 300, 'sum TAC');
assert(classifyCalibrationStatus({}) === 'none', 'no path => none');
assert(classifyCalibrationStatus({ calPath: '/a.cal', applyEnabled: true, ageDays: 2, staleDays: 30 }) === 'active', 'fresh active');
assert(classifyCalibrationStatus({ calPath: '/a.cal', ageDays: 40, staleDays: 30 }) === 'stale', 'age stale');
assert(
classifyCalibrationStatus({
calPath: '/a.cal',
ageDays: 1,
printerName: 'Epson',
currentPrinter: 'Canon',
}) === 'stale',
'printer mismatch is stale'
);
const long = Array.from({ length: 256 }, (_, i) => [i / 255, i / 255]);
const ds = downsampleCurve(long, 48);
assert(ds.length === 48, 'downsample length');
assert(ds[0][0] === 0, 'downsample starts at 0');
assert(Math.abs(ds[ds.length - 1][0] - 1) < 1e-9, 'downsample ends at 1');
const poly = buildCurvePolyline([[0, 0], [1, 1]], 100, 100, 10);
assert(poly.startsWith('M'), 'polyline starts with move');
assert(poly.includes('L'), 'polyline has line');
const skipped = getPrinttargCalibrationFields('CAL_photo');
assert(skipped.calibration_file == null, 'calibration charts do not apply -K to themselves');
console.log(`\n${passed}/${total} passed`);
if (passed !== total) process.exitCode = 1;
return { passed, total };
}
runAll();
+11
View File
@@ -4,6 +4,7 @@ import { setStage4Result } from './profcheck.js';
import { loadGamutMesh } from './gamut_viewer.js';
import { wizardState } from './state.js';
import { logger } from './logger.js';
import { applyCalibrationToProfile, getActiveCalibration } from './calibration.js';
let chartreadBasename = "";
let chartreadCwd = "";
@@ -159,6 +160,16 @@ export function initColprof() {
wizardState.setTarget(basename, cwd);
setStage4Result(basename, cwd);
const cal = getActiveCalibration();
if (cal.applyEnabled && cal.calPath) {
logPre.textContent += `\nApplying calibration ${cal.filename || cal.calPath} via applycal...\n`;
const applied = await applyCalibrationToProfile(profilePath);
if (applied && applied.output_path) {
profilePath = applied.output_path;
logPre.textContent += `[SUCCESS] ${applied.message}\n`;
}
}
// Automatically extract gamut mesh for 3D visualization
triggerGamutExtraction(basename, cwd, profilePath);
} else {
+4
View File
@@ -1,5 +1,7 @@
const { invoke } = window.__TAURI__.core;
import { getActiveCalibration } from './calibration.js';
let currentPresets = [];
let activePresetId = "preset-std-rgb";
@@ -417,6 +419,8 @@ export async function initPresets() {
colprof_observer,
colprof_input_viewing_cond,
colprof_output_viewing_cond,
calibration_file: getActiveCalibration().calPath || null,
apply_calibration: getActiveCalibration().applyEnabled,
};
}
+2
View File
@@ -3,6 +3,7 @@ const { listen } = window.__TAURI__.event;
import { setStage2Result } from './chartread.js';
import { wizardState } from './state.js';
import { logger } from './logger.js';
import { getPrinttargCalibrationFields } from './calibration.js';
// Module-level state: set by Stage 1 when it completes
let stage1Basename = "";
@@ -505,6 +506,7 @@ export function initPrinttarg() {
no_randomize: noRandomize,
basename: stage1Basename,
cwd: stage1Cwd,
...getPrinttargCalibrationFields(stage1Basename),
};
const processId = `printtarg_${stage1Basename}`;
+5
View File
@@ -70,6 +70,10 @@ export async function initSettings() {
if (deltaEWarningMax) {
deltaEWarningMax.value = Number(settings.delta_e_warning_max ?? 5.0).toFixed(1);
}
const staleDays = document.getElementById('calibrationStaleDays');
if (staleDays) {
staleDays.value = Number(settings.calibration_stale_days ?? 30);
}
validateDeltaEThresholds();
await refreshLogPath();
dialog.showModal();
@@ -150,6 +154,7 @@ export async function initSettings() {
delta_e_good_max: getInputValueAsFloat('deltaEGoodMax', 2.0),
delta_e_warning_max: getInputValueAsFloat('deltaEWarningMax', 5.0),
enable_i1pro2_leds: enableI1Pro2Leds ? enableI1Pro2Leds.checked : false,
calibration_stale_days: Math.max(1, parseInt(document.getElementById('calibrationStaleDays')?.value, 10) || 30),
};
await invoke('save_settings', { settings });
logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings');
+14 -2
View File
@@ -8,6 +8,8 @@ export const wizardState = {
cwd: "",
printerName: "",
noticeTimer: null,
sessionMode: "profile",
profileBasename: "",
setTarget(basename, cwd) {
if (basename) this.basename = basename;
@@ -61,6 +63,10 @@ export const wizardState = {
const stages = document.querySelectorAll('.stage');
steps.forEach(s => {
if (stageNumber === 0) {
s.classList.remove('active');
return;
}
if (s.getAttribute('data-step') === String(stageNumber)) {
s.classList.remove('disabled');
s.classList.add('active');
@@ -70,7 +76,7 @@ export const wizardState = {
});
stages.forEach(s => {
if (s.id === `stage-${stageNumber}`) {
if (s.id === `stage-${stageNumber}` || (stageNumber === 0 && s.id === 'stage-cal')) {
s.classList.remove('hidden');
s.classList.add('active');
} else {
@@ -90,7 +96,13 @@ export const wizardState = {
async navigateToStage(stageNumber) {
const targetNum = parseInt(stageNumber, 10);
if (isNaN(targetNum) || targetNum < 1 || targetNum > 5) return false;
if (isNaN(targetNum) || targetNum < 0 || targetNum > 5) return false;
if (targetNum === 0) {
this.currentStage = 0;
this.applyStageDOM(0);
return true;
}
if (targetNum === 1) {
this.currentStage = 1;
+117
View File
@@ -1923,3 +1923,120 @@ button.danger:hover {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
}
/* Printer calibration dashboard (#224) */
.cal-status-chip {
margin-top: 8px;
font-size: 0.72rem;
line-height: 1.3;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border-color);
color: var(--text-muted, #9aa);
background: rgba(255, 255, 255, 0.03);
}
.cal-status-active {
color: #86efac;
border-color: rgba(34, 197, 94, 0.35);
}
.cal-status-stale {
color: #fbbf24;
border-color: rgba(251, 191, 36, 0.4);
}
.cal-status-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin: 0 0 16px;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: rgba(0, 122, 204, 0.08);
font-size: 0.88rem;
}
.cal-banner-stale {
background: rgba(251, 191, 36, 0.08);
border-color: rgba(251, 191, 36, 0.35);
}
.cal-banner-active {
background: rgba(34, 197, 94, 0.08);
border-color: rgba(34, 197, 94, 0.3);
}
.cal-apply-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 0.82rem;
}
.cal-dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 1100px) {
.cal-dashboard-grid { grid-template-columns: 1fr; }
}
.cal-card {
background: var(--panel-color);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 16px 18px;
}
.cal-card h3 {
margin: 0 0 12px;
font-size: 1rem;
}
.cal-curve-svg {
width: 100%;
height: 180px;
background: #14141a;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.cal-grid {
stroke: #2a2a33;
stroke-width: 1;
}
.cal-axis-label {
fill: #888;
font-size: 10px;
font-family: system-ui, sans-serif;
}
.cal-curve-legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
font-size: 0.8rem;
}
.cal-legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.cal-legend-item i {
width: 10px;
height: 10px;
border-radius: 2px;
display: inline-block;
}
.cal-tac-card {
font-size: 0.95rem;
margin-bottom: 10px;
}
.cal-ink-row {
display: grid;
grid-template-columns: 1.4rem 1fr 4.5rem auto;
gap: 8px;
align-items: center;
margin: 6px 0;
font-weight: 600;
}
.cal-ink-row input[type="number"] {
width: 100%;
}