feat(cal): printer linearization via printcal / applycal (#224) #227
@@ -1,5 +1,15 @@
|
||||
# ICCery Agent Notes
|
||||
|
||||
## Printer Calibration (`printcal` / `applycal`) (#224)
|
||||
|
||||
- Optional Stage 0 dashboard, opened from **Calibrate Printer**. The 1–5 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`
|
||||
|
||||
@@ -2,19 +2,20 @@
|
||||
|
||||
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS.
|
||||
|
||||
[](https://git.i3omb.com/gronod/ICCery)
|
||||
[](https://git.i3omb.com/gronod/ICCery)
|
||||
[](https://git.i3omb.com/gronod/ICCery)
|
||||
[](https://tauri.app)
|
||||
[](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
@@ -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
@@ -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"
|
||||
|
||||
Generated
+1
-1
@@ -1423,7 +1423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iccery"
|
||||
version = "0.8.4"
|
||||
version = "0.8.5"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
@@ -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 (11–51). 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 & 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 & 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> • <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
|
||||
<p><strong>Version:</strong> <span id="aboutVersion">v0.8.5</span> • <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
|
||||
<p><strong>Copyright © 2026 Gordon Bolton. All rights reserved.</strong></p>
|
||||
<h3>Licences & EULA</h3>
|
||||
<div class="license-text-container">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user