feat(cal): printer linearization via printcal / applycal (#224) #227
@@ -1,5 +1,15 @@
|
|||||||
# ICCery Agent Notes
|
# 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
|
## Stage 5 Verification / Profcheck
|
||||||
|
|
||||||
- `profcheck` output is parsed from both JSON summaries (preferred) and legacy plain-text report formats.
|
- `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)
|
- Verification & drift tests: `node src/js/profcheck.test.js` (21 tests)
|
||||||
- Chartread classifier & XY table tests: `node src/js/chartread.test.js` (39 tests)
|
- Chartread classifier & XY table tests: `node src/js/chartread.test.js` (39 tests)
|
||||||
- Gamut viewer tests: `node src/js/gamut_viewer.test.js`
|
- 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())`
|
- Browser devtools console: `import('./profcheck.test.js').then(m => m.runAll())`
|
||||||
- **Frontend development server**: `npm run tauri dev`
|
- **Frontend development server**: `npm run tauri dev`
|
||||||
- **Production package build**: `npm run tauri build`
|
- **Production package build**: `npm run tauri build`
|
||||||
|
|||||||
@@ -2,19 +2,20 @@
|
|||||||
|
|
||||||
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS.
|
> 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://git.i3omb.com/gronod/ICCery)
|
||||||
[](https://tauri.app)
|
[](https://tauri.app)
|
||||||
[](LICENCE.md)
|
[](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
|
## Key Features
|
||||||
|
|
||||||
- 🪄 **Linear 5-Stage Wizard Workflow**:
|
- 🪄 **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:
|
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).
|
- **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.
|
- **Windows**: GDI uncorrected raw printing and DEVMODE preferences.
|
||||||
@@ -54,10 +55,12 @@ flowchart TD
|
|||||||
QualityStore[Verification History & Drift Analytics]
|
QualityStore[Verification History & Drift Analytics]
|
||||||
PrintEngine["Raw Print Subsystem (GDI / CUPS / NSPrintPanel)"]
|
PrintEngine["Raw Print Subsystem (GDI / CUPS / NSPrintPanel)"]
|
||||||
ProcMgr[Async Subprocess IPC Manager]
|
ProcMgr[Async Subprocess IPC Manager]
|
||||||
|
CalStore[Calibration .cal library]
|
||||||
|
|
||||||
UI <--> State
|
UI <--> State
|
||||||
State <--> ProcMgr
|
State <--> ProcMgr
|
||||||
State <--> QualityStore
|
State <--> QualityStore
|
||||||
|
State <--> CalStore
|
||||||
ProcMgr --> ThreeJS
|
ProcMgr --> ThreeJS
|
||||||
UI --> PrintEngine
|
UI --> PrintEngine
|
||||||
QualityStore --> UI
|
QualityStore --> UI
|
||||||
@@ -67,6 +70,7 @@ flowchart TD
|
|||||||
BIN_TAR[targen]
|
BIN_TAR[targen]
|
||||||
BIN_PRT[printtarg]
|
BIN_PRT[printtarg]
|
||||||
BIN_CHR[chartread]
|
BIN_CHR[chartread]
|
||||||
|
BIN_CAL[printcal / applycal]
|
||||||
BIN_COL[colprof]
|
BIN_COL[colprof]
|
||||||
BIN_CHK[profcheck]
|
BIN_CHK[profcheck]
|
||||||
BIN_GAM[iccgamut]
|
BIN_GAM[iccgamut]
|
||||||
@@ -75,6 +79,7 @@ flowchart TD
|
|||||||
ProcMgr -- stdin/stdout/stderr pipes --> BIN_TAR
|
ProcMgr -- stdin/stdout/stderr pipes --> BIN_TAR
|
||||||
ProcMgr -- stdin/stdout/stderr pipes --> BIN_PRT
|
ProcMgr -- stdin/stdout/stderr pipes --> BIN_PRT
|
||||||
ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHR
|
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_COL
|
||||||
ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHK
|
ProcMgr -- stdin/stdout/stderr pipes --> BIN_CHK
|
||||||
ProcMgr -- stdin/stdout/stderr pipes --> BIN_GAM
|
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.
|
- **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.
|
- **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`).
|
- **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] **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.
|
- [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
|
## 3. Future Roadmap
|
||||||
|
|||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "iccery",
|
"name": "iccery",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.4",
|
"version": "0.8.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"fetch-argyll": "node scripts/fetch-argyll.mjs",
|
"fetch-argyll": "node scripts/fetch-argyll.mjs",
|
||||||
"tauri": "tauri",
|
"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": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2"
|
"@tauri-apps/cli": "^2"
|
||||||
|
|||||||
Generated
+1
-1
@@ -1423,7 +1423,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iccery"
|
name = "iccery"
|
||||||
version = "0.8.4"
|
version = "0.8.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"image",
|
"image",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iccery"
|
name = "iccery"
|
||||||
version = "0.8.4"
|
version = "0.8.5"
|
||||||
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
||||||
authors = ["Gordon"]
|
authors = ["Gordon"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -21,7 +21,7 @@ include = [
|
|||||||
[lib]
|
[lib]
|
||||||
# The `_lib` suffix may seem redundant but it is necessary
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
# 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"
|
name = "iccery_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
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 no_randomize: bool, // If true, pass -r (raster layout / no randomization)
|
||||||
pub basename: String, // Must match the .ti1 basename from Stage 1
|
pub basename: String, // Must match the .ti1 basename from Stage 1
|
||||||
pub cwd: String, // Working directory where the .ti1 file resides
|
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> {
|
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());
|
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.push(config.basename.clone());
|
||||||
args
|
args
|
||||||
}
|
}
|
||||||
@@ -1621,6 +1639,8 @@ mod tests {
|
|||||||
no_randomize: false,
|
no_randomize: false,
|
||||||
basename: "my_profile".to_string(),
|
basename: "my_profile".to_string(),
|
||||||
cwd: "/tmp".to_string(),
|
cwd: "/tmp".to_string(),
|
||||||
|
calibration_file: None,
|
||||||
|
calibration_embed_only: false,
|
||||||
};
|
};
|
||||||
let args = build_printtarg_args(&config);
|
let args = build_printtarg_args(&config);
|
||||||
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "1", "-t", "100", "my_profile"]);
|
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,
|
no_randomize: false,
|
||||||
basename: "cmyk_profile".to_string(),
|
basename: "cmyk_profile".to_string(),
|
||||||
cwd: "/home/user".to_string(),
|
cwd: "/home/user".to_string(),
|
||||||
|
calibration_file: None,
|
||||||
|
calibration_embed_only: false,
|
||||||
};
|
};
|
||||||
let args = build_printtarg_args(&config);
|
let args = build_printtarg_args(&config);
|
||||||
assert_eq!(args, vec!["-v", "-u", "-i", "CM", "-p", "Letter", "-R", "1", "-T", "300", "cmyk_profile"]);
|
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,
|
no_randomize: false,
|
||||||
basename: "custom_target".to_string(),
|
basename: "custom_target".to_string(),
|
||||||
cwd: "/tmp".to_string(),
|
cwd: "/tmp".to_string(),
|
||||||
|
calibration_file: None,
|
||||||
|
calibration_embed_only: false,
|
||||||
};
|
};
|
||||||
let args = build_printtarg_args(&config);
|
let args = build_printtarg_args(&config);
|
||||||
assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-R", "1", "-t", "150", "custom_target"]);
|
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,
|
no_randomize: false,
|
||||||
basename: "my_profile".to_string(),
|
basename: "my_profile".to_string(),
|
||||||
cwd: "/tmp".to_string(),
|
cwd: "/tmp".to_string(),
|
||||||
|
calibration_file: None,
|
||||||
|
calibration_embed_only: false,
|
||||||
};
|
};
|
||||||
let args = build_printtarg_args(&config);
|
let args = build_printtarg_args(&config);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1706,6 +1732,8 @@ mod tests {
|
|||||||
no_randomize: false,
|
no_randomize: false,
|
||||||
basename: "my_profile".to_string(),
|
basename: "my_profile".to_string(),
|
||||||
cwd: "/tmp".to_string(),
|
cwd: "/tmp".to_string(),
|
||||||
|
calibration_file: None,
|
||||||
|
calibration_embed_only: false,
|
||||||
};
|
};
|
||||||
let args = build_printtarg_args(&config);
|
let args = build_printtarg_args(&config);
|
||||||
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "42", "-t", "300", "my_profile"]);
|
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,
|
no_randomize: true,
|
||||||
basename: "my_profile".to_string(),
|
basename: "my_profile".to_string(),
|
||||||
cwd: "/tmp".to_string(),
|
cwd: "/tmp".to_string(),
|
||||||
|
calibration_file: None,
|
||||||
|
calibration_embed_only: false,
|
||||||
};
|
};
|
||||||
let args = build_printtarg_args(&config);
|
let args = build_printtarg_args(&config);
|
||||||
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-r", "-t", "300", "my_profile"]);
|
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]
|
#[test]
|
||||||
fn test_build_chartread_args_auto() {
|
fn test_build_chartread_args_auto() {
|
||||||
let config = ChartreadConfig {
|
let config = ChartreadConfig {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use tauri::Manager;
|
|||||||
|
|
||||||
mod cgats;
|
mod cgats;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
mod calibration;
|
||||||
mod events;
|
mod events;
|
||||||
mod macos_webview;
|
mod macos_webview;
|
||||||
mod print;
|
mod print;
|
||||||
@@ -92,6 +93,15 @@ pub fn run() {
|
|||||||
commands::show_printer_properties,
|
commands::show_printer_properties,
|
||||||
commands::print_target_native,
|
commands::print_target_native,
|
||||||
commands::select_csv_save_path,
|
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::save_verification_record,
|
||||||
quality_store::get_verification_history,
|
quality_store::get_verification_history,
|
||||||
quality_store::clear_verification_history,
|
quality_store::clear_verification_history,
|
||||||
|
|||||||
@@ -58,10 +58,15 @@ pub struct ProfilingPreset {
|
|||||||
pub random_seed: Option<u32>,
|
pub random_seed: Option<u32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub no_randomize: Option<bool>,
|
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_good_max() -> f64 { 2.0 }
|
||||||
fn default_delta_e_warning_max() -> f64 { 5.0 }
|
fn default_delta_e_warning_max() -> f64 { 5.0 }
|
||||||
|
fn default_cal_stale_days() -> u32 { 30 }
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
@@ -76,6 +81,8 @@ pub struct AppSettings {
|
|||||||
pub custom_presets: Vec<ProfilingPreset>,
|
pub custom_presets: Vec<ProfilingPreset>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enable_i1pro2_leds: bool,
|
pub enable_i1pro2_leds: bool,
|
||||||
|
#[serde(default = "default_cal_stale_days")]
|
||||||
|
pub calibration_stale_days: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
@@ -88,6 +95,7 @@ impl Default for AppSettings {
|
|||||||
delta_e_warning_max: default_delta_e_warning_max(),
|
delta_e_warning_max: default_delta_e_warning_max(),
|
||||||
custom_presets: Vec::new(),
|
custom_presets: Vec::new(),
|
||||||
enable_i1pro2_leds: false,
|
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,
|
device_power: None,
|
||||||
random_seed: Some(1),
|
random_seed: Some(1),
|
||||||
no_randomize: Some(false),
|
no_randomize: Some(false),
|
||||||
|
calibration_file: None,
|
||||||
|
apply_calibration: None,
|
||||||
colprof_fwa: Some("D50".to_string()),
|
colprof_fwa: Some("D50".to_string()),
|
||||||
colprof_illuminant: None,
|
colprof_illuminant: None,
|
||||||
colprof_observer: None,
|
colprof_observer: None,
|
||||||
@@ -169,6 +179,8 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
|||||||
device_power: None,
|
device_power: None,
|
||||||
random_seed: Some(1),
|
random_seed: Some(1),
|
||||||
no_randomize: Some(false),
|
no_randomize: Some(false),
|
||||||
|
calibration_file: None,
|
||||||
|
apply_calibration: None,
|
||||||
colprof_fwa: Some("D50".to_string()),
|
colprof_fwa: Some("D50".to_string()),
|
||||||
colprof_illuminant: None,
|
colprof_illuminant: None,
|
||||||
colprof_observer: None,
|
colprof_observer: None,
|
||||||
@@ -203,6 +215,8 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
|||||||
device_power: None,
|
device_power: None,
|
||||||
random_seed: Some(1),
|
random_seed: Some(1),
|
||||||
no_randomize: Some(false),
|
no_randomize: Some(false),
|
||||||
|
calibration_file: None,
|
||||||
|
apply_calibration: None,
|
||||||
colprof_fwa: Some("D50".to_string()),
|
colprof_fwa: Some("D50".to_string()),
|
||||||
colprof_illuminant: None,
|
colprof_illuminant: None,
|
||||||
colprof_observer: None,
|
colprof_observer: None,
|
||||||
@@ -237,6 +251,8 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
|||||||
device_power: None,
|
device_power: None,
|
||||||
random_seed: Some(1),
|
random_seed: Some(1),
|
||||||
no_randomize: Some(false),
|
no_randomize: Some(false),
|
||||||
|
calibration_file: None,
|
||||||
|
apply_calibration: None,
|
||||||
colprof_fwa: Some("D50".to_string()),
|
colprof_fwa: Some("D50".to_string()),
|
||||||
colprof_illuminant: None,
|
colprof_illuminant: None,
|
||||||
colprof_observer: None,
|
colprof_observer: None,
|
||||||
@@ -405,6 +421,8 @@ mod tests {
|
|||||||
device_power: Some(1.2),
|
device_power: Some(1.2),
|
||||||
random_seed: Some(42),
|
random_seed: Some(42),
|
||||||
no_randomize: Some(false),
|
no_randomize: Some(false),
|
||||||
|
calibration_file: None,
|
||||||
|
apply_calibration: None,
|
||||||
colprof_fwa: Some("D50".to_string()),
|
colprof_fwa: Some("D50".to_string()),
|
||||||
colprof_illuminant: None,
|
colprof_illuminant: None,
|
||||||
colprof_observer: None,
|
colprof_observer: None,
|
||||||
@@ -474,6 +492,7 @@ mod tests {
|
|||||||
fn test_default_enable_i1pro2_leds() {
|
fn test_default_enable_i1pro2_leds() {
|
||||||
let settings = AppSettings::default();
|
let settings = AppSettings::default();
|
||||||
assert_eq!(settings.enable_i1pro2_leds, false);
|
assert_eq!(settings.enable_i1pro2_leds, false);
|
||||||
|
assert_eq!(settings.calibration_stale_days, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ICCery",
|
"productName": "ICCery",
|
||||||
"version": "0.8.4",
|
"version": "0.8.5",
|
||||||
"identifier": "com.gronod.iccery",
|
"identifier": "com.gronod.iccery",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../src"
|
"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;">
|
<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>
|
<option value="" disabled selected>Loading presets...</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
<nav class="stepper">
|
<nav class="stepper">
|
||||||
@@ -68,12 +70,112 @@
|
|||||||
<button type="button" id="wizardNotificationClose" class="notification-close" title="Dismiss notice">✕</button>
|
<button type="button" id="wizardNotificationClose" class="notification-close" title="Dismiss notice">✕</button>
|
||||||
</div>
|
</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 -->
|
<!-- Stage 1: targen -->
|
||||||
<section id="stage-1" class="stage active">
|
<section id="stage-1" class="stage active">
|
||||||
<h2 style="display:flex; justify-content:space-between; align-items:center;">
|
<h2 style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
Define Target
|
Define Target
|
||||||
<button type="button" id="btnToggleAllHelp" class="secondary btn-sm">Toggle Help Mode</button>
|
<button type="button" id="btnToggleAllHelp" class="secondary btn-sm">Toggle Help Mode</button>
|
||||||
</h2>
|
</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">
|
<div class="form-container" id="stage1FormContainer">
|
||||||
<!-- Basic Settings -->
|
<!-- Basic Settings -->
|
||||||
@@ -319,6 +421,10 @@
|
|||||||
<!-- Stage 2: printtarg -->
|
<!-- Stage 2: printtarg -->
|
||||||
<section id="stage-2" class="stage hidden">
|
<section id="stage-2" class="stage hidden">
|
||||||
<h2>Print Layout</h2>
|
<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>
|
<p>Configure the target layout for your instrument and paper size, then generate printable TIFF images.</p>
|
||||||
|
|
||||||
<!-- Colour management warning banner -->
|
<!-- Colour management warning banner -->
|
||||||
@@ -634,6 +740,10 @@
|
|||||||
<!-- Stage 4: colprof -->
|
<!-- Stage 4: colprof -->
|
||||||
<section id="stage-4" class="stage hidden">
|
<section id="stage-4" class="stage hidden">
|
||||||
<h2>Create Profile</h2>
|
<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>
|
<p>Calculate the ICC profile from your target measurement data.</p>
|
||||||
|
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
@@ -790,6 +900,10 @@
|
|||||||
<!-- Stage 5: profcheck -->
|
<!-- Stage 5: profcheck -->
|
||||||
<section id="stage-5" class="stage hidden">
|
<section id="stage-5" class="stage hidden">
|
||||||
<h2>Verify Profile</h2>
|
<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>
|
<p>Check the numerical accuracy of your profile against the original measurement data.</p>
|
||||||
|
|
||||||
<div class="stage-actions">
|
<div class="stage-actions">
|
||||||
@@ -828,6 +942,7 @@
|
|||||||
<summary>Verification History & Drift Tracking</summary>
|
<summary>Verification History & Drift Tracking</summary>
|
||||||
<div class="notification-banner warning hidden" id="driftAlertCard">
|
<div class="notification-banner warning hidden" id="driftAlertCard">
|
||||||
<span id="driftAlertIcon">⚠️</span><span id="driftAlertText"></span>
|
<span id="driftAlertIcon">⚠️</span><span id="driftAlertText"></span>
|
||||||
|
<button type="button" class="secondary btn-sm" id="btnDriftRecalibrate">Re-calibrate</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="drift-filter-row hidden" id="driftFilterRow">
|
<div class="drift-filter-row hidden" id="driftFilterRow">
|
||||||
<label for="driftPrinterFilter">Filter by Printer:</label>
|
<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>
|
<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>
|
||||||
|
|
||||||
|
<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);">
|
<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>
|
<label style="font-weight: 600;">Diagnostics & Logging</label>
|
||||||
<div class="input-row" style="margin-top: 8px;">
|
<div class="input-row" style="margin-top: 8px;">
|
||||||
@@ -1000,6 +1124,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</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 -->
|
<!-- About Dialog -->
|
||||||
<dialog id="aboutDialog" class="settings-modal about-modal">
|
<dialog id="aboutDialog" class="settings-modal about-modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -1007,7 +1144,7 @@
|
|||||||
<img src="./assets/ICCery-logo.svg" alt="ICCery Logo" class="about-logo" />
|
<img src="./assets/ICCery-logo.svg" alt="ICCery Logo" class="about-logo" />
|
||||||
</div>
|
</div>
|
||||||
<h2>About ICCery</h2>
|
<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>
|
<p><strong>Copyright © 2026 Gordon Bolton. All rights reserved.</strong></p>
|
||||||
<h3>Licences & EULA</h3>
|
<h3>Licences & EULA</h3>
|
||||||
<div class="license-text-container">
|
<div class="license-text-container">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { initPresets } from './presets.js';
|
|||||||
import { wizardState } from './state.js';
|
import { wizardState } from './state.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { CgatsInterop } from './cgats_interop.js';
|
import { CgatsInterop } from './cgats_interop.js';
|
||||||
|
import { initCalibration } from './calibration.js';
|
||||||
|
|
||||||
const { invoke } = window.__TAURI__.core;
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
|
||||||
@@ -135,6 +136,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
safeInit('Stage 5 (Profcheck)', initProfcheck);
|
safeInit('Stage 5 (Profcheck)', initProfcheck);
|
||||||
safeInit('Settings', initSettings);
|
safeInit('Settings', initSettings);
|
||||||
safeInit('Presets', initPresets);
|
safeInit('Presets', initPresets);
|
||||||
|
safeInit('Calibration', initCalibration);
|
||||||
|
|
||||||
// Double-rAF waits for layout + first paint of the dark CSS.
|
// Double-rAF waits for layout + first paint of the dark CSS.
|
||||||
requestAnimationFrame(() => {
|
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 { loadGamutMesh } from './gamut_viewer.js';
|
||||||
import { wizardState } from './state.js';
|
import { wizardState } from './state.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { applyCalibrationToProfile, getActiveCalibration } from './calibration.js';
|
||||||
|
|
||||||
let chartreadBasename = "";
|
let chartreadBasename = "";
|
||||||
let chartreadCwd = "";
|
let chartreadCwd = "";
|
||||||
@@ -159,6 +160,16 @@ export function initColprof() {
|
|||||||
wizardState.setTarget(basename, cwd);
|
wizardState.setTarget(basename, cwd);
|
||||||
setStage4Result(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
|
// Automatically extract gamut mesh for 3D visualization
|
||||||
triggerGamutExtraction(basename, cwd, profilePath);
|
triggerGamutExtraction(basename, cwd, profilePath);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const { invoke } = window.__TAURI__.core;
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
|
||||||
|
import { getActiveCalibration } from './calibration.js';
|
||||||
|
|
||||||
let currentPresets = [];
|
let currentPresets = [];
|
||||||
let activePresetId = "preset-std-rgb";
|
let activePresetId = "preset-std-rgb";
|
||||||
|
|
||||||
@@ -417,6 +419,8 @@ export async function initPresets() {
|
|||||||
colprof_observer,
|
colprof_observer,
|
||||||
colprof_input_viewing_cond,
|
colprof_input_viewing_cond,
|
||||||
colprof_output_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 { setStage2Result } from './chartread.js';
|
||||||
import { wizardState } from './state.js';
|
import { wizardState } from './state.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { getPrinttargCalibrationFields } from './calibration.js';
|
||||||
|
|
||||||
// Module-level state: set by Stage 1 when it completes
|
// Module-level state: set by Stage 1 when it completes
|
||||||
let stage1Basename = "";
|
let stage1Basename = "";
|
||||||
@@ -505,6 +506,7 @@ export function initPrinttarg() {
|
|||||||
no_randomize: noRandomize,
|
no_randomize: noRandomize,
|
||||||
basename: stage1Basename,
|
basename: stage1Basename,
|
||||||
cwd: stage1Cwd,
|
cwd: stage1Cwd,
|
||||||
|
...getPrinttargCalibrationFields(stage1Basename),
|
||||||
};
|
};
|
||||||
|
|
||||||
const processId = `printtarg_${stage1Basename}`;
|
const processId = `printtarg_${stage1Basename}`;
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ export async function initSettings() {
|
|||||||
if (deltaEWarningMax) {
|
if (deltaEWarningMax) {
|
||||||
deltaEWarningMax.value = Number(settings.delta_e_warning_max ?? 5.0).toFixed(1);
|
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();
|
validateDeltaEThresholds();
|
||||||
await refreshLogPath();
|
await refreshLogPath();
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
@@ -150,6 +154,7 @@ export async function initSettings() {
|
|||||||
delta_e_good_max: getInputValueAsFloat('deltaEGoodMax', 2.0),
|
delta_e_good_max: getInputValueAsFloat('deltaEGoodMax', 2.0),
|
||||||
delta_e_warning_max: getInputValueAsFloat('deltaEWarningMax', 5.0),
|
delta_e_warning_max: getInputValueAsFloat('deltaEWarningMax', 5.0),
|
||||||
enable_i1pro2_leds: enableI1Pro2Leds ? enableI1Pro2Leds.checked : false,
|
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 });
|
await invoke('save_settings', { settings });
|
||||||
logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings');
|
logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings');
|
||||||
|
|||||||
+14
-2
@@ -8,6 +8,8 @@ export const wizardState = {
|
|||||||
cwd: "",
|
cwd: "",
|
||||||
printerName: "",
|
printerName: "",
|
||||||
noticeTimer: null,
|
noticeTimer: null,
|
||||||
|
sessionMode: "profile",
|
||||||
|
profileBasename: "",
|
||||||
|
|
||||||
setTarget(basename, cwd) {
|
setTarget(basename, cwd) {
|
||||||
if (basename) this.basename = basename;
|
if (basename) this.basename = basename;
|
||||||
@@ -61,6 +63,10 @@ export const wizardState = {
|
|||||||
const stages = document.querySelectorAll('.stage');
|
const stages = document.querySelectorAll('.stage');
|
||||||
|
|
||||||
steps.forEach(s => {
|
steps.forEach(s => {
|
||||||
|
if (stageNumber === 0) {
|
||||||
|
s.classList.remove('active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (s.getAttribute('data-step') === String(stageNumber)) {
|
if (s.getAttribute('data-step') === String(stageNumber)) {
|
||||||
s.classList.remove('disabled');
|
s.classList.remove('disabled');
|
||||||
s.classList.add('active');
|
s.classList.add('active');
|
||||||
@@ -70,7 +76,7 @@ export const wizardState = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stages.forEach(s => {
|
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.remove('hidden');
|
||||||
s.classList.add('active');
|
s.classList.add('active');
|
||||||
} else {
|
} else {
|
||||||
@@ -90,7 +96,13 @@ export const wizardState = {
|
|||||||
|
|
||||||
async navigateToStage(stageNumber) {
|
async navigateToStage(stageNumber) {
|
||||||
const targetNum = parseInt(stageNumber, 10);
|
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) {
|
if (targetNum === 1) {
|
||||||
this.currentStage = 1;
|
this.currentStage = 1;
|
||||||
|
|||||||
@@ -1923,3 +1923,120 @@ button.danger:hover {
|
|||||||
background: rgba(34, 197, 94, 0.2);
|
background: rgba(34, 197, 94, 0.2);
|
||||||
color: #86efac;
|
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