25 Commits
Author SHA1 Message Date
gronod d6373c243b Release v0.8.2 (#207)
Build Windows Packages / Build Windows (push) Successful in 7m40s
Build Linux Packages / Build Linux (push) Successful in 7m40s
Build macOS Packages / Build macOS (Apple Silicon) (push) Successful in 15m28s
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Successful in 21m59s
Build macOS Packages / Build macOS (Intel) (push) Successful in 12m4s
Release v0.8.2
2026-09-05 23:37:02 +01:00
gronod dc7c8ada96 chore: bump version to v0.8.2 2026-09-05 23:36:41 +01:00
gronod 0741c961e6 feat(chartread): support XY automated scanning tables in Stage 3 (#93)
- Extract pure classifyChartreadLine parser with tests for two-line prompts, fiducials, and sheet placement
- Add TABLE_PLACE_SHEET and TABLE_ALIGN states with sticky continuation lines
- Add 4-step XY sequence panel and hint in Stage 3
- Handle graceful head parking with 'q\n' on cancel
- Support --xy mock branch with blocking stdin reads
- Align ROADMAP.md and AGENTS.md for Milestone 12
2026-09-05 23:30:02 +01:00
gronod 9769d397b9 feat(verification): implement printer drift tracking & verification analytics (#95) 2026-09-05 23:23:01 +01:00
gronod 18a6d9394c ci(macos): compile tests with --no-run on cross-target Apple Silicon
Build Linux Packages / Build Linux (push) Successful in 7m9s
Build Windows Packages / Build Windows (push) Successful in 6m48s
Build macOS Packages / Build macOS (Apple Silicon) (push) Successful in 20m33s
Build macOS Packages / Build macOS (Intel) (push) Successful in 20m38s
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Successful in 26m25s
2026-09-05 21:25:13 +01:00
gronod 9016932547 ci(macos): remove hanging rust-cache and run native cargo test for all platforms
Build Windows Packages / Build Windows (push) Successful in 7m13s
Build macOS Packages / Build macOS (Apple Silicon) (push) Failing after 7m15s
Build Linux Packages / Build Linux (push) Successful in 8m0s
Build macOS Packages / Build macOS (Intel) (push) Canceled after 10m57s
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Canceled after 12m4s
2026-09-05 21:14:14 +01:00
gronod 2f7d8f3ca7 ci(macos): run cargo test without target for universal-apple-darwin
Build Windows Packages / Build Windows (push) Successful in 7m6s
Build Linux Packages / Build Linux (push) Successful in 7m43s
Build macOS Packages / Build macOS (Apple Silicon) (push) Failing after 9m16s
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Canceled after 43m44s
Build macOS Packages / Build macOS (Intel) (push) Canceled after 43m44s
2026-09-05 20:34:17 +01:00
gronod 41efac68ed Merge pull request 'Release v0.8.1' (#206) from development into main
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Failing after 1m27s
Build macOS Packages / Build macOS (Apple Silicon) (push) Canceled after 6m4s
Build macOS Packages / Build macOS (Intel) (push) Canceled after 6m4s
Build Windows Packages / Build Windows (push) Canceled after 6m1s
Build Linux Packages / Build Linux (push) Canceled after 6m12s
Merge pull request 'Release v0.8.1' (#206) from development into main
2026-09-05 20:27:52 +01:00
gronod 9aad7b083a Merge pull request 'feat(chartread): support -Y l switch for i1Pro 2 LED feedback (closes #204)' (#205) from feat/204-i1pro2-led-feedback into development
Merge pull request 'feat(chartread): support -Y l switch for i1Pro 2 LED feedback (closes #204)' (#205) from feat/204-i1pro2-led-feedback into development
2026-09-05 20:27:41 +01:00
gronod 5ee1b07263 feat(chartread): support -Y l switch for i1Pro 2 LED feedback (#204) 2026-09-05 20:26:44 +01:00
gronod 53ce298bc1 chore(release): bump version to 0.8.1 2026-09-05 20:24:46 +01:00
gronod 45ed7dbfe5 ci: uncomment Intel and Apple Silicon build targets in macos workflow matrix 2026-09-05 17:49:41 +01:00
gronod f553f10399 Update .gitea/workflows/build-macos.yml
made macos build on gitea universal only
2026-09-05 17:36:37 +01:00
gronod a9112445bb Update .gitea/workflows/build-macos.yml
Build Windows Packages / Build Windows (push) Canceled after 14s
Build Linux Packages / Build Linux (push) Canceled after 32s
Build macOS Packages / Build macOS (Intel) (push) Canceled after 32m48s
commennted arm64 and universal builds from macos gitea workflow and updated node version to 24
2026-09-05 17:24:16 +01:00
gronod a584b9746f Update .gitea/workflows/build-macos.yml
Build Linux Packages / Build Linux (push) Canceled after 14s
Build Windows Packages / Build Windows (push) Canceled after 15s
Build macOS Packages / Build macOS (Intel) (push) Canceled after 0s
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Canceled after 0s
Build macOS Packages / Build macOS (Apple Silicon) (push) Canceled after 2m2s
2026-09-05 17:18:31 +01:00
Gordon Bolton c2fdfafaa7 Merge development for v0.8.0 release
Build macOS Packages / Build macOS (Apple Silicon) (push) Canceled after 0s
Build macOS Packages / Build macOS (Intel) (push) Canceled after 0s
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Canceled after 0s
Build Linux Packages / Build Linux (push) Canceled after 22s
Build Windows Packages / Build Windows (push) Canceled after 24s
Integrates Milestone 13 polish and closes #177, #178, #184, #176, #185, #179.

Generated with Devin
2026-09-04 01:31:08 +01:00
Gordon Bolton b60d0c3bce chore(release): bump version to 0.8.0
Generated with Devin
2026-09-04 01:28:00 +01:00
Gordon Bolton 8a0065274b Merge milestone 13 feature stack into development (#177-#185, #179)
Integrates the stacked enhancements and fixes from:
- enh/177-button-standardization
- enh/178-swatch-grid
- feat/184-configurable-delta-e
- enh/176-colprof-fwa
- enh/185-gamut-viewer
- fix/179-gamut-profcheck

Generated with Devin
2026-09-04 01:05:26 +01:00
Gordon Bolton ab2f6e562e fix(stage5): harden gamut .gam parsing and profcheck output parsing (#179)
- Make parseGamutFile support multiple BEGIN_DATA blocks, inline #
  comments, out-of-bounds vertex warnings, and user-visible parser
  warnings (returned in { vertices, faces, warnings }).
- Harden profcheck report parsing: prefer the last JSON object with
  avg_de/max_de/rms_de keys, then fall back to a wider set of regex
  patterns for legacy plain-text output.
- Add parser warnings to the process log when metrics cannot be found.
- Fix loadGamutMesh call signature and add try/catch around gamut load.
- Add manual gamut parser tests in src/js/gamut_viewer.test.js.
- Update AGENTS.md, README.md, and ROADMAP.md.

Generated with Devin
2026-09-04 01:03:07 +01:00
Gordon Bolton 3026e7081c enh(stage5): gamut viewer controls and JSDoc (#185)
- Add resetCamera(), setProfileOpacity(), setSrgbReferenceOpacity(), and
  setAxisOpacity() public API functions with JSDoc.
- Add Reset View button and per-layer opacity sliders to the gamut viewer
  control panel.
- Add R keyboard shortcut to reset camera when Stage 5 is active.
- Add CSS for new gamut controls (sliders and reset button).
- Update AGENTS.md, README.md, and ROADMAP.md.

Generated with Devin
2026-09-04 00:57:40 +01:00
Gordon Bolton 84df0b7a7d enh(stage4): add contextual tooltips and tests for colprof options (#176)
- Add has-tooltip / tooltip-text to all Stage 4 colprof controls:
  OBA/FWA compensation, custom spectrum, illuminant, observer,
  input/output viewing conditions.
- Add combined  test covering FWA, illuminant,
  observer, and viewing conditions simultaneously.
- Update AGENTS.md, README.md, and ROADMAP.md.

Generated with Devin
2026-09-04 00:52:02 +01:00
Gordon Bolton 9f8b2c8567 feat(settings): configurable ΔE₀₀ traffic-light thresholds (#184)
- Add AppSettings fields delta_e_good_max / delta_e_warning_max with
  serde defaults (2.0 / 5.0) and validation on save.
- Add Settings dialog UI for the two threshold inputs with live frontend
  validation and a backend validate() method / unit tests.
- Dispatch a settings-saved custom event so the swatch grid can re-classify
  existing patches on the fly.
- Refactor swatch_grid.js to load thresholds from settings, classify using
  current bounds, store raw ΔE on each patch element, and re-classify on
  settings change.
- Update AGENTS.md, README.md, and ROADMAP.md.

Generated with Devin
2026-09-04 00:43:04 +01:00
Gordon Bolton 2c97e98dc3 enh(stage3): polish swatch grid orientation, white patches, and tooltips (#178)
- Document the is_pad guard so legitimate white reference patches are
  preserved while true spacer pads are skipped.
- Document row/patch ordering assumption (matches printtarg layout).
- Restructure patch tooltip to include intended Lab/device, measured Lab,
  and a Good/Warning/Bad ΔE₀₀ classification.
- Add classifyDeltaE() helper with default thresholds (reused later by
  configurable thresholds).
- Add a manual smoke-test stub in swatch_grid.test.js.
- Update AGENTS.md, README.md, and ROADMAP.md.

Generated with Devin
2026-09-04 00:25:49 +01:00
Gordon Bolton 0603faca59 enh(ui): standardize button sizing across all views (#177)
- Add CSS variables for button tier heights/radii and refactor .btn-sm,
  .btn-md, .btn-lg, .btn-icon-sq to use them.
- Normalize button.primary, button.secondary, button.danger, .icon-btn,
  .btn-properties, and .btn-icon to 36px (or their tier) with
  box-sizing: border-box.
- Remove inline style attributes from buttons and their immediate row
  containers; replace with .btn-row, .btn-row-sm, .btn-row-end,
  .input-row-sm, and .form-group-header utility classes.
- Apply .btn-lg to the major Stage 4/5 primary actions.
- Add UI Button Conventions to AGENTS.md, README, and Milestone 13 to
  ROADMAP.md.

Generated with Devin
2026-09-04 00:20:31 +01:00
Gordon Bolton 956b5ff499 fix(print): robust Printer tests and driver color bypass detection
- Update test_merge_printer_info to assert individual Printer fields,
  decoupling the test from the optional display_name field populated
  by lpoptions in different environments.
- Add EPIJ_CCor (Epson Color Correction) to detect_driver_color_bypass
  and reorder StpColorCorrection/ColorCorrection checks so the more
  specific StpColorCorrection key is preferred.

Generated with Devin
2026-09-04 00:18:51 +01:00
29 changed files with 3027 additions and 263 deletions
+10 -7
View File
@@ -13,17 +13,17 @@ jobs:
matrix: matrix:
platform: platform:
- name: Intel - name: Intel
os: macos-15-intel os: macos
target: x86_64-apple-darwin target: x86_64-apple-darwin
binary_dir: macos-x86_64 binary_dir: macos-x86_64
arch: x86_64 arch: x86_64
- name: Apple Silicon - name: Apple Silicon
os: macos-15 os: macos
target: aarch64-apple-darwin target: aarch64-apple-darwin
binary_dir: macos-aarch64 binary_dir: macos-aarch64
arch: arm64 arch: arm64
- name: Universal (Intel + Apple Silicon) - name: Universal (Intel + Apple Silicon)
os: macos-15 os: macos
target: universal-apple-darwin target: universal-apple-darwin
binary_dir: macos-universal binary_dir: macos-universal
arch: universal arch: universal
@@ -35,7 +35,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 24
cache: 'npm' cache: 'npm'
- name: Install Rust stable - name: Install Rust stable
@@ -44,9 +44,6 @@ jobs:
toolchain: stable toolchain: stable
targets: ${{ matrix.platform.target == 'universal-apple-darwin' && 'x86_64-apple-darwin, aarch64-apple-darwin' || matrix.platform.target }} targets: ${{ matrix.platform.target == 'universal-apple-darwin' && 'x86_64-apple-darwin, aarch64-apple-darwin' || matrix.platform.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Install Node dependencies - name: Install Node dependencies
run: npm ci run: npm ci
@@ -69,7 +66,13 @@ jobs:
CARGO_TARGET_DIR: "${{ runner.temp }}/cargo-target" CARGO_TARGET_DIR: "${{ runner.temp }}/cargo-target"
run: | run: |
cd src-tauri cd src-tauri
if [ "${{ matrix.platform.target }}" = "universal-apple-darwin" ]; then
cargo test
elif [ "${{ matrix.platform.target }}" = "aarch64-apple-darwin" ] && [ "$(uname -m)" != "arm64" ]; then
cargo test --no-run --target ${{ matrix.platform.target }}
else
cargo test --target ${{ matrix.platform.target }} cargo test --target ${{ matrix.platform.target }}
fi
- name: Set Release Environment - name: Set Release Environment
id: set_env id: set_env
+96
View File
@@ -1,5 +1,58 @@
# ICCery Agent Notes # ICCery Agent Notes
## Stage 5 Verification / Profcheck
- `profcheck` output is parsed from both JSON summaries (preferred) and legacy plain-text report formats.
- If no delta-E values can be detected, the report cards show 0.00 and a warning is appended to the process log.
- The `.gam` file for the 3D viewer is parsed using `parseGamutFile`, which supports multiple `BEGIN_DATA` blocks (some Argyll files use a separate block per surface section), inline `#` comments, and out-of-bounds vertex warnings.
- Manual parser tests live in `src/js/gamut_viewer.test.js`.
## 3D Gamut Viewer
- The viewer renders the measured/derived `.gam` volume and an optional sRGB reference wireframe in CIELAB.
- Layer controls (profile, sRGB, axes) each have visibility toggles and opacity sliders.
- Click **Reset View** or press **R** to return the camera to its default position.
- Full JSDoc is provided on the public API in `src/js/gamut_viewer.js`.
## Stage 4 colprof Options
- `colprof` options are exposed in the Stage 4 UI with contextual tooltips:
- **FWA / OBA Compensation** (`-f`): choose D50, None, D65, or a custom `.sp` spectrum file.
- **Standard Illuminant** (`-i`) and **Observer** (`-o`): override default D50 / 1931 2° for CIE colourimetric calculations.
- **Viewing Conditions** (`-c` input, `-d` output): set CIE viewing-condition transforms for the profile table and output intent.
- The backend `build_colprof_args` in `commands.rs` maps these UI values to `colprof` CLI flags. Unit tests cover all combinations.
## Stage 3 Swatch Grid
- Swatch patches render a 135° diagonal split: top-left is the intended/expected colour, bottom-right is the measured colour.
- Argyll `chartread` emits `is_pad` for boundary/spacer patches. White reference patches (e.g. `-e` white steps) may also carry `is_pad`, but they have valid `expected.Lab` or non-zero `device` data. The guard skips only pads with no measurement *and* all-zero device values.
- Row/patch order from `chartread` (rows A→Z, patches 1→N within each row) is rendered left-to-right / top-to-bottom to match the `printtarg` output.
- Tooltip shows intended Lab (or device %), measured Lab, and ΔE₀₀ with a Good/Warning/Bad classification.
## UI Button Conventions
The frontend uses a tiered button sizing system defined in `src/styles/main.css`. Prefer these utility classes over inline `style` attributes.
| Class | Size | Use for |
|-------|------|---------|
| `.btn-sm` | 28px | Toolbar actions, header icons, minor toggles |
| `.btn-md` (default for `button.secondary`/`button.danger`) | 36px | Standard dialog/form actions and browse buttons |
| `.btn-lg` | 40px | Primary stage actions (Generate, Create Layout, Create Profile, Verify, etc.) |
| `.btn-icon-sq` | 36×36px | Square icon-only buttons (refresh, settings, etc.) |
| `.icon-btn` | 28×28px | Small header icon-only buttons (settings, about, save preset, manage presets) |
| `.btn-properties` | 36px | Printer driver preferences button |
- Primary action buttons use `button.primary` plus `.btn-lg` for major stage actions.
- Danger actions use `button.danger` (36px).
- All action rows use one of: `.stage-actions`, `.modal-actions`, `.chartread-actions`, `.print-actions-row`, `.btn-row`, `.btn-row-sm`, `.btn-row-end`, or `.input-row-sm`.
- Avoid inline `style` on `<button>` elements or their immediate parent rows.
## Settings & Preferences
- Settings are persisted to `settings.json` in the app data directory and include the Stage 3 ΔE₀₀ traffic-light thresholds.
- Valid threshold values must be non-negative and `delta_e_good_max < delta_e_warning_max`; both the frontend and backend enforce this.
- Saving settings dispatches a `settings-saved` custom event so live components (e.g. the swatch grid) can re-classify on the fly.
## Build Commands ## Build Commands
- **Rust backend**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo check` (the project lives on a network filesystem that doesn't support file locking, so `CARGO_INCREMENTAL=0` is required) - **Rust backend**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo check` (the project lives on a network filesystem that doesn't support file locking, so `CARGO_INCREMENTAL=0` is required)
@@ -49,3 +102,46 @@ The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI
- Epson color bypass: `EPIJ_CMat=3` (Off / No Color Adjustment) - Epson color bypass: `EPIJ_CMat=3` (Off / No Color Adjustment)
- Canon color bypass: `CNIJIntent2=4` or `CNIJIntent=4` - Canon color bypass: `CNIJIntent2=4` or `CNIJIntent=4`
- Gutenprint: `StpColorCorrection=Uncorrected` - Gutenprint: `StpColorCorrection=Uncorrected`
## Verification History & Printer Drift Tracking (#95)
- Historical verification runs are stored in `verification_history.json` in the app data directory.
- Record schema (`VerificationRecord` in `src-tauri/src/quality_store.rs`):
- `id`: unique record identifier in the format `vr-<epoch_millis>-<seq>`.
- `profile_name`: target profile filename.
- `printer`: device name captured at print spooling (`wizardState.printerName`), or "Unknown".
- `avg_de`, `max_de`, `rms_de`: CIEDE2000 metrics from `profcheck` (using `-u` JSON summary).
- `patch_count`: number of test patches evaluated.
- `status`: classified status using **ICCery verification bands (issue #95)**:
- `< 1.0`: "Excellent" (`badge-excellent`)
- `< 2.0`: "Good" (`badge-good`)
- `< 3.5`: "Acceptable" (`badge-acceptable`)
- `>= 3.5`: "Warning" (`badge-poor`)
- `timestamp`: ISO-8601 UTC string.
- Max capacity is 500 records; oldest records evicted on overflow.
- Atomic file writes (`.tmp` write followed by `rename`) prevent data corruption.
- Tauri IPC command casing:
- Nested struct fields (`VerificationRecord`) serialize with `snake_case`.
- Top-level Tauri command arguments use `camelCase` (e.g. `savePath`, `record`, `profileName`).
- Drift history UI in Stage 5 features an interactive SVG trend chart with ICCery verification reference bands, consecutive-breach alert card (requires $\ge 2$ consecutive runs $\ge 3.5$ on distinct calendar days or $\ge 1$ hour apart), and RFC-4180 compliant CSV export.
## Stage 3 XY Automated Scanning Tables (#93)
- Supports automated XY scanning tables (GretagMacbeth SpectroScan, X-Rite i1iO) in Stage 3 `chartread`.
- Hardware detection in `instlist` flags devices matching `/spectro\s?scan|i1io/i` with `data-xy="1"` and `· XY Table` label suffix.
- Runtime auto-detection activates when any XY-specific prompt is classified from `chartread` stdout (supporting i1iO units reporting as i1Pro).
- XY State Machine additions:
- `STATE.TABLE_PLACE_SHEET`: Prompts user to place sheet on table; button displays "✓ Sheet Placed — Continue".
- `STATE.TABLE_ALIGN`: Prompts user to align measurement head with target fiducial patches (`locate patch <ID> with sight`); button displays "✓ Aligned — Continue".
- Two-line prompt handling & sticky state:
- Argyll `chartread.c` splits XY prompts across two lines (prompt line followed by `hit return to continue...`).
- While in `TABLE_PLACE_SHEET` or `TABLE_ALIGN`, subsequent continuation lines remain sticky in that table state, preserving the custom button label and preventing regression to generic `PROMPT_CONTINUE`.
- Button behaviors:
- `btnAccept`: in `TABLE_*` states, sends `\n` without forcing `STATE.READING`; the state machine advances naturally when Argyll emits the next prompt.
- `btnCancel`: in `TABLE_*` states or when an XY table is active, sends `q\n` first to allow the hardware to park its measurement head gracefully before terminating the process.
- Multi-sheet and final sheet notice:
- Multi-sheet targets are measured within a single `chartread` process lifecycle; sheet changes transition through `TABLE_PLACE_SHEET` without opening the Stage 3 multi-pass averaging panel.
- `Please remove last sheet from table` is emitted by Argyll right before writing `.ti3` and exiting; it is classified as an info-only notice (`isRemoveSheetNotice: true`) and does not prompt for user input.
- Testing:
- Pure line classification unit tests live in `src/js/chartread.test.js` (executable directly in Node or browser console).
- Unix/macOS mock script `src-tauri/argyll/mocks/chartread.mock` supports `--xy` flag (or `MOCK_XY_TABLE=1`) with blocking `read` calls simulating calibration, sheet placement, fiducial alignment, and scanning.
+4 -3
View File
@@ -16,13 +16,14 @@
- 🪄 **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, and neutral/grey axis boosting. 1. **Stage 1 — Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, and neutral/grey axis boosting.
2. **Stage 2 — Target Creation & Raw Printing (`printtarg`)**: Format patch targets for spectrophotometers (i1Pro, i1Pro2, ColorMunki, SpyderPrint). View high-resolution downscaled TIFF previews and print directly using native OS raw unmanaged pathways (Windows GDI uncorrected / Linux CUPS `raw`). 2. **Stage 2 — Target Creation & Raw Printing (`printtarg`)**: Format patch targets for spectrophotometers (i1Pro, i1Pro2, ColorMunki, SpyderPrint). View high-resolution downscaled TIFF previews and print directly using native OS raw unmanaged pathways (Windows GDI uncorrected / Linux CUPS `raw`).
3. **Stage 3 — Interactive Measurement (`chartread`) & Averaging (`average`)**: Instrument auto-detection (`instlist`), real-time calibration prompts, interactive strip reading state machine, live swatch grid with CIEDE2000 ($\Delta E_{00}$) quality indicators, and multi-pass sheet averaging for measurement noise reduction. 3. **Stage 3 — Interactive Measurement (`chartread`) & Averaging (`average`)**: Instrument auto-detection (`instlist`), real-time calibration prompts, interactive strip reading state machine, live swatch grid with CIEDE2000 ($\Delta E_{00}$) quality indicators and user-configurable traffic-light thresholds, diagonally split intended-vs-measured colour swatches, white reference patch preservation, and multi-pass sheet averaging for measurement noise reduction.
4. **Stage 4 — Profile Calculation (`colprof`)**: Generate high-precision cLUT mathematical ICC/ICM profiles with configurable algorithm quality, descriptions, and copyright tagging. 4. **Stage 4 — Profile Calculation (`colprof`)**: Generate high-precision cLUT mathematical ICC/ICM profiles with configurable algorithm quality, OBA/FWA compensation, illuminant/observer selection, viewing-condition transforms, custom ambient spectrum support, descriptions, and copyright tagging.
5. **Stage 5 — Verification & 3D Gamut (`profcheck` + `iccgamut`)**: Comprehensive mathematical validation report (Peak, Average, RMS $\Delta E$) paired with an interactive 3D CIELAB convex hull color volume viewer, touch controls, and bundled sRGB reference wireframe comparison. 5. **Stage 5 — Verification & 3D Gamut (`profcheck` + `iccgamut`)**: Comprehensive mathematical validation report (Peak, Average, RMS $\Delta E$) paired with an interactive 3D CIELAB convex hull color volume viewer, per-vertex true-colour rendering, layer opacity controls, camera reset, keyboard shortcut, touch controls, bundled sRGB reference wireframe comparison, and robust parsing of both JSON and legacy profcheck output formats.
- 📋 **Profiling Presets**: One-click configuration presets (Standard RGB Photo, High-Gamut CMYK Proofing, Fast RGB Draft) with custom preset export/import and security validation. - 📋 **Profiling Presets**: One-click configuration presets (Standard RGB Photo, High-Gamut CMYK Proofing, Fast RGB Draft) with custom preset export/import and security validation.
- 🐧 **glibc Compatibility**: Pre-built Linux packages compiled with Ubuntu 22.04 LTS compatibility for Debian/Ubuntu environments. - 🐧 **glibc Compatibility**: Pre-built Linux packages compiled with Ubuntu 22.04 LTS compatibility for Debian/Ubuntu environments.
- 🛡️ **Disk Artefact Gating**: Stepper navigation strictly verifies generated artefacts on disk (`.ti1`, `.ti2`, `.ti3`, `.icc`/`.icm`), preventing out-of-order execution while preserving backward navigation. - 🛡️ **Disk Artefact Gating**: Stepper navigation strictly verifies generated artefacts on disk (`.ti1`, `.ti2`, `.ti3`, `.icc`/`.icm`), preventing out-of-order execution while preserving backward navigation.
- 🌐 **Platform-Aware**: Automatic handling of platform profile conventions (`.icm` on Windows, `.icc` on Linux/macOS) and native OS printer subsystems. - 🌐 **Platform-Aware**: Automatic handling of platform profile conventions (`.icm` on Windows, `.icc` on Linux/macOS) and native OS printer subsystems.
- 🎛️ **Consistent UI Controls**: Tiered button sizing (`btn-sm`/`btn-md`/`btn-lg`/`btn-icon-sq`) and standardised action row classes provide a uniform, polished interface across all wizard stages and dialogs.
- ⚖️ **Clean AGPL Boundary**: Complete isolation of AGPLv3 binaries via asynchronous tokio IPC process pipelines. - ⚖️ **Clean AGPL Boundary**: Complete isolation of AGPLv3 binaries via asynchronous tokio IPC process pipelines.
--- ---
+13 -4
View File
@@ -97,8 +97,17 @@ ICCery is a native, cross-platform desktop application built with:
### Milestone 9 — macOS Native Support & Enhanced Print Spooling (`v0.4.0`) ### Milestone 9 — macOS Native Support & Enhanced Print Spooling (`v0.4.0`)
- [ ] **macOS Platform Bundle**: Build and sign universal macOS `.dmg` bundles with notarization. - [ ] **macOS Platform Bundle**: Build and sign universal macOS `.dmg` bundles with notarization.
- [ ] **macOS Raw Spooling**: Native CoreGraphics/CUPS raw print dialog bypass. - [ ] **macOS Raw Spooling**: Native CoreGraphics/CUPS raw print dialog bypass.
- [ ] **SpectroScan & Automated Table Support (#93)**: Support XY automated scanning tables (i1iO / SpectroScan) in `chartread` (deferred).
### Milestone 12Future Workflow & Advanced Analytics (Deferred) ### Milestone 13UI/UX & Workflow Polish
- [ ] **Batch Verification & Drift Tracking (#95)**: Track printer drift over time by comparing periodic verification measurements against a baseline profile.
- [ ] **Multi-Language Localization (#96)**: Full UI internationalization (English, German, French, Japanese). - [x] **Global Button Standardization (#177)**: Enforce `.btn-sm`/`.btn-md`/`.btn-lg`/`.btn-icon-sq` tiers across all stages, remove inline button styles, and add CSS custom properties for button metrics.
- [x] **Swatch Grid White Patch & Orientation Polish (#178)**: Finalise `is_pad` guard documentation, diagonally split swatch tooltips, and validate `printtarg` row/column ordering.
- [x] **Configurable CIEDE2000 Thresholds (#184)**: User-configurable good/warning ΔE₀₀ upper bounds in Settings, persisted across sessions and applied to the Stage 3 swatch grid.
- [x] **Stage 4 OBA/FWA Tooltips & Tests (#176)**: Contextual help for OBA/FWA, illuminant, observer, and viewing-condition controls; additional `colprof` arg builder tests.
- [x] **3D Gamut Viewer Controls (#185)**: Camera reset, opacity sliders, keyboard shortcut, and full public-API JSDoc.
- [x] **Gamut / Profcheck Hardening (#179)**: Validate `.gam` vertex/face parsing, improved `profcheck` regex fallbacks for legacy text output, and user-visible parser warnings.
### Milestone 12 — Future Workflow & Advanced Analytics
- [x] **Printer Drift Tracking & Verification Analytics (#95)**: Track longitudinal printer drift in Stage 5 over time with CIEDE2000 trend charts, breach alert banners, RFC-4180 CSV export, and history storage.
- [x] **XY Automated Scanning Tables (#93)**: Full Stage 3 support for automated XY scanning tables (SpectroScan, i1iO) with multi-line prompt classification, fiducial alignment, and sheet placement checklist.
- [ ] ~~**Multi-Language Localization (#96)**~~: *Closed — Won't Fix* (English UI retained as standard color-management terminology).
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "iccery", "name": "iccery",
"private": true, "private": true,
"version": "0.7.4", "version": "0.8.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"fetch-argyll": "node scripts/fetch-argyll.mjs", "fetch-argyll": "node scripts/fetch-argyll.mjs",
+1 -1
View File
@@ -1423,7 +1423,7 @@ dependencies = [
[[package]] [[package]]
name = "iccery" name = "iccery"
version = "0.7.4" version = "0.8.2"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"image", "image",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "iccery" name = "iccery"
version = "0.7.4" version = "0.8.2"
description = "Modern Printer Profiling UI frontend for ArgyllCMS" description = "Modern Printer Profiling UI frontend for ArgyllCMS"
authors = ["Gordon"] authors = ["Gordon"]
edition = "2021" edition = "2021"
+45
View File
@@ -2,6 +2,51 @@
# Mock script for chartread -u # Mock script for chartread -u
# This script simulates the behaviour of chartread for testing purposes. # This script simulates the behaviour of chartread for testing purposes.
# Check for --xy argument or MOCK_XY_TABLE environment variable
IS_XY=0
for arg in "$@"; do
if [ "$arg" = "--xy" ]; then
IS_XY=1
break
fi
done
if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then
echo "Place instrument on calibration tile and hit [Space] to calibrate."
read -r _calib
echo "Calibration successful."
echo "Please place sheet 1 of 1 on the table"
echo "hit return to continue, Esc or 'q' to give up"
read -r _sheet1
echo "locate patch A1 with the sight,"
echo "then hit return to continue"
read -r _fid1
echo "locate patch B24 with the sight,"
echo "then hit return to continue"
read -r _fid2
echo "Reading sheet 1..."
sleep 0.5
# Emit mock JSON for strip A
cat << 'EOF'
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
EOF
# Emit mock JSON for strip B
cat << 'EOF'
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
EOF
echo "Sheet 1 of 1 read OK"
echo "Please remove last sheet from table"
exit 0
fi
# Handheld / strip reader simulation
echo "Place instrument on calibration tile and hit [Space] to calibrate." echo "Place instrument on calibration tile and hit [Space] to calibrate."
# We don't really wait for input, just wait 1 second # We don't really wait for input, just wait 1 second
+4 -6
View File
@@ -1,14 +1,12 @@
#!/bin/bash #!/bin/bash
# Mock script for profcheck # Mock script for profcheck
# Simulates profcheck verification output # Simulates real ArgyllCMS profcheck -v -k -s -u output
echo "profcheck: Checking profile accuracy..." echo "profcheck: Checking profile accuracy..."
echo "No of test patches = 52"
sleep 1 sleep 1
cat << 'EOF' cat << 'EOF'
{"event": "profcheck_complete", "avg_de": 0.85, "max_de": 2.41, "rms_de": 1.02} {"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
EOF EOF
echo "Summary:" echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02"
echo " avg. dE = 0.85"
echo " max. dE = 2.41"
echo " rms. dE = 1.02"
exit 0 exit 0
+109 -3
View File
@@ -425,6 +425,34 @@ pub async fn select_target_file(
rx.await.map_err(|e| format!("Dialog channel error: {}", e)) rx.await.map_err(|e| format!("Dialog channel error: {}", e))
} }
#[tauri::command]
pub async fn select_csv_save_path(
app: AppHandle,
default_name: Option<String>,
) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let mut builder = app.dialog().file().add_filter("CSV", &["csv"]);
if let Some(ref name) = default_name {
if !name.trim().is_empty() {
let filename = if name.to_lowercase().ends_with(".csv") {
name.to_string()
} else {
format!("{}.csv", name)
};
builder = builder.set_file_name(filename);
}
}
let (tx, rx) = tokio::sync::oneshot::channel();
builder.save_file(move |file_path| {
let res = file_path.map(|p| p.to_string());
let _ = tx.send(res);
});
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
}
#[tauri::command] #[tauri::command]
pub async fn select_directory( pub async fn select_directory(
app: AppHandle, app: AppHandle,
@@ -829,11 +857,13 @@ pub async fn read_tiff_preview_png(path: String) -> Result<String, String> {
Ok(base64::engine::general_purpose::STANDARD.encode(png_bytes.into_inner())) Ok(base64::engine::general_purpose::STANDARD.encode(png_bytes.into_inner()))
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ChartreadConfig { pub struct ChartreadConfig {
pub basename: String, pub basename: String,
pub cwd: String, pub cwd: String,
pub port: Option<String>, pub port: Option<String>,
#[serde(default)]
pub enable_i1pro2_leds: Option<bool>,
} }
pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> { pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
@@ -849,6 +879,11 @@ pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
} }
} }
if config.enable_i1pro2_leds.unwrap_or(false) {
args.push("-Y".to_string());
args.push("l".to_string());
}
args.push(config.basename.clone()); args.push(config.basename.clone());
args args
} }
@@ -857,8 +892,12 @@ pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
pub async fn run_chartread( pub async fn run_chartread(
app: AppHandle, app: AppHandle,
state: State<'_, ProcessManager>, state: State<'_, ProcessManager>,
config: ChartreadConfig, mut config: ChartreadConfig,
) -> Result<(), String> { ) -> Result<(), String> {
if config.enable_i1pro2_leds.is_none() {
let settings = crate::settings::load_settings(app.clone()).unwrap_or_default();
config.enable_i1pro2_leds = Some(settings.enable_i1pro2_leds);
}
let binary = resolve_binary(app.clone(), "chartread".to_string()).await?; let binary = resolve_binary(app.clone(), "chartread".to_string()).await?;
let args = build_chartread_args(&config); let args = build_chartread_args(&config);
let id = format!("chartread_{}", config.basename); let id = format!("chartread_{}", config.basename);
@@ -1073,6 +1112,7 @@ pub fn build_profcheck_args(config: &ProfcheckConfig) -> Vec<String> {
"-v".to_string(), "-v".to_string(),
"-k".to_string(), "-k".to_string(),
"-s".to_string(), "-s".to_string(),
"-u".to_string(),
config.ti3_path.clone(), config.ti3_path.clone(),
config.icc_path.clone(), config.icc_path.clone(),
] ]
@@ -1558,6 +1598,7 @@ mod tests {
basename: "my_profile".to_string(), basename: "my_profile".to_string(),
cwd: "/home/user".to_string(), cwd: "/home/user".to_string(),
port: None, port: None,
enable_i1pro2_leds: None,
}; };
let args = build_chartread_args(&config); let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "my_profile"]); assert_eq!(args, vec!["-v", "-u", "my_profile"]);
@@ -1569,6 +1610,7 @@ mod tests {
basename: "my_profile".to_string(), basename: "my_profile".to_string(),
cwd: "/home/user".to_string(), cwd: "/home/user".to_string(),
port: Some("".to_string()), port: Some("".to_string()),
enable_i1pro2_leds: None,
}; };
let args = build_chartread_args(&config); let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "my_profile"]); assert_eq!(args, vec!["-v", "-u", "my_profile"]);
@@ -1580,11 +1622,48 @@ mod tests {
basename: "my_profile".to_string(), basename: "my_profile".to_string(),
cwd: "/home/user".to_string(), cwd: "/home/user".to_string(),
port: Some("1".to_string()), port: Some("1".to_string()),
enable_i1pro2_leds: None,
}; };
let args = build_chartread_args(&config); let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "-c", "1", "my_profile"]); assert_eq!(args, vec!["-v", "-u", "-c", "1", "my_profile"]);
} }
#[test]
fn test_build_chartread_args_leds_enabled() {
let config = ChartreadConfig {
basename: "my_profile".to_string(),
cwd: "/home/user".to_string(),
port: None,
enable_i1pro2_leds: Some(true),
};
let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "-Y", "l", "my_profile"]);
}
#[test]
fn test_build_chartread_args_with_port_and_leds() {
let config = ChartreadConfig {
basename: "my_profile".to_string(),
cwd: "/home/user".to_string(),
port: Some("1".to_string()),
enable_i1pro2_leds: Some(true),
};
let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "-c", "1", "-Y", "l", "my_profile"]);
}
#[test]
fn test_build_chartread_args_leds_disabled() {
let config = ChartreadConfig {
basename: "my_profile".to_string(),
cwd: "/home/user".to_string(),
port: None,
enable_i1pro2_leds: Some(false),
};
let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
}
#[test] #[test]
fn test_build_average_args() { fn test_build_average_args() {
let config = AverageConfig { let config = AverageConfig {
@@ -1783,6 +1862,33 @@ mod tests {
); );
} }
#[test]
fn test_build_colprof_args_combined_fwa_illuminant_observer_viewing() {
let config = ColprofConfig {
algorithm: "x".to_string(),
quality: "u".to_string(),
intent: Some("a".to_string()),
copyright: Some("C".to_string()),
description: Some("D".to_string()),
basename: "profile".to_string(),
cwd: "".to_string(),
fwa: Some("D65".to_string()),
illuminant: Some("D65".to_string()),
observer: Some("2015_2".to_string()),
input_viewing_cond: Some("pc".to_string()),
output_viewing_cond: Some("md".to_string()),
};
let args = build_colprof_args(&config);
assert_eq!(
args,
vec![
"-v", "-a", "x", "-q", "u", "-t", "a",
"-f", "D65", "-i", "D65", "-o", "2015_2",
"-c", "pc", "-d", "md", "-D", "D", "-C", "C", "profile"
]
);
}
#[test] #[test]
fn test_build_profcheck_args() { fn test_build_profcheck_args() {
let config = ProfcheckConfig { let config = ProfcheckConfig {
@@ -1791,7 +1897,7 @@ mod tests {
cwd: "/home/user".to_string(), cwd: "/home/user".to_string(),
}; };
let args = build_profcheck_args(&config); let args = build_profcheck_args(&config);
assert_eq!(args, vec!["-v", "-k", "-s", "my_profile.ti3", "my_profile.icc"]); assert_eq!(args, vec!["-v", "-k", "-s", "-u", "my_profile.ti3", "my_profile.icc"]);
} }
#[test] #[test]
+6
View File
@@ -5,6 +5,7 @@ mod commands;
mod events; mod events;
mod print; mod print;
mod process_manager; mod process_manager;
mod quality_store;
mod settings; mod settings;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -74,6 +75,11 @@ pub fn run() {
commands::get_printer_capabilities, commands::get_printer_capabilities,
commands::show_printer_properties, commands::show_printer_properties,
commands::print_target_native, commands::print_target_native,
commands::select_csv_save_path,
quality_store::save_verification_record,
quality_store::get_verification_history,
quality_store::clear_verification_history,
quality_store::export_verification_history_csv,
settings::load_settings, settings::load_settings,
settings::save_settings, settings::save_settings,
settings::get_all_presets, settings::get_all_presets,
+20 -34
View File
@@ -253,14 +253,17 @@ pub fn detect_driver_color_bypass(output: &str) -> Option<(&'static str, &'stati
Some(("CNIJIntent2", "4")) Some(("CNIJIntent2", "4"))
} else if output.contains("CNIJIntent") { } else if output.contains("CNIJIntent") {
Some(("CNIJIntent", "4")) Some(("CNIJIntent", "4"))
} else if output.contains("EPIJ_CCor") {
// Epson "Color Settings" option. The value 0 disables driver-side color correction.
Some(("EPIJ_CCor", "0"))
} else if output.contains("EPIJ_CMat") { } else if output.contains("EPIJ_CMat") {
// Epson "Color Settings" option. The value 3 is "Off (No Color // Epson "Color Settings" option. The value 3 is "Off (No Color
// Adjustment)" which disables driver-side color management. // Adjustment)" which disables driver-side color management.
Some(("EPIJ_CMat", "3")) Some(("EPIJ_CMat", "3"))
} else if output.contains("ColorCorrection") {
Some(("ColorCorrection", "Uncorrected"))
} else if output.contains("StpColorCorrection") { } else if output.contains("StpColorCorrection") {
Some(("StpColorCorrection", "Uncorrected")) Some(("StpColorCorrection", "Uncorrected"))
} else if output.contains("ColorCorrection") {
Some(("ColorCorrection", "Uncorrected"))
} else if output.contains("EpsonColorMode") { } else if output.contains("EpsonColorMode") {
Some(("EpsonColorMode", "Off")) Some(("EpsonColorMode", "Off"))
} else { } else {
@@ -515,38 +518,21 @@ printer Zebra_Label disabled since Wed 10 Jun 2026 - reason: out of ribbon\n\
let merged = merge_printer_info(&destinations, &statuses, default_dest); let merged = merge_printer_info(&destinations, &statuses, default_dest);
assert_eq!(merged.len(), 4); assert_eq!(merged.len(), 4);
assert_eq!( assert_eq!(merged[0].name, "Epson-Stylus-SX420W".to_string());
merged[0], assert_eq!(merged[0].status, "Idle".to_string());
Printer { assert!(merged[0].is_default);
name: "Epson-Stylus-SX420W".to_string(),
status: "Idle".to_string(), assert_eq!(merged[1].name, "HP_LaserJet".to_string());
is_default: true, assert_eq!(merged[1].status, "Printing".to_string());
} assert!(!merged[1].is_default);
);
assert_eq!( assert_eq!(merged[2].name, "Virtual_PDF".to_string());
merged[1], assert_eq!(merged[2].status, "Idle".to_string());
Printer { assert!(!merged[2].is_default);
name: "HP_LaserJet".to_string(),
status: "Printing".to_string(), assert_eq!(merged[3].name, "Zebra_Label".to_string());
is_default: false, assert_eq!(merged[3].status, "Stopped".to_string());
} assert!(!merged[3].is_default);
);
assert_eq!(
merged[2],
Printer {
name: "Virtual_PDF".to_string(),
status: "Idle".to_string(),
is_default: false,
}
);
assert_eq!(
merged[3],
Printer {
name: "Zebra_Label".to_string(),
status: "Stopped".to_string(),
is_default: false,
}
);
} }
#[test] #[test]
+1 -1
View File
@@ -276,7 +276,7 @@ mod tests {
let pm = ProcessManager::new(); let pm = ProcessManager::new();
// Test helper using dummy/mock or direct map operations // Test helper using dummy/mock or direct map operations
{ {
let mut stdins = pm.stdins.lock().await; let stdins = pm.stdins.lock().await;
assert!(!stdins.contains_key("test_proc")); assert!(!stdins.contains_key("test_proc"));
} }
} }
+421
View File
@@ -0,0 +1,421 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager};
static SEQ_COUNTER: AtomicU64 = AtomicU64::new(1);
const HISTORY_CAP: usize = 1000;
const STORE_VERSION: u32 = 1;
const MAX_STRING_LEN: usize = 200;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct VerificationRecord {
pub id: String, // backend-generated only: "vr-<epoch_millis>-<seq>"
pub timestamp: String, // ISO 8601 string
pub printer_name: String,
pub profile_name: String,
pub avg_de: f64,
pub max_de: f64,
pub rms_de: f64,
pub patch_count: u32,
pub status: String, // backend ALWAYS fills via classify_status
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct VerificationHistoryStore {
pub version: u32,
pub records: Vec<VerificationRecord>,
}
impl Default for VerificationHistoryStore {
fn default() -> Self {
Self {
version: STORE_VERSION,
records: Vec::new(),
}
}
}
/// Generates a unique record identifier: "vr-<epoch_millis>-<seq>"
pub fn generate_record_id() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let seq = SEQ_COUNTER.fetch_add(1, Ordering::SeqCst);
format!("vr-{}-{}", millis, seq)
}
/// Classifies status into ICCery verification bands (issue #95):
/// - < 1.0: "excellent"
/// - < 2.0: "good"
/// - < 3.5: "acceptable"
/// - >= 3.5: "warning"
pub fn classify_status(avg_de: f64) -> &'static str {
if avg_de < 1.0 {
"excellent"
} else if avg_de < 2.0 {
"good"
} else if avg_de < 3.5 {
"acceptable"
} else {
"warning"
}
}
/// Validates record data constraints.
pub fn validate_record(record: &VerificationRecord) -> Result<(), String> {
if record.profile_name.trim().is_empty() {
return Err("Profile name cannot be empty.".to_string());
}
if record.profile_name.len() > MAX_STRING_LEN {
return Err(format!("Profile name exceeds maximum length of {} characters.", MAX_STRING_LEN));
}
if record.printer_name.len() > MAX_STRING_LEN {
return Err(format!("Printer name exceeds maximum length of {} characters.", MAX_STRING_LEN));
}
if record.timestamp.trim().is_empty() {
return Err("Timestamp cannot be empty.".to_string());
}
if !record.avg_de.is_finite() || record.avg_de < 0.0 {
return Err("Average ΔE must be a finite non-negative number.".to_string());
}
if !record.max_de.is_finite() || record.max_de < 0.0 {
return Err("Peak ΔE must be a finite non-negative number.".to_string());
}
if !record.rms_de.is_finite() || record.rms_de < 0.0 {
return Err("RMS ΔE must be a finite non-negative number.".to_string());
}
Ok(())
}
/// Loads verification history from a JSON file.
/// Missing, unreadable, or corrupted files safely return an empty vector.
pub fn load_history_file(path: &Path) -> Vec<VerificationRecord> {
if !path.exists() {
return Vec::new();
}
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
match serde_json::from_str::<VerificationHistoryStore>(&content) {
Ok(store) => store.records,
Err(_) => Vec::new(),
}
}
/// Writes verification records to the specified JSON path.
/// Automatically creates parent directories if needed.
/// Evicts oldest records by timestamp if count exceeds HISTORY_CAP (1,000).
pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
}
let mut bounded_records: Vec<VerificationRecord> = records.to_vec();
// Sort by timestamp ascending
bounded_records.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
if bounded_records.len() > HISTORY_CAP {
let excess = bounded_records.len() - HISTORY_CAP;
bounded_records.drain(0..excess);
}
let store = VerificationHistoryStore {
version: STORE_VERSION,
records: bounded_records,
};
let json = serde_json::to_string_pretty(&store)
.map_err(|e| format!("Failed to serialize verification history: {}", e))?;
fs::write(path, json).map_err(|e| format!("Failed to write verification history: {}", e))?;
Ok(())
}
/// Helper to escape CSV text fields according to RFC-4180.
fn escape_csv_field(val: &str) -> String {
if val.contains('"') || val.contains(',') || val.contains('\n') || val.contains('\r') {
format!("\"{}\"", val.replace('"', "\"\""))
} else {
val.to_string()
}
}
/// Formats records into standard RFC-4180 CSV string.
pub fn to_csv(records: &[VerificationRecord]) -> String {
let mut csv = String::from("id,timestamp,printer_name,profile_name,avg_de,max_de,rms_de,patch_count,status\r\n");
for r in records {
csv.push_str(&format!(
"{},{},{},{},{:.4},{:.4},{:.4},{},{}\r\n",
escape_csv_field(&r.id),
escape_csv_field(&r.timestamp),
escape_csv_field(&r.printer_name),
escape_csv_field(&r.profile_name),
r.avg_de,
r.max_de,
r.rms_de,
r.patch_count,
escape_csv_field(&r.status),
));
}
csv
}
/// Validates CSV destination file path.
pub fn validate_csv_dest(dest: &str) -> Result<PathBuf, String> {
let trimmed = dest.trim();
if trimmed.is_empty() {
return Err("Destination path cannot be empty.".to_string());
}
let mut path = PathBuf::from(trimmed);
if path.extension().is_none() || path.extension().unwrap_or_default() != "csv" {
path.set_extension("csv");
}
Ok(path)
}
fn get_store_path(app: &AppHandle) -> Result<PathBuf, String> {
let app_data = app.path().app_data_dir().map_err(|e| e.to_string())?;
Ok(app_data.join("verification_history.json"))
}
// ---------------------------------------------------------------------------
// Tauri Commands
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn save_verification_record(
app: AppHandle,
mut record: VerificationRecord,
) -> Result<VerificationRecord, String> {
record.id = generate_record_id();
record.status = classify_status(record.avg_de).to_string();
validate_record(&record)?;
let path = get_store_path(&app)?;
let mut records = load_history_file(&path);
records.push(record.clone());
write_history_file(&path, &records)?;
Ok(record)
}
#[tauri::command]
pub fn get_verification_history(
app: AppHandle,
profile_name: Option<String>,
printer_name: Option<String>,
) -> Result<Vec<VerificationRecord>, String> {
let path = get_store_path(&app)?;
let mut records = load_history_file(&path);
if let Some(ref prof) = profile_name {
let prof_clean = prof.trim();
if !prof_clean.is_empty() {
records.retain(|r| r.profile_name == prof_clean);
}
}
if let Some(ref prn) = printer_name {
let prn_clean = prn.trim();
if !prn_clean.is_empty() {
records.retain(|r| r.printer_name == prn_clean);
}
}
// Return chronological order (ascending timestamp)
records.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
Ok(records)
}
#[tauri::command]
pub fn clear_verification_history(app: AppHandle) -> Result<(), String> {
let path = get_store_path(&app)?;
write_history_file(&path, &[])
}
#[tauri::command]
pub fn export_verification_history_csv(
app: AppHandle,
dest_path: String,
profile_name: Option<String>,
printer_name: Option<String>,
) -> Result<usize, String> {
let validated_path = validate_csv_dest(&dest_path)?;
let records = get_verification_history(app, profile_name, printer_name)?;
let count = records.len();
let csv_content = to_csv(&records);
if let Some(parent) = validated_path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create CSV parent directory: {}", e))?;
}
fs::write(&validated_path, csv_content)
.map_err(|e| format!("Failed to write CSV file: {}", e))?;
Ok(count)
}
// ---------------------------------------------------------------------------
// Unit Tests (Path-based, no AppHandle required)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn sample_record(id: &str, ts: &str, avg: f64) -> VerificationRecord {
VerificationRecord {
id: id.to_string(),
timestamp: ts.to_string(),
printer_name: "Canon PRO-1000".to_string(),
profile_name: "ProLustre_Photo".to_string(),
avg_de: avg,
max_de: avg * 2.0,
rms_de: avg * 1.2,
patch_count: 50,
status: classify_status(avg).to_string(),
}
}
#[test]
fn test_classify_status_bands() {
// Boundaries: <1.0 excellent, <2.0 good, <3.5 acceptable, >=3.5 warning
assert_eq!(classify_status(0.0), "excellent");
assert_eq!(classify_status(0.999), "excellent");
assert_eq!(classify_status(1.0), "good");
assert_eq!(classify_status(1.999), "good");
assert_eq!(classify_status(2.0), "acceptable");
assert_eq!(classify_status(3.499), "acceptable");
assert_eq!(classify_status(3.5), "warning");
assert_eq!(classify_status(5.2), "warning");
}
#[test]
fn test_validate_record_valid_and_invalid() {
let valid = sample_record("vr-1", "2026-09-05T12:00:00Z", 0.85);
assert!(validate_record(&valid).is_ok());
let mut empty_prof = valid.clone();
empty_prof.profile_name = " ".to_string();
assert!(validate_record(&empty_prof).is_err());
let mut empty_ts = valid.clone();
empty_ts.timestamp = "".to_string();
assert!(validate_record(&empty_ts).is_err());
let mut negative_de = valid.clone();
negative_de.avg_de = -0.1;
assert!(validate_record(&negative_de).is_err());
let mut nan_de = valid.clone();
nan_de.max_de = f64::NAN;
assert!(validate_record(&nan_de).is_err());
let mut inf_de = valid.clone();
inf_de.rms_de = f64::INFINITY;
assert!(validate_record(&inf_de).is_err());
let mut long_name = valid.clone();
long_name.profile_name = "a".repeat(201);
assert!(validate_record(&long_name).is_err());
}
#[test]
fn test_store_load_missing_and_corrupt() {
let temp_dir = std::env::temp_dir().join("iccery_test_quality_store_corrupt");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let missing_path = temp_dir.join("nonexistent.json");
let records = load_history_file(&missing_path);
assert!(records.is_empty());
let corrupt_path = temp_dir.join("corrupt.json");
fs::write(&corrupt_path, "{ broken json ... ").unwrap();
let records_corrupt = load_history_file(&corrupt_path);
assert!(records_corrupt.is_empty());
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_store_write_and_roundtrip() {
let temp_dir = std::env::temp_dir().join("iccery_test_quality_store_roundtrip");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let file_path = temp_dir.join("verification_history.json");
let rec1 = sample_record("vr-1", "2026-09-01T10:00:00Z", 0.75);
let rec2 = sample_record("vr-2", "2026-09-02T10:00:00Z", 1.85);
write_history_file(&file_path, &[rec1.clone(), rec2.clone()]).unwrap();
let loaded = load_history_file(&file_path);
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0], rec1);
assert_eq!(loaded[1], rec2);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_cap_eviction_drops_oldest_by_timestamp() {
let temp_dir = std::env::temp_dir().join("iccery_test_quality_store_eviction");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let file_path = temp_dir.join("capped_history.json");
// Generate 1005 records with disordered timestamps
let mut records = Vec::new();
for i in 0..1005 {
// ts ranges from 1000 to 2004
let ts = format!("2026-01-01T{:04}Z", i);
records.push(sample_record(&format!("vr-{}", i), &ts, 1.0));
}
// Shuffle slightly so input is out of order
records.swap(0, 500);
write_history_file(&file_path, &records).unwrap();
let loaded = load_history_file(&file_path);
assert_eq!(loaded.len(), HISTORY_CAP); // 1000 records
// Oldest 5 records (ts 0000..0004) should have been evicted
assert_eq!(loaded[0].timestamp, "2026-01-01T0005Z");
assert_eq!(loaded[loaded.len() - 1].timestamp, "2026-01-01T1004Z");
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_to_csv_escaping_and_formatting() {
let mut rec1 = sample_record("vr-1", "2026-09-05T12:00:00Z", 0.85);
rec1.printer_name = "Canon \"Pro\" 1000, Tray 1".to_string(); // quotes + comma
rec1.profile_name = "FineArt, Velvet".to_string();
let csv = to_csv(&[rec1]);
let lines: Vec<&str> = csv.split("\r\n").filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], "id,timestamp,printer_name,profile_name,avg_de,max_de,rms_de,patch_count,status");
assert!(lines[1].contains("\"Canon \"\"Pro\"\" 1000, Tray 1\""));
assert!(lines[1].contains("\"FineArt, Velvet\""));
}
#[test]
fn test_validate_csv_dest() {
assert!(validate_csv_dest(" ").is_err());
let p1 = validate_csv_dest("/tmp/history.csv").unwrap();
assert_eq!(p1.extension().unwrap(), "csv");
let p2 = validate_csv_dest("/tmp/history").unwrap();
assert_eq!(p2.extension().unwrap(), "csv");
assert_eq!(p2.to_string_lossy(), "/tmp/history.csv");
}
}
+95 -1
View File
@@ -60,13 +60,49 @@ pub struct ProfilingPreset {
pub no_randomize: Option<bool>, pub no_randomize: Option<bool>,
} }
#[derive(Debug, Deserialize, Serialize, Default, Clone)] fn default_delta_e_good_max() -> f64 { 2.0 }
fn default_delta_e_warning_max() -> f64 { 5.0 }
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AppSettings { pub struct AppSettings {
pub argyll_binary_dir: Option<String>, pub argyll_binary_dir: Option<String>,
pub default_instrument: Option<String>, pub default_instrument: Option<String>,
pub log_level: Option<String>, pub log_level: Option<String>,
#[serde(default = "default_delta_e_good_max")]
pub delta_e_good_max: f64,
#[serde(default = "default_delta_e_warning_max")]
pub delta_e_warning_max: f64,
#[serde(default)] #[serde(default)]
pub custom_presets: Vec<ProfilingPreset>, pub custom_presets: Vec<ProfilingPreset>,
#[serde(default)]
pub enable_i1pro2_leds: bool,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
argyll_binary_dir: None,
default_instrument: None,
log_level: None,
delta_e_good_max: default_delta_e_good_max(),
delta_e_warning_max: default_delta_e_warning_max(),
custom_presets: Vec::new(),
enable_i1pro2_leds: false,
}
}
}
impl AppSettings {
/// Validate thresholds and other cross-field invariants.
pub fn validate(&self) -> Result<(), String> {
if self.delta_e_good_max < 0.0 || self.delta_e_warning_max < 0.0 {
return Err("ΔE thresholds cannot be negative.".to_string());
}
if self.delta_e_good_max >= self.delta_e_warning_max {
return Err("Good ΔE threshold must be strictly less than the warning threshold.".to_string());
}
Ok(())
}
} }
pub fn get_default_presets() -> Vec<ProfilingPreset> { pub fn get_default_presets() -> Vec<ProfilingPreset> {
@@ -239,6 +275,7 @@ pub fn load_settings(app: AppHandle) -> Result<AppSettings, String> {
#[tauri::command] #[tauri::command]
pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> { pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
settings.validate()?;
let path = app.path().app_data_dir().unwrap(); let path = app.path().app_data_dir().unwrap();
fs::create_dir_all(&path).map_err(|e| e.to_string())?; fs::create_dir_all(&path).map_err(|e| e.to_string())?;
let json = serde_json::to_string_pretty(&settings).unwrap(); let json = serde_json::to_string_pretty(&settings).unwrap();
@@ -380,6 +417,39 @@ mod tests {
assert_eq!(preset, imported); assert_eq!(preset, imported);
} }
#[test]
fn test_default_delta_e_thresholds() {
let settings = AppSettings::default();
assert_eq!(settings.delta_e_good_max, 2.0);
assert_eq!(settings.delta_e_warning_max, 5.0);
assert!(settings.delta_e_good_max < settings.delta_e_warning_max);
assert!(settings.validate().is_ok());
}
#[test]
fn test_delta_e_threshold_serialization_defaults() {
let json = "{}";
let settings: AppSettings = serde_json::from_str(json).unwrap();
assert_eq!(settings.delta_e_good_max, 2.0);
assert_eq!(settings.delta_e_warning_max, 5.0);
}
#[test]
fn test_delta_e_threshold_validation() {
let mut settings = AppSettings::default();
settings.delta_e_good_max = 5.0;
settings.delta_e_warning_max = 2.0;
assert!(settings.validate().is_err());
settings.delta_e_good_max = -1.0;
settings.delta_e_warning_max = 2.0;
assert!(settings.validate().is_err());
settings.delta_e_good_max = 1.0;
settings.delta_e_warning_max = 3.0;
assert!(settings.validate().is_ok());
}
#[test] #[test]
fn test_parse_log_level_filter() { fn test_parse_log_level_filter() {
assert_eq!(parse_log_level_filter(Some("error")), log::LevelFilter::Error); assert_eq!(parse_log_level_filter(Some("error")), log::LevelFilter::Error);
@@ -399,4 +469,28 @@ mod tests {
assert_eq!(parse_log_level_filter(Some("unknown")), log::LevelFilter::Info); assert_eq!(parse_log_level_filter(Some("unknown")), log::LevelFilter::Info);
} }
} }
#[test]
fn test_default_enable_i1pro2_leds() {
let settings = AppSettings::default();
assert_eq!(settings.enable_i1pro2_leds, false);
}
#[test]
fn test_enable_i1pro2_leds_serialization() {
// Missing field defaults to false
let json_empty = "{}";
let settings_empty: AppSettings = serde_json::from_str(json_empty).unwrap();
assert_eq!(settings_empty.enable_i1pro2_leds, false);
// Explicit true
let json_true = r#"{"enable_i1pro2_leds": true}"#;
let settings_true: AppSettings = serde_json::from_str(json_true).unwrap();
assert_eq!(settings_true.enable_i1pro2_leds, true);
// Explicit false
let json_false = r#"{"enable_i1pro2_leds": false}"#;
let settings_false: AppSettings = serde_json::from_str(json_false).unwrap();
assert_eq!(settings_false.enable_i1pro2_leds, false);
}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ICCery", "productName": "ICCery",
"version": "0.7.4", "version": "0.8.2",
"identifier": "com.gronod.iccery", "identifier": "com.gronod.iccery",
"build": { "build": {
"frontendDist": "../src" "frontendDist": "../src"
+154 -31
View File
@@ -35,9 +35,9 @@
<!-- Profiling Preset Selector --> <!-- Profiling Preset Selector -->
<div class="preset-selector-container" style="padding: 0 16px 12px; border-bottom: 1px solid var(--border-color, #2a2a30);"> <div class="preset-selector-container" style="padding: 0 16px 12px; border-bottom: 1px solid var(--border-color, #2a2a30);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;"> <div class="form-group-header">
<label for="presetSelect" style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; font-weight: 600;">Profiling Preset</label> <label for="presetSelect" class="form-group-label-small">Profiling Preset</label>
<div style="display: flex; gap: 6px; align-items: center; justify-content: flex-end;"> <div class="btn-row-sm">
<button type="button" id="btnSavePresetModal" class="icon-btn btn-sm" title="Save current settings as Preset"> <button type="button" id="btnSavePresetModal" class="icon-btn btn-sm" title="Save current settings as Preset">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
</button> </button>
@@ -131,10 +131,10 @@
</div> </div>
<div class="form-group has-tooltip"> <div class="form-group has-tooltip">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;"> <div class="form-group-header">
<label style="margin: 0;">Project Directory &amp; Filename</label> <label>Project Directory &amp; Filename</label>
<div style="display:flex; justify-content:flex-end;"> <div class="btn-row-sm">
<button type="button" id="btn-import-dataset" class="secondary btn-sm" style="margin-right: 4px;">Import Dataset...</button> <button type="button" id="btn-import-dataset" class="secondary btn-sm">Import Dataset...</button>
<button type="button" id="btnOpenExisting" class="secondary btn-sm">Open Target (.ti2/.ti3)</button> <button type="button" id="btnOpenExisting" class="secondary btn-sm">Open Target (.ti2/.ti3)</button>
</div> </div>
</div> </div>
@@ -407,8 +407,8 @@
<!-- Target Label & Metadata (printtarg -d) --> <!-- Target Label & Metadata (printtarg -d) -->
<div class="form-group" style="margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);"> <div class="form-group" style="margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;"> <div class="form-group-header">
<label style="margin: 0; font-weight: 600;">Target Label &amp; Metadata (Optional)</label> <label class="form-group-title">Target Label &amp; Metadata (Optional)</label>
<button type="button" class="secondary btn-sm" id="btnToggleLabelEdit">Edit Custom Field</button> <button type="button" class="secondary btn-sm" id="btnToggleLabelEdit">Edit Custom Field</button>
</div> </div>
<p class="sub-label" style="margin-bottom: 10px;">Printed below test patches to identify hardware, ink, and media used for this profiling run.</p> <p class="sub-label" style="margin-bottom: 10px;">Printed below test patches to identify hardware, ink, and media used for this profiling run.</p>
@@ -481,7 +481,7 @@
<select id="printerSelect"> <select id="printerSelect">
<option value="" disabled selected>Detecting printers...</option> <option value="" disabled selected>Detecting printers...</option>
</select> </select>
<button type="button" class="btn-icon" id="btnRefreshPrinters" title="Refresh printer list"></button> <button type="button" class="btn-icon-sq" id="btnRefreshPrinters" title="Refresh printer list"></button>
<button type="button" class="btn-properties" id="btnPrinterProperties" title="Open native printer driver preferences (disable colour management, set media type, etc.)">⚙️ Preferences</button> <button type="button" class="btn-properties" id="btnPrinterProperties" title="Open native printer driver preferences (disable colour management, set media type, etc.)">⚙️ Preferences</button>
<span id="printerStatusBadge" class="status-badge badge-idle hidden">Idle</span> <span id="printerStatusBadge" class="status-badge badge-idle hidden">Idle</span>
</div> </div>
@@ -521,7 +521,7 @@
</div> </div>
<div class="print-actions-row"> <div class="print-actions-row">
<button class="primary btn-print" id="btnPrintAll"> <button class="primary" id="btnPrintAll">
<span class="btn-text">🖨️ Print All Pages (Bypass CM)</span> <span class="btn-text">🖨️ Print All Pages (Bypass CM)</span>
<span class="btn-spinner hidden"></span> <span class="btn-spinner hidden"></span>
</button> </button>
@@ -550,8 +550,8 @@
<div class="form-container" style="margin-bottom: 16px;"> <div class="form-container" style="margin-bottom: 16px;">
<div class="form-group"> <div class="form-group">
<label for="chartreadInstrumentSelect">Communication Port</label> <label for="chartreadInstrumentSelect">Communication Port</label>
<div class="input-row" style="display: flex; gap: 8px; align-items: center;"> <div class="input-row-sm">
<select id="chartreadInstrumentSelect" style="flex: 1;"> <select id="chartreadInstrumentSelect">
<option value="">Auto (First available port)</option> <option value="">Auto (First available port)</option>
</select> </select>
<button type="button" class="secondary" id="btnDetectInstruments" title="Detect connected instruments via instlist">↻ Detect</button> <button type="button" class="secondary" id="btnDetectInstruments" title="Detect connected instruments via instlist">↻ Detect</button>
@@ -559,6 +559,25 @@
</div> </div>
</div> </div>
<!-- XY Scanning Table Panel -->
<div id="xyTableHint" class="notification-banner info hidden">
<span></span>
<span>SpectroScan → use the SS layout in Stage 2; i1iO → use i1/i1Pro2 layout — the table is auto-detected when chartread starts.</span>
</div>
<div id="xyTablePanel" class="xy-table-panel hidden">
<div class="xy-table-header">
<h4>XY Automated Scanning Table Sequence</h4>
<span id="xyTableActiveStepBadge" class="status-badge badge-idle">Standby</span>
</div>
<ol class="xy-steps-list">
<li id="xyStepPlace" class="xy-step">Place sheet on table</li>
<li id="xyStepAlign" class="xy-step">Align reference patches with sight</li>
<li id="xyStepScan" class="xy-step">Automated scan in progress</li>
<li id="xyStepRemove" class="xy-step">Remove sheet from table</li>
</ol>
</div>
<!-- State & Prompt Area --> <!-- State & Prompt Area -->
<div class="chartread-status"> <div class="chartread-status">
<div class="status-label">State: <span id="chartreadState">IDLE</span></div> <div class="status-label">State: <span id="chartreadState">IDLE</span></div>
@@ -593,13 +612,13 @@
<!-- Multi-Pass Session & Averaging Controls --> <!-- Multi-Pass Session & Averaging Controls -->
<div class="averaging-panel hidden" id="chartreadAveragingPanel" style="margin-top: 16px; padding: 14px; border: 1px solid var(--border-color, #333); border-radius: 8px; background: rgba(255, 255, 255, 0.03);"> <div class="averaging-panel hidden" id="chartreadAveragingPanel" style="margin-top: 16px; padding: 14px; border: 1px solid var(--border-color, #333); border-radius: 8px; background: rgba(255, 255, 255, 0.03);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <div class="form-group-header">
<h3 style="margin: 0; font-size: 1rem;">Multi-Pass Target Averaging</h3> <h3 class="form-group-title">Multi-Pass Target Averaging</h3>
<span id="passCounterBadge" class="status-badge badge-idle">Pass 1 Complete</span> <span id="passCounterBadge" class="status-badge badge-idle">Pass 1 Complete</span>
</div> </div>
<p class="sub-label" style="margin-bottom: 12px;">Each successful sheet is saved as a separate _passN.ti3 file. Stage 4 stays locked until you finish. Measure extra copies to reduce noise, then finish to write the canonical .ti3.</p> <p class="sub-label" style="margin-bottom: 12px;">Each successful sheet is saved as a separate _passN.ti3 file. Stage 4 stays locked until you finish. Measure extra copies to reduce noise, then finish to write the canonical .ti3.</p>
<div id="passesList" class="passes-list" style="display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;"></div> <div id="passesList" class="passes-list" style="display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;"></div>
<div style="display: flex; gap: 8px;"> <div class="btn-row">
<button type="button" class="secondary" id="btnMeasureAnotherSheet"> Measure Another Sheet</button> <button type="button" class="secondary" id="btnMeasureAnotherSheet"> Measure Another Sheet</button>
<button type="button" class="primary" id="btnFinishAndAverage" title="Complete measurement (Finish) before profiling">✓ Average Passes &amp; Proceed →</button> <button type="button" class="primary" id="btnFinishAndAverage" title="Complete measurement (Finish) before profiling">✓ Average Passes &amp; Proceed →</button>
</div> </div>
@@ -648,7 +667,7 @@
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group has-tooltip">
<label>OBA / FWA Compensation</label> <label>OBA / FWA Compensation</label>
<select id="colprofFwa"> <select id="colprofFwa">
<option value="D50" selected>Calculated M1 — D50 FWA Compensation (Recommended)</option> <option value="D50" selected>Calculated M1 — D50 FWA Compensation (Recommended)</option>
@@ -656,14 +675,24 @@
<option value="D65">D65 FWA Compensation</option> <option value="D65">D65 FWA Compensation</option>
<option value="custom">Custom Ambient Spectrum (.sp)...</option> <option value="custom">Custom Ambient Spectrum (.sp)...</option>
</select> </select>
<div class="tooltip-text">
Select how colprof handles Optical Brightening Agent (OBA) / Fluorescent Whitening Agent (FWA) compensation.<br>
<strong>D50</strong>: Simulates ISO 3664 M1 viewing (D50, UV-cut) — recommended for modern OBA papers.<br>
<strong>None</strong>: Native M0; no FWA compensation. Can look yellow under D50 if the paper contains OBA.<br>
<strong>D65</strong>: Simulates outdoor D65 daylight; useful for outdoor display / packaging proofing.<br>
<strong>Custom (.sp)</strong>: Provide an Argyll spectrum file describing the viewing booth illuminant.
</div>
</div> </div>
<div class="form-group hidden" id="colprofCustomSpRow"> <div class="form-group hidden has-tooltip" id="colprofCustomSpRow">
<label>Custom Spectrum File (.sp)</label> <label>Custom Spectrum File (.sp)</label>
<div style="display: flex; gap: 8px;"> <div class="input-row-sm">
<input type="text" id="colprofCustomSpPath" placeholder="Select .sp file..." readonly style="flex:1;"> <input type="text" id="colprofCustomSpPath" placeholder="Select .sp file..." readonly>
<button class="secondary btn-sm" id="btnBrowseCustomSp">Browse</button> <button class="secondary btn-sm" id="btnBrowseCustomSp">Browse</button>
</div> </div>
<div class="tooltip-text">
Path to an Argyll-format spectrum file (.sp) representing the ambient illuminant. This is passed to colprof via the -f option and overrides the built-in D50/D65 compensation.
</div>
</div> </div>
<details class="advanced-section-details"> <details class="advanced-section-details">
@@ -673,7 +702,7 @@
</summary> </summary>
<div class="advanced-content"> <div class="advanced-content">
<div class="advanced-grid-2col"> <div class="advanced-grid-2col">
<div class="form-group"> <div class="form-group has-tooltip">
<label>Standard Illuminant</label> <label>Standard Illuminant</label>
<select id="colprofIlluminant"> <select id="colprofIlluminant">
<option value="" selected>Default (D50)</option> <option value="" selected>Default (D50)</option>
@@ -685,8 +714,11 @@
<option value="F8">F8</option> <option value="F8">F8</option>
<option value="F10">F10</option> <option value="F10">F10</option>
</select> </select>
<div class="tooltip-text">
Illuminant used for the CIE colourimetric calculations in colprof (option -i). Leave at Default (D50) for normal print profiling.
</div> </div>
<div class="form-group"> </div>
<div class="form-group has-tooltip">
<label>Standard Observer</label> <label>Standard Observer</label>
<select id="colprofObserver"> <select id="colprofObserver">
<option value="" selected>Default (1931 2°)</option> <option value="" selected>Default (1931 2°)</option>
@@ -694,8 +726,11 @@
<option value="2015_2">2015 2°</option> <option value="2015_2">2015 2°</option>
<option value="2015_10">2015 10°</option> <option value="2015_10">2015 10°</option>
</select> </select>
<div class="tooltip-text">
CIE standard observer used for the colourimetric calculations (option -o). The 1931 2° observer is the default for graphic-arts profiling.
</div> </div>
<div class="form-group"> </div>
<div class="form-group has-tooltip">
<label>Input Viewing Condition</label> <label>Input Viewing Condition</label>
<select id="colprofInputViewCond"> <select id="colprofInputViewCond">
<option value="none" selected>None</option> <option value="none" selected>None</option>
@@ -704,8 +739,11 @@
<option value="pe">Print evaluation (CIE 116-1995)</option> <option value="pe">Print evaluation (CIE 116-1995)</option>
<option value="pm">Print evaluation (partial mid-tone adaptation)</option> <option value="pm">Print evaluation (partial mid-tone adaptation)</option>
</select> </select>
<div class="tooltip-text">
Viewing condition under which the print target was measured (option -c). This affects the adapted white point used in the profile lookup table.
</div> </div>
<div class="form-group"> </div>
<div class="form-group has-tooltip">
<label>Output Viewing Condition</label> <label>Output Viewing Condition</label>
<select id="colprofOutputViewCond"> <select id="colprofOutputViewCond">
<option value="none" selected>None</option> <option value="none" selected>None</option>
@@ -716,6 +754,9 @@
<option value="jd">Projector in dark environment</option> <option value="jd">Projector in dark environment</option>
<option value="tv">Television/Film Studio</option> <option value="tv">Television/Film Studio</option>
</select> </select>
<div class="tooltip-text">
Viewing condition under which the profile is intended to be used (option -d). Useful for creating display / output-referred profiles.
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -723,7 +764,7 @@
</div> </div>
<div class="stage-actions"> <div class="stage-actions">
<button class="primary" id="btnCreateProfile">Calculate Profile (.icc)</button> <button class="primary btn-lg" id="btnCreateProfile">Calculate Profile (.icc)</button>
</div> </div>
<!-- Spinner and Progress Stage --> <!-- Spinner and Progress Stage -->
@@ -736,7 +777,7 @@
<div class="success-card hidden" id="colprofSuccessCard"> <div class="success-card hidden" id="colprofSuccessCard">
<h3>🎉 Profile Calculation Complete</h3> <h3>🎉 Profile Calculation Complete</h3>
<p id="colprofSuccessInfo"></p> <p id="colprofSuccessInfo"></p>
<button class="primary" id="btnGoToVerify">Proceed to Verification →</button> <button class="primary btn-lg" id="btnGoToVerify">Proceed to Verification →</button>
</div> </div>
<!-- Process Log --> <!-- Process Log -->
@@ -752,7 +793,7 @@
<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">
<button class="primary" id="btnVerify">Verify Profile Accuracy</button> <button class="primary btn-lg" id="btnVerify">Verify Profile Accuracy</button>
</div> </div>
<!-- Report Card --> <!-- Report Card -->
@@ -782,6 +823,44 @@
</div> </div>
</div> </div>
<!-- Verification History & Drift Tracking -->
<details class="log-container drift-history" id="driftHistorySection">
<summary>Verification History &amp; Drift Tracking</summary>
<div class="notification-banner warning hidden" id="driftAlertCard">
<span id="driftAlertIcon">⚠️</span><span id="driftAlertText"></span>
</div>
<div class="drift-filter-row hidden" id="driftFilterRow">
<label for="driftPrinterFilter">Filter by Printer:</label>
<select id="driftPrinterFilter">
<option value="">All Printers</option>
</select>
</div>
<div id="driftChartWrap" class="drift-chart-wrap">
<svg id="driftTrendChart" role="img" aria-label="Printer Drift Verification History"></svg>
</div>
<p id="driftEmptyState" class="help-hint">No verification history yet for this profile.</p>
<div class="drift-table-wrap">
<table id="verificationHistoryTable" class="drift-table">
<thead>
<tr>
<th>Date</th>
<th>Printer</th>
<th>Avg ΔE₀₀</th>
<th>Peak ΔE₀₀</th>
<th>RMS ΔE₀₀</th>
<th>Patches</th>
<th>Status</th>
</tr>
</thead>
<tbody id="verificationHistoryTbody"></tbody>
</table>
</div>
<div class="btn-row">
<button type="button" class="secondary" id="btnExportHistoryCsv" disabled>Export CSV…</button>
<button type="button" class="danger" id="btnClearHistory" disabled>Clear History</button>
</div>
</details>
<!-- 3D Gamut Viewer --> <!-- 3D Gamut Viewer -->
<div class="gamut-viewer-section"> <div class="gamut-viewer-section">
<div class="gamut-viewer-header"> <div class="gamut-viewer-header">
@@ -805,6 +884,10 @@
<span class="gamut-toggle-slider"></span> <span class="gamut-toggle-slider"></span>
</label> </label>
</div> </div>
<div class="gamut-legend-item gamut-slider-item">
<label for="rngProfileOpacity" class="gamut-slider-label">Opacity</label>
<input type="range" id="rngProfileOpacity" min="0.1" max="1" step="0.05" value="0.88">
</div>
<div class="gamut-legend-item"> <div class="gamut-legend-item">
<span class="gamut-swatch gamut-swatch-srgb"></span> <span class="gamut-swatch gamut-swatch-srgb"></span>
@@ -814,6 +897,10 @@
<span class="gamut-toggle-slider"></span> <span class="gamut-toggle-slider"></span>
</label> </label>
</div> </div>
<div class="gamut-legend-item gamut-slider-item">
<label for="rngSrgbOpacity" class="gamut-slider-label">Opacity</label>
<input type="range" id="rngSrgbOpacity" min="0.1" max="1" step="0.05" value="0.55">
</div>
<div class="gamut-legend-item"> <div class="gamut-legend-item">
<span class="gamut-swatch gamut-swatch-axes"></span> <span class="gamut-swatch gamut-swatch-axes"></span>
@@ -823,6 +910,15 @@
<span class="gamut-toggle-slider"></span> <span class="gamut-toggle-slider"></span>
</label> </label>
</div> </div>
<div class="gamut-legend-item gamut-slider-item">
<label for="rngAxisOpacity" class="gamut-slider-label">Opacity</label>
<input type="range" id="rngAxisOpacity" min="0.1" max="1" step="0.05" value="0.8">
</div>
<div class="gamut-controls-actions">
<button type="button" id="btnGamutResetCamera" class="btn-sm" title="Reset camera to default view (R)">⟲ Reset View</button>
<span class="gamut-keyboard-hint">Press R to reset</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -844,10 +940,37 @@
<label for="argyll_binary_dir">ArgyllCMS Binary Directory Override</label> <label for="argyll_binary_dir">ArgyllCMS Binary Directory Override</label>
<input type="text" id="argyll_binary_dir" placeholder="Leave empty for bundled sidecars (e.g. /usr/bin)"> <input type="text" id="argyll_binary_dir" placeholder="Leave empty for bundled sidecars (e.g. /usr/bin)">
</div> </div>
<div class="form-group"> <div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
<label for="default_instrument">Default Instrument Override</label> <label style="font-weight: 600;">Instrument &amp; Measurement Preferences</label>
<div style="margin-top: 8px;">
<label for="default_instrument" class="sub-label">Default Instrument Override</label>
<input type="text" id="default_instrument" placeholder="e.g. i1"> <input type="text" id="default_instrument" placeholder="e.g. i1">
</div> </div>
<div style="margin-top: 12px;">
<label class="checkbox-label" for="enable_i1pro2_leds" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="enable_i1pro2_leds">
<span>Enable i1Pro 2 status LEDs (<code>-Y l</code>)</span>
</label>
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">Provides visual status feedback via the instrument ring LEDs during patch reading. Requires patched ArgyllCMS build.</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;">Stage 3 ΔE₀₀ Traffic-Light Thresholds</label>
<div class="input-row" style="margin-top: 8px; align-items: flex-start;">
<div>
<label for="deltaEGoodMax" class="sub-label">Good / Warning boundary</label>
<input type="number" id="deltaEGoodMax" min="0" step="0.1" value="2.0">
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">ΔE below this value is shown as green (Good).</small>
</div>
<div>
<label for="deltaEWarningMax" class="sub-label">Warning / Bad boundary</label>
<input type="number" id="deltaEWarningMax" min="0" step="0.1" value="5.0">
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">ΔE above this value is shown as red (Bad).</small>
</div>
</div>
<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);"> <div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
<label style="font-weight: 600;">Diagnostics &amp; Logging</label> <label style="font-weight: 600;">Diagnostics &amp; Logging</label>
<div class="input-row" style="margin-top: 8px;"> <div class="input-row" style="margin-top: 8px;">
@@ -862,7 +985,7 @@
</select> </select>
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">Applies immediately on Save</small> <small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">Applies immediately on Save</small>
</div> </div>
<div style="display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap;"> <div class="btn-row wrap">
<button type="button" id="btnOpenLogFolder" class="secondary" title="Open log folder in file explorer">📂 Open Log Folder</button> <button type="button" id="btnOpenLogFolder" class="secondary" title="Open log folder in file explorer">📂 Open Log Folder</button>
<button type="button" id="btnCopyLogPath" class="secondary" title="Copy full log file path to clipboard">📋 Copy Path</button> <button type="button" id="btnCopyLogPath" class="secondary" title="Copy full log file path to clipboard">📋 Copy Path</button>
<button type="button" id="btnCopyLogExcerpt" class="secondary" title="Copy recent log entries (last 200 lines) to clipboard">📋 Copy Recent Logs</button> <button type="button" id="btnCopyLogExcerpt" class="secondary" title="Copy recent log entries (last 200 lines) to clipboard">📋 Copy Recent Logs</button>
@@ -884,7 +1007,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.7.2</span> &bull; <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p> <p><strong>Version:</strong> <span id="aboutVersion">v0.8.2</span> &bull; <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
<p><strong>Copyright &copy; 2026 Gordon Bolton. All rights reserved.</strong></p> <p><strong>Copyright &copy; 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">
+430 -52
View File
@@ -45,7 +45,7 @@ export function populateStage3TargetContext(metadata) {
} }
// State machine states // State machine states
const STATE = { export const STATE = {
IDLE: "IDLE", IDLE: "IDLE",
CALIBRATING: "CALIBRATING", CALIBRATING: "CALIBRATING",
AWAITING_STRIP: "AWAITING_STRIP", AWAITING_STRIP: "AWAITING_STRIP",
@@ -53,13 +53,242 @@ const STATE = {
ALL_STRIPS_READ: "ALL_STRIPS_READ", ALL_STRIPS_READ: "ALL_STRIPS_READ",
WARNING: "WARNING", WARNING: "WARNING",
PROMPT_CONTINUE: "PROMPT_CONTINUE", PROMPT_CONTINUE: "PROMPT_CONTINUE",
TABLE_PLACE_SHEET: "TABLE_PLACE_SHEET",
TABLE_ALIGN: "TABLE_ALIGN",
ERROR: "ERROR", ERROR: "ERROR",
FINISHED: "FINISHED", FINISHED: "FINISHED",
}; };
/**
* Classifies a line of stdout from chartread into a state transition, prompt, and metadata.
* Pure function with no side-effects or DOM interaction.
*
* @param {string} line - Raw stdout line
* @param {string} currentState - The current STATE value
* @returns {{ state: string, prompt: string, matched: boolean, meta?: object }}
*/
export function classifyChartreadLine(line, currentState = STATE.IDLE) {
if (typeof line !== "string") {
return { state: currentState, prompt: "", matched: false };
}
const lineTrim = line.trim();
const lineLower = lineTrim.toLowerCase();
if (!lineTrim) {
return { state: currentState, prompt: "", matched: false };
}
// 1. Info-only: Remove last sheet notice (emitted by Argyll before writing .ti3 and exiting)
if (lineLower.includes("remove last sheet from table") || lineLower.includes("remove last sheet")) {
return {
state: currentState,
prompt: lineTrim,
matched: true,
meta: { isRemoveSheetNotice: true },
};
}
// 2. Info-only: Sheet read OK
const sheetOkMatch = lineTrim.match(/sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok/i);
if (sheetOkMatch) {
return {
state: currentState,
prompt: lineTrim,
matched: true,
meta: {
sheetOk: true,
sheet: parseInt(sheetOkMatch[1], 10),
totalSheets: parseInt(sheetOkMatch[2], 10),
},
};
}
// 3. Fiducial alignment prompt (XY table)
// e.g. "locate patch A1 with the sight," or "locate patch 1 with the sight"
const alignMatch = lineTrim.match(/locate\s+patch\s+([A-Za-z0-9_]+)\s+with\s+(?:the\s+)?sight/i);
if (alignMatch) {
return {
state: STATE.TABLE_ALIGN,
prompt: lineTrim,
matched: true,
meta: { patch: alignMatch[1] },
};
}
if (lineLower.includes("locate patch") && lineLower.includes("sight")) {
const fallbackMatch = lineTrim.match(/locate\s+patch\s+([^\s,]+)/i);
return {
state: STATE.TABLE_ALIGN,
prompt: lineTrim,
matched: true,
meta: { patch: fallbackMatch ? fallbackMatch[1] : "" },
};
}
// 4. Sheet placement prompt (XY table)
// e.g. "Please place sheet 1 of 1 on the table" or "Please remove previous sheet and place sheet 2 of 2 on the table"
const placeMatch = lineTrim.match(/place\s+sheet\s+(\d+)\s+of\s+(\d+)/i);
if (placeMatch) {
return {
state: STATE.TABLE_PLACE_SHEET,
prompt: lineTrim,
matched: true,
meta: {
sheet: parseInt(placeMatch[1], 10),
totalSheets: parseInt(placeMatch[2], 10),
},
};
}
if (lineLower.includes("place sheet") || lineLower.includes("remove previous sheet")) {
return {
state: STATE.TABLE_PLACE_SHEET,
prompt: lineTrim,
matched: true,
meta: {},
};
}
// 5. Continuation lines ("hit return to continue...", etc.)
// Real Argyll XY prompts are two lines:
// Line 1: "locate patch A1 with the sight,"
// Line 2: "then hit return to continue"
// When line 2 arrives, if we are in TABLE_PLACE_SHEET or TABLE_ALIGN, we MUST remain sticky in that table state!
const isContinuePrompt =
(lineLower.includes("hit return to continue") ||
lineLower.includes("then hit return to continue") ||
lineLower.includes("hit return to continue, esc or 'q' to give up")) &&
!lineLower.includes("use it anyway");
if (isContinuePrompt) {
if (currentState === STATE.TABLE_PLACE_SHEET) {
return {
state: STATE.TABLE_PLACE_SHEET,
prompt: lineTrim,
matched: true,
meta: { isContinuation: true },
};
}
if (currentState === STATE.TABLE_ALIGN) {
return {
state: STATE.TABLE_ALIGN,
prompt: lineTrim,
matched: true,
meta: { isContinuation: true },
};
}
return {
state: STATE.PROMPT_CONTINUE,
prompt: lineTrim,
matched: true,
};
}
// 6. Strip / measurement completed signals
if (
lineLower.includes("'d' if done") ||
lineLower.includes("'d' when done") ||
lineLower.includes("d if done") ||
lineLower.includes("d when done") ||
lineLower.includes("d to finish") ||
lineLower.includes("d to save") ||
lineLower.includes("all strips read") ||
lineLower.includes("all patches read") ||
lineLower.includes("done reading")
) {
return {
state: STATE.ALL_STRIPS_READ,
prompt: lineTrim,
matched: true,
};
}
// 7. Warning prompts (e.g. unexpected response, use it anyway)
if (
lineLower.includes("(warning)") ||
lineLower.includes("use it anyway") ||
lineLower.includes("seem to have read strip pass") ||
lineLower.includes("unexpected response")
) {
return {
state: STATE.WARNING,
prompt: lineTrim,
matched: true,
};
}
// 8. Calibration prompts
if (
((lineLower.includes("place") &&
(lineLower.includes("reference") ||
lineLower.includes("white") ||
lineLower.includes("calibrat") ||
lineLower.includes("standard"))) ||
lineLower.includes("hit any key to continue") ||
lineLower.includes("calibration")) &&
!lineLower.includes("place sheet") &&
!lineLower.includes("locate patch")
) {
return {
state: STATE.CALIBRATING,
prompt: lineTrim,
matched: true,
};
}
// 9. Ready to read / Strip trigger (handheld / strip readers)
if (
((lineLower.includes("hit") && lineLower.includes("read") && lineLower.includes("strip")) ||
lineLower.includes("ready to read") ||
(lineLower.includes("read") && lineLower.includes("strip") && lineLower.includes("key"))) &&
!lineLower.includes("all strips read")
) {
return {
state: STATE.AWAITING_STRIP,
prompt: lineTrim,
matched: true,
};
}
// 10. Reading / Scanning
if (
lineLower.includes("reading strip") ||
lineLower.includes("processing") ||
lineLower.includes("scanning") ||
lineLower.includes("reading sheet")
) {
return {
state: STATE.READING,
prompt: lineTrim,
matched: true,
};
}
// 11. Errors
if (
lineLower.includes("error") ||
lineLower.includes("too fast") ||
lineLower.includes("too slow") ||
lineLower.includes("misread") ||
lineLower.includes("failed to read")
) {
return {
state: STATE.ERROR,
prompt: lineTrim,
matched: true,
};
}
return {
state: currentState,
prompt: lineTrim,
matched: false,
};
}
let currentState = STATE.IDLE; let currentState = STATE.IDLE;
let currentProcessId = ""; let currentProcessId = "";
let measurementInProgress = false; let measurementInProgress = false;
let xyTableDetected = false;
let currentPassIndex = 0; let currentPassIndex = 0;
const recordedPasses = []; const recordedPasses = [];
@@ -83,6 +312,82 @@ export function initChartread() {
const passCounterBadge = document.getElementById("passCounterBadge"); const passCounterBadge = document.getElementById("passCounterBadge");
const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet"); const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet");
const btnFinishAndAverage = document.getElementById("btnFinishAndAverage"); const btnFinishAndAverage = document.getElementById("btnFinishAndAverage");
const xyTableHint = document.getElementById("xyTableHint");
const xyTablePanel = document.getElementById("xyTablePanel");
const xyTableActiveStepBadge = document.getElementById("xyTableActiveStepBadge");
const xyStepPlace = document.getElementById("xyStepPlace");
const xyStepAlign = document.getElementById("xyStepAlign");
const xyStepScan = document.getElementById("xyStepScan");
const xyStepRemove = document.getElementById("xyStepRemove");
function setXyTableVisible(visible) {
if (xyTableHint) {
if (visible) xyTableHint.classList.remove("hidden");
else xyTableHint.classList.add("hidden");
}
if (xyTablePanel) {
if (visible) xyTablePanel.classList.remove("hidden");
else xyTablePanel.classList.add("hidden");
}
}
function updateXyTableSequence(phase) {
if (!xyTablePanel) return;
const steps = [
{ el: xyStepPlace, name: "place" },
{ el: xyStepAlign, name: "align" },
{ el: xyStepScan, name: "scan" },
{ el: xyStepRemove, name: "remove" },
];
const phaseOrder = ["place", "align", "scan", "remove", "done"];
const targetIndex = phaseOrder.indexOf(phase);
steps.forEach((step, idx) => {
if (!step.el) return;
step.el.classList.remove("active", "completed");
if (targetIndex >= 0) {
if (idx < targetIndex) {
step.el.classList.add("completed");
} else if (idx === targetIndex && phase !== "done") {
step.el.classList.add("active");
} else if (phase === "done") {
step.el.classList.add("completed");
}
}
});
if (xyTableActiveStepBadge) {
xyTableActiveStepBadge.className = "status-badge";
switch (phase) {
case "place":
xyTableActiveStepBadge.textContent = "Step 1: Place Sheet";
xyTableActiveStepBadge.classList.add("badge-primary");
break;
case "align":
xyTableActiveStepBadge.textContent = "Step 2: Align Patches";
xyTableActiveStepBadge.classList.add("badge-primary");
break;
case "scan":
xyTableActiveStepBadge.textContent = "Step 3: Scanning";
xyTableActiveStepBadge.classList.add("badge-primary");
break;
case "remove":
xyTableActiveStepBadge.textContent = "Step 4: Remove Sheet";
xyTableActiveStepBadge.classList.add("badge-primary");
break;
case "done":
xyTableActiveStepBadge.textContent = "Complete";
xyTableActiveStepBadge.classList.add("badge-good");
break;
default:
xyTableActiveStepBadge.textContent = "Standby";
xyTableActiveStepBadge.classList.add("badge-idle");
break;
}
}
}
function setMeasurementBusy(busy) { function setMeasurementBusy(busy) {
measurementInProgress = busy; measurementInProgress = busy;
@@ -182,7 +487,13 @@ export function initChartread() {
const opt = document.createElement("option"); const opt = document.createElement("option");
// Port value for -c switch. If port is 1 or auto, empty string leaves -c omitted for default port // Port value for -c switch. If port is 1 or auto, empty string leaves -c omitted for default port
opt.value = inst.port && inst.port !== "1" ? inst.port : ""; opt.value = inst.port && inst.port !== "1" ? inst.port : "";
const isXy = /spectro\s?scan|i1io/i.test(inst.name) || /spectro\s?scan|i1io/i.test(inst.type);
if (isXy) {
opt.dataset.xy = "1";
opt.textContent = `${inst.type || inst.name}${inst.port ? ` (Port ${inst.port})` : ""} · XY Table`;
} else {
opt.textContent = `${inst.type || inst.name}${inst.port ? ` (Port ${inst.port})` : ""}`; opt.textContent = `${inst.type || inst.name}${inst.port ? ` (Port ${inst.port})` : ""}`;
}
instrumentSelect.appendChild(opt); instrumentSelect.appendChild(opt);
}); });
instrumentSelect.value = ""; instrumentSelect.value = "";
@@ -202,6 +513,21 @@ export function initChartread() {
}); });
} }
if (instrumentSelect) {
instrumentSelect.addEventListener("change", () => {
const selectedOpt = instrumentSelect.selectedOptions && instrumentSelect.selectedOptions[0];
const isXy = Boolean(selectedOpt && selectedOpt.dataset && selectedOpt.dataset.xy === "1");
if (isXy) {
xyTableDetected = true;
setXyTableVisible(true);
updateXyTableSequence("standby");
} else if (!measurementInProgress) {
xyTableDetected = false;
setXyTableVisible(false);
}
});
}
function setState(newState) { function setState(newState) {
currentState = newState; currentState = newState;
if (stateLabel) stateLabel.textContent = newState; if (stateLabel) stateLabel.textContent = newState;
@@ -287,6 +613,22 @@ export function initChartread() {
} }
if (btnCancel) btnCancel.classList.remove("hidden"); if (btnCancel) btnCancel.classList.remove("hidden");
break; break;
case STATE.TABLE_PLACE_SHEET:
if (btnAccept) {
btnAccept.disabled = false;
btnAccept.textContent = "✓ Sheet Placed — Continue";
btnAccept.classList.remove("hidden");
}
if (btnCancel) btnCancel.classList.remove("hidden");
break;
case STATE.TABLE_ALIGN:
if (btnAccept) {
btnAccept.disabled = false;
btnAccept.textContent = "✓ Aligned — Continue";
btnAccept.classList.remove("hidden");
}
if (btnCancel) btnCancel.classList.remove("hidden");
break;
case STATE.ERROR: case STATE.ERROR:
if (btnRetry) { if (btnRetry) {
btnRetry.disabled = false; btnRetry.disabled = false;
@@ -339,6 +681,15 @@ export function initChartread() {
setState(STATE.CALIBRATING); setState(STATE.CALIBRATING);
setPrompt("Starting chartread... waiting for instrument calibration prompt."); setPrompt("Starting chartread... waiting for instrument calibration prompt.");
const selectedOpt = instrumentSelect && instrumentSelect.selectedOptions && instrumentSelect.selectedOptions[0];
if (selectedOpt && selectedOpt.dataset && selectedOpt.dataset.xy === "1") {
xyTableDetected = true;
setXyTableVisible(true);
updateXyTableSequence("standby");
} else {
xyTableDetected = false;
}
const selectedPort = instrumentSelect && instrumentSelect.value ? instrumentSelect.value : null; const selectedPort = instrumentSelect && instrumentSelect.value ? instrumentSelect.value : null;
const config = { const config = {
@@ -365,69 +716,74 @@ export function initChartread() {
logPre.textContent += line + "\n"; logPre.textContent += line + "\n";
logPre.scrollTop = logPre.scrollHeight; logPre.scrollTop = logPre.scrollHeight;
// Parse prompts for state transitions const classified = classifyChartreadLine(line, currentState);
const lineLower = line.toLowerCase();
if (classified.matched) {
if ( if (
lineLower.includes("'d' if done") || classified.state === STATE.TABLE_PLACE_SHEET ||
lineLower.includes("'d' when done") || classified.state === STATE.TABLE_ALIGN ||
lineLower.includes("d if done") || (classified.meta && (classified.meta.isRemoveSheetNotice || classified.meta.sheetOk))
lineLower.includes("d when done") ||
lineLower.includes("d to finish") ||
lineLower.includes("d to save") ||
lineLower.includes("all strips read") ||
lineLower.includes("all patches read") ||
lineLower.includes("done reading")
) {
setState(STATE.ALL_STRIPS_READ);
setPrompt(`🎉 ${line.trim()} — Click 'Done & Save .ti3' to save.`);
} else if (
lineLower.includes("(warning)") ||
lineLower.includes("use it anyway") ||
lineLower.includes("seem to have read strip pass") ||
lineLower.includes("unexpected response") ||
lineLower.includes("hit return to use it anyway")
) { ) {
if (!xyTableDetected) {
xyTableDetected = true;
setXyTableVisible(true);
}
}
if (xyTableDetected) {
if (classified.state === STATE.TABLE_PLACE_SHEET) {
updateXyTableSequence("place");
} else if (classified.state === STATE.TABLE_ALIGN) {
updateXyTableSequence("align");
} else if (classified.state === STATE.READING) {
updateXyTableSequence("scan");
} else if (classified.meta && classified.meta.isRemoveSheetNotice) {
updateXyTableSequence("remove");
}
}
if (classified.state !== currentState) {
setState(classified.state);
}
if (classified.state === STATE.TABLE_PLACE_SHEET) {
const sheetInfo = (classified.meta && classified.meta.sheet && classified.meta.totalSheets)
? ` (Sheet ${classified.meta.sheet} of ${classified.meta.totalSheets})`
: "";
setPrompt(`📋 ${classified.prompt}${sheetInfo}`);
} else if (classified.state === STATE.TABLE_ALIGN) {
const patchInfo = (classified.meta && classified.meta.patch)
? ` [Patch ${classified.meta.patch}]`
: "";
setPrompt(`🎯 ${classified.prompt}${patchInfo}`);
} else if (classified.meta && classified.meta.isRemoveSheetNotice) {
setPrompt(`${classified.prompt}`);
} else if (classified.meta && classified.meta.sheetOk) {
setPrompt(`${classified.prompt}`);
} else if (classified.state === STATE.ALL_STRIPS_READ) {
setPrompt(`🎉 ${classified.prompt} — Click 'Done & Save .ti3' to save.`);
} else if (classified.state === STATE.WARNING) {
const previousPrompt = promptText ? promptText.textContent.trim() : ""; const previousPrompt = promptText ? promptText.textContent.trim() : "";
const lineLower = line.toLowerCase();
const isContinuationPrompt = lineLower.includes("hit return to use it anyway") || lineLower.includes("use it anyway"); const isContinuationPrompt = lineLower.includes("hit return to use it anyway") || lineLower.includes("use it anyway");
setState(STATE.WARNING);
if (currentState === STATE.WARNING && isContinuationPrompt && previousPrompt && !previousPrompt.includes(line.trim())) { if (currentState === STATE.WARNING && isContinuationPrompt && previousPrompt && !previousPrompt.includes(line.trim())) {
setPrompt(`${previousPrompt}\n${line.trim()}`); setPrompt(`${previousPrompt}\n${line.trim()}`);
} else { } else {
setPrompt(line.trim()); setPrompt(classified.prompt);
}
} else if (classified.state === STATE.ERROR) {
setPrompt("⚠️ " + classified.prompt);
} else {
setPrompt(classified.prompt);
} }
} else if (
lineLower.includes("place sheet") ||
lineLower.includes("remove previous sheet") ||
(lineLower.includes("hit return to continue") && !lineLower.includes("use it anyway"))
) {
setState(STATE.PROMPT_CONTINUE);
setPrompt(line.trim());
} else if (
(lineLower.includes("place") && (lineLower.includes("reference") || lineLower.includes("white") || lineLower.includes("calibrat") || lineLower.includes("standard"))) ||
lineLower.includes("hit any key to continue") ||
lineLower.includes("calibration")
) {
setState(STATE.CALIBRATING);
setPrompt(line.trim());
} else if (
(lineLower.includes("hit") && lineLower.includes("read") && lineLower.includes("strip")) ||
lineLower.includes("ready to read") ||
(lineLower.includes("read") && lineLower.includes("strip") && lineLower.includes("key"))
) {
setState(STATE.AWAITING_STRIP);
setPrompt(line.trim());
} else if (lineLower.includes("reading strip") || lineLower.includes("processing")) {
setState(STATE.READING);
setPrompt(line.trim());
} else if (lineLower.includes("error") || lineLower.includes("too fast") || lineLower.includes("too slow") || lineLower.includes("misread") || lineLower.includes("failed to read")) {
setState(STATE.ERROR);
setPrompt("⚠️ " + line.trim());
} }
}); });
let lastStderrLine = "";
const unlistenStderr = await listen("process:stderr", (event) => { const unlistenStderr = await listen("process:stderr", (event) => {
if (event.payload.id === currentProcessId && event.payload.line) { if (event.payload.id === currentProcessId && event.payload.line) {
lastStderrLine = event.payload.line;
logPre.textContent += "ERR: " + event.payload.line + "\n"; logPre.textContent += "ERR: " + event.payload.line + "\n";
logPre.scrollTop = logPre.scrollHeight; logPre.scrollTop = logPre.scrollHeight;
} }
@@ -441,6 +797,9 @@ export function initChartread() {
stopSwatchListener(); stopSwatchListener();
if (event.payload.code === 0) { if (event.payload.code === 0) {
if (xyTableDetected) {
updateXyTableSequence("done");
}
try { try {
const passIndex = currentPassIndex + 1; const passIndex = currentPassIndex + 1;
const filename = await invoke("snapshot_ti3", { const filename = await invoke("snapshot_ti3", {
@@ -471,8 +830,10 @@ export function initChartread() {
} }
} else { } else {
setState(STATE.FINISHED); setState(STATE.FINISHED);
setPrompt(`❌ chartread exited with code ${event.payload.code}.`); if (logContainer) logContainer.open = true;
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`; const errDetail = lastStderrLine ? ` (${lastStderrLine})` : "";
setPrompt(`❌ chartread exited with code ${event.payload.code}.${errDetail}`);
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.${errDetail}\n`;
} }
setMeasurementBusy(false); setMeasurementBusy(false);
@@ -610,8 +971,12 @@ export function initChartread() {
try { try {
btnAccept.disabled = true; btnAccept.disabled = true;
await invoke("send_stdin", { id: currentProcessId, input: "\n" }); await invoke("send_stdin", { id: currentProcessId, input: "\n" });
if (currentState === STATE.TABLE_PLACE_SHEET || currentState === STATE.TABLE_ALIGN) {
setPrompt("Continuing XY table sequence...");
} else {
setState(STATE.READING); setState(STATE.READING);
setPrompt("Accepted. Processing..."); setPrompt("Accepted. Processing...");
}
} catch (e) { } catch (e) {
console.error("send_stdin error:", e); console.error("send_stdin error:", e);
btnAccept.disabled = false; btnAccept.disabled = false;
@@ -677,14 +1042,27 @@ export function initChartread() {
if (btnCancel) { if (btnCancel) {
btnCancel.addEventListener("click", async () => { btnCancel.addEventListener("click", async () => {
try { try {
btnCancel.disabled = true;
if (currentState === STATE.TABLE_PLACE_SHEET || currentState === STATE.TABLE_ALIGN || xyTableDetected) {
// For XY table states, send 'q\n' first to allow the table to park its measurement head gracefully
try {
await invoke("send_stdin", { id: currentProcessId, input: "q\n" });
} catch (_) {}
// Brief pause before kill to allow graceful parking
await new Promise((resolve) => setTimeout(resolve, 500));
}
await invoke("kill_process", { id: currentProcessId }); await invoke("kill_process", { id: currentProcessId });
stopSwatchListener(); stopSwatchListener();
setState(recordedPasses.length > 0 ? STATE.FINISHED : STATE.IDLE); setState(recordedPasses.length > 0 ? STATE.FINISHED : STATE.IDLE);
setPrompt("Measurement cancelled."); setPrompt("Measurement cancelled.");
if (xyTableDetected) {
updateXyTableSequence("standby");
}
} catch (e) { } catch (e) {
console.error("kill_process error:", e); console.error("kill_process error:", e);
setPrompt(`Cancel failed: ${e}`); setPrompt(`Cancel failed: ${e}`);
} finally { } finally {
btnCancel.disabled = false;
setMeasurementBusy(false); setMeasurementBusy(false);
stopSwatchListener(); stopSwatchListener();
} }
+153
View File
@@ -0,0 +1,153 @@
// Unit & console tests for chartread.js stdout classifier and state machine transitions.
// Can be run in browser devtools console:
// import('./chartread.test.js').then(m => m.runAll())
// Or in Node:
// node src/js/chartread.test.js
// Node environment polyfill for browser globals
if (typeof window === 'undefined') {
globalThis.window = {
__TAURI__: {
core: { invoke: () => Promise.resolve() },
event: {
listen: () => Promise.resolve(() => {}),
emit: () => Promise.resolve(),
},
},
addEventListener: () => {},
dispatchEvent: () => {},
};
globalThis.document = {
getElementById: () => null,
querySelectorAll: () => [],
createElement: () => ({
classList: { add: () => {}, remove: () => {} },
style: {},
appendChild: () => {},
}),
};
}
const { STATE, classifyChartreadLine } = await import('./chartread.js');
export function runAll() {
console.group('Chartread Classifier & XY Table Tests');
let passed = 0;
let total = 0;
function assert(actual, expected, message) {
total++;
const ok = JSON.stringify(actual) === JSON.stringify(expected);
if (ok) {
console.log('PASS:', message);
passed++;
} else {
console.error('FAIL:', message, '\nExpected:', expected, '\nGot:', actual);
}
return ok;
}
// 1. XY Sheet Placement prompts
const place1 = classifyChartreadLine("Please place sheet 1 of 1 on the table", STATE.CALIBRATING);
assert(place1.state, STATE.TABLE_PLACE_SHEET, 'sheet 1 of 1 state TABLE_PLACE_SHEET');
assert(place1.matched, true, 'sheet 1 of 1 matched');
assert(place1.meta?.sheet, 1, 'sheet 1 of 1 sheet number 1');
assert(place1.meta?.totalSheets, 1, 'sheet 1 of 1 total sheets 1');
const place2 = classifyChartreadLine("Please remove previous sheet and place sheet 2 of 2 on the table", STATE.READING);
assert(place2.state, STATE.TABLE_PLACE_SHEET, 'sheet 2 of 2 state TABLE_PLACE_SHEET');
assert(place2.meta?.sheet, 2, 'sheet 2 of 2 sheet number 2');
assert(place2.meta?.totalSheets, 2, 'sheet 2 of 2 total sheets 2');
const placeGeneric = classifyChartreadLine("place sheet on table", STATE.IDLE);
assert(placeGeneric.state, STATE.TABLE_PLACE_SHEET, 'generic place sheet state TABLE_PLACE_SHEET');
// 2. XY Fiducial patch alignment prompts
const fid1 = classifyChartreadLine("locate patch A1 with the sight,", STATE.TABLE_PLACE_SHEET);
assert(fid1.state, STATE.TABLE_ALIGN, 'locate patch A1 state TABLE_ALIGN');
assert(fid1.matched, true, 'locate patch A1 matched');
assert(fid1.meta?.patch, "A1", 'locate patch A1 meta patch');
const fid2 = classifyChartreadLine("locate patch B24 with the sight", STATE.TABLE_ALIGN);
assert(fid2.state, STATE.TABLE_ALIGN, 'locate patch B24 state TABLE_ALIGN');
assert(fid2.meta?.patch, "B24", 'locate patch B24 meta patch');
const fid3 = classifyChartreadLine("locate patch 1 with sight", STATE.TABLE_ALIGN);
assert(fid3.state, STATE.TABLE_ALIGN, 'locate patch 1 state TABLE_ALIGN');
assert(fid3.meta?.patch, "1", 'locate patch 1 meta patch');
// 3. Two-line prompt sticky transitions
// A continuation line arriving while in TABLE_PLACE_SHEET must remain in TABLE_PLACE_SHEET
const contSheet1 = classifyChartreadLine("hit return to continue, Esc or 'q' to give up", STATE.TABLE_PLACE_SHEET);
assert(contSheet1.state, STATE.TABLE_PLACE_SHEET, 'sheet continuation remains in TABLE_PLACE_SHEET');
assert(contSheet1.meta?.isContinuation, true, 'sheet continuation flag set');
const contSheet2 = classifyChartreadLine("then hit return to continue", STATE.TABLE_PLACE_SHEET);
assert(contSheet2.state, STATE.TABLE_PLACE_SHEET, 'then hit return remains in TABLE_PLACE_SHEET');
// A continuation line arriving while in TABLE_ALIGN must remain in TABLE_ALIGN
const contAlign1 = classifyChartreadLine("then hit return to continue", STATE.TABLE_ALIGN);
assert(contAlign1.state, STATE.TABLE_ALIGN, 'align continuation remains in TABLE_ALIGN');
assert(contAlign1.meta?.isContinuation, true, 'align continuation flag set');
const contAlign2 = classifyChartreadLine("hit return to continue, Esc or 'q' to give up", STATE.TABLE_ALIGN);
assert(contAlign2.state, STATE.TABLE_ALIGN, 'align esc/q continuation remains in TABLE_ALIGN');
// Continuation prompt in strip mode becomes generic PROMPT_CONTINUE
const contStrip = classifyChartreadLine("hit return to continue", STATE.READING);
assert(contStrip.state, STATE.PROMPT_CONTINUE, 'strip continue transitions to PROMPT_CONTINUE');
// 4. Final sheet removal notice (Info-only, does not change state or trigger stdin prompt)
const removeSheet = classifyChartreadLine("Please remove last sheet from table", STATE.READING);
assert(removeSheet.state, STATE.READING, 'remove last sheet notice preserves currentState');
assert(removeSheet.matched, true, 'remove last sheet notice is matched');
assert(removeSheet.meta?.isRemoveSheetNotice, true, 'remove last sheet notice flag set');
// 5. Sheet read OK notices
const sheetOk = classifyChartreadLine("Sheet 1 of 1 read OK", STATE.READING);
assert(sheetOk.matched, true, 'sheet read OK matched');
assert(sheetOk.meta?.sheetOk, true, 'sheet read OK flag');
assert(sheetOk.meta?.sheet, 1, 'sheet read OK sheet 1');
assert(sheetOk.meta?.totalSheets, 1, 'sheet read OK totalSheets 1');
// 6. Strip mode regressions
const calib = classifyChartreadLine("Place instrument on calibration tile and hit [Space] to calibrate.", STATE.IDLE);
assert(calib.state, STATE.CALIBRATING, 'strip calibration prompt transitions to CALIBRATING');
const awaitStrip = classifyChartreadLine("Hit [Space] to read strip A (or 's' to skip).", STATE.CALIBRATING);
assert(awaitStrip.state, STATE.AWAITING_STRIP, 'strip prompt transitions to AWAITING_STRIP');
const readingStrip = classifyChartreadLine("Reading strip A...", STATE.AWAITING_STRIP);
assert(readingStrip.state, STATE.READING, 'reading strip transitions to READING');
const readingSheet = classifyChartreadLine("Reading sheet 1...", STATE.TABLE_ALIGN);
assert(readingSheet.state, STATE.READING, 'reading sheet transitions to READING');
const warning = classifyChartreadLine("Warning: unexpected response from instrument", STATE.READING);
assert(warning.state, STATE.WARNING, 'unexpected response transitions to WARNING');
const warnAnyway = classifyChartreadLine("Hit return to use it anyway", STATE.WARNING);
assert(warnAnyway.state, STATE.WARNING, 'use it anyway stays in WARNING');
const allStripsDone = classifyChartreadLine("All strips read. Hit 'd' when done", STATE.READING);
assert(allStripsDone.state, STATE.ALL_STRIPS_READ, 'all strips read transitions to ALL_STRIPS_READ');
const err = classifyChartreadLine("Fatal error: instrument communication failed", STATE.READING);
assert(err.state, STATE.ERROR, 'instrument communication failed transitions to ERROR');
const unrecognized = classifyChartreadLine("some debug log output [1234]", STATE.READING);
assert(unrecognized.state, STATE.READING, 'unrecognized line preserves currentState');
assert(unrecognized.matched, false, 'unrecognized line matched is false');
console.log(`\nResults: ${passed} / ${total} tests passed.`);
console.groupEnd();
if (passed !== total) {
throw new Error(`Chartread tests failed: ${total - passed} failure(s)`);
}
}
// Auto-run if executed in Node.js
if (typeof process !== 'undefined' && process.argv && process.argv[1]?.endsWith('chartread.test.js')) {
runAll();
}
+173 -15
View File
@@ -204,30 +204,80 @@ function _line(from, to, material) {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Parse .gam file text → { vertices: [[L,a,b],...], faces: [[v0,v1,v2],...] } /**
* Parse an Argyll `.gam` text file into vertex and face arrays.
*
* Argyll `.gam` files contain a header followed by one or more `BEGIN_DATA`
* ... `END_DATA` blocks. The first data block is a vertex list
* (index L a b); subsequent blocks contain triangle face indices (v0 v1 v2).
* Blank lines and hash `#` comments outside data blocks are ignored.
*
* @param {string} text - Raw contents of the .gam file.
* @returns {{ vertices: number[][], faces: number[][], warnings: string[] }}
*/
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export function parseGamutFile(text) { export function parseGamutFile(text) {
const lines = text.split('\n'); const lines = text.split('\n');
const vertices = []; const vertices = [];
const faces = []; const faces = [];
const warnings = [];
let dataStarted = false; let dataStarted = false;
let dataBlock = 0; // 1 = vertices section, 2 = faces section let dataBlock = 0; // 1 = vertices section, 2+ = faces sections
for (const line of lines) { for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
const trimmed = line.trim(); const raw = lines[lineIdx];
if (trimmed === 'BEGIN_DATA') { dataBlock++; dataStarted = true; continue; } const trimmed = raw.replace(/#.*$/, '').trim(); // strip inline comments
if (trimmed === 'END_DATA') { dataStarted = false; continue; } if (trimmed === '') continue;
if (trimmed.toUpperCase() === 'BEGIN_DATA') {
dataBlock++;
dataStarted = true;
continue;
}
if (trimmed.toUpperCase() === 'END_DATA') {
dataStarted = false;
continue;
}
if (!dataStarted) continue; if (!dataStarted) continue;
const parts = trimmed.split(/\s+/).map(Number); const parts = trimmed.split(/\s+/).map(Number);
if (dataBlock === 1 && parts.length >= 4) { const allNumeric = parts.every(n => !Number.isNaN(n));
vertices.push([parts[1], parts[2], parts[3]]); // [L, a, b] if (!allNumeric) {
} else if (dataBlock === 2 && parts.length >= 3) { warnings.push(`Skipping non-numeric data at line ${lineIdx + 1}`);
continue;
}
if (dataBlock === 1) {
if (parts.length >= 4) {
// Vertex format: index L a b (index is usually ignored)
const [_, L, a, b] = parts;
if (L < 0 || L > 100 || Math.abs(a) > 128 || Math.abs(b) > 128) {
warnings.push(`Vertex at line ${lineIdx + 1} is outside plausible CIELAB bounds: L=${L}, a=${a}, b=${b}`);
}
vertices.push([L, a, b]);
} else {
warnings.push(`Vertex data at line ${lineIdx + 1} has only ${parts.length} values`);
}
} else {
// Face data can appear in multiple blocks (some .gam files use a
// separate DATA block per surface type or per convex-hull section).
if (parts.length >= 3) {
faces.push([parts[0], parts[1], parts[2]]); faces.push([parts[0], parts[1], parts[2]]);
} else {
warnings.push(`Face data at line ${lineIdx + 1} has only ${parts.length} values`);
}
} }
} }
return { vertices, faces }; if (vertices.length > 0 && faces.length === 0) {
warnings.push(`Parsed ${vertices.length} vertices but no faces; will compute convex hull on the fly.`);
}
if (dataBlock === 0) {
warnings.push('No BEGIN_DATA blocks found; file may be empty or not a valid Argyll .gam file.');
}
return { vertices, faces, warnings };
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -304,7 +354,10 @@ function _renderSrgbReference(text, previousGroup) {
}); });
} }
const { vertices, faces } = parseGamutFile(text); const { vertices, faces, warnings } = parseGamutFile(text);
if (warnings.length > 0) {
console.warn('Gamut parser warnings:', warnings.join('\n'));
}
const built = _buildGeometry(vertices, faces); const built = _buildGeometry(vertices, faces);
if (!built) return null; if (!built) return null;
@@ -348,7 +401,10 @@ function _renderProfileGamut(text, previousMesh) {
if (previousMesh.material) previousMesh.material.dispose(); if (previousMesh.material) previousMesh.material.dispose();
} }
const { vertices, faces } = parseGamutFile(text); const { vertices, faces, warnings } = parseGamutFile(text);
if (warnings.length > 0) {
console.warn('Gamut parser warnings:', warnings.join('\n'));
}
const built = _buildGeometry(vertices, faces); const built = _buildGeometry(vertices, faces);
if (!built) return null; if (!built) return null;
@@ -383,7 +439,77 @@ function _renderProfileGamut(text, previousMesh) {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Public: load bundled sRGB reference gamut // Camera & view controls
// ─────────────────────────────────────────────────────────────────────────────
/**
* Reset the camera to its default home position and orientation.
* Also resets orbit controls target to the centre of the CIELAB volume.
*/
export function resetCamera() {
if (!camera || !controls) return;
camera.position.set(180, 120, 180);
camera.lookAt(0, 50, 0);
controls.target.set(0, 50, 0);
controls.update();
}
/**
* Set the opacity of the rendered profile gamut surface (01).
* @param {number} opacity
*/
export function setProfileOpacity(opacity) {
if (currentProfileMesh && currentProfileMesh.material) {
currentProfileMesh.material.opacity = Math.max(0, Math.min(1, opacity));
currentProfileMesh.material.transparent = opacity < 1;
currentProfileMesh.material.needsUpdate = true;
}
}
/**
* Set the opacity of the sRGB reference wireframe and fill (01).
* @param {number} opacity
*/
export function setSrgbReferenceOpacity(opacity) {
if (sRgbGroup) {
sRgbGroup.traverse((child) => {
if (child.material) {
child.material.opacity = child.material.opacity >= 0.5
? Math.max(0.05, Math.min(1, opacity))
: Math.max(0.02, Math.min(0.2, opacity * 0.2));
}
});
}
}
/**
* Set the opacity of the CIELAB axis scaffold (01).
* @param {number} opacity
*/
export function setAxisOpacity(opacity) {
if (axisScaffoldGroup) {
axisScaffoldGroup.traverse((child) => {
if (child.material) {
child.material.opacity = Math.max(0, Math.min(1, opacity));
}
});
}
}
/**
* Handle keyboard shortcuts for the gamut viewer.
* @param {KeyboardEvent} e
*/
function _onKeyDown(e) {
if (e.key === 'r' || e.key === 'R') {
resetCamera();
}
}
// ─────────────────────────────────────────────────────────────────────────────
/**
* Load and render the bundled sRGB reference gamut.
* @returns {Promise<void>}
*/
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export async function loadSrgbReferenceGamut() { export async function loadSrgbReferenceGamut() {
try { try {
@@ -398,7 +524,11 @@ export async function loadSrgbReferenceGamut() {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Public: load and render a profile gamut from a .gam file path /**
* Load and render a profile gamut from a `.gam` file path.
* @param {string} gamFilePath - Absolute path to the gamut file.
* @returns {Promise<THREE.Mesh|null>}
*/
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export async function loadGamutMesh(gamFilePath) { export async function loadGamutMesh(gamFilePath) {
try { try {
@@ -428,7 +558,7 @@ export function toggleAxes(visible) {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Wire legend toggle checkboxes to the toggle functions // Wire legend toggle checkboxes, opacity sliders, and reset button
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
function _wireToggles() { function _wireToggles() {
const bindings = [ const bindings = [
@@ -440,4 +570,32 @@ function _wireToggles() {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.addEventListener('change', (e) => fn(e.target.checked)); if (el) el.addEventListener('change', (e) => fn(e.target.checked));
} }
const resetBtn = document.getElementById('btnGamutResetCamera');
if (resetBtn) resetBtn.addEventListener('click', resetCamera);
const profileOpacity = document.getElementById('rngProfileOpacity');
if (profileOpacity) {
profileOpacity.addEventListener('input', (e) => setProfileOpacity(parseFloat(e.target.value)));
}
const srgbOpacity = document.getElementById('rngSrgbOpacity');
if (srgbOpacity) {
srgbOpacity.addEventListener('input', (e) => setSrgbReferenceOpacity(parseFloat(e.target.value)));
}
const axisOpacity = document.getElementById('rngAxisOpacity');
if (axisOpacity) {
axisOpacity.addEventListener('input', (e) => setAxisOpacity(parseFloat(e.target.value)));
}
// Only listen for 'R' reset when the viewer tab is active.
const stage5 = document.getElementById('stage-5');
if (stage5) {
stage5.addEventListener('keydown', (e) => {
if ((e.key === 'r' || e.key === 'R') && !stage5.classList.contains('hidden')) {
resetCamera();
}
});
}
} }
+80
View File
@@ -0,0 +1,80 @@
// Manual / browser-console tests for gamut_viewer.js and profcheck.js parsing.
// Run in a browser/devtools console after the app has loaded:
// import('./gamut_viewer.test.js').then(m => m.runAll())
import { parseGamutFile } from './gamut_viewer.js';
export function runAll() {
console.group('gamut/profcheck parser tests');
testParseGamutBasic();
testParseGamutDualTable();
testParseGamutWithComments();
console.groupEnd();
}
function assertEqual(actual, expected, message) {
const ok = JSON.stringify(actual) === JSON.stringify(expected);
if (ok) {
console.log('PASS:', message);
} else {
console.error('FAIL:', message, 'expected', expected, 'got', actual);
}
return ok;
}
function testParseGamutBasic() {
const text = `GAMUT file
BEGIN_DATA
0 50 0 0
1 100 0 0
2 0 -128 0
3 0 0 128
END_DATA
BEGIN_DATA
0 1 2
1 2 3
END_DATA`;
const { vertices, faces, warnings } = parseGamutFile(text);
assertEqual(vertices.length, 4, 'basic gamut vertex count');
assertEqual(faces.length, 2, 'basic gamut face count');
assertEqual(warnings.length, 0, 'basic gamut no warnings');
}
function testParseGamutDualTable() {
const text = `GAMUT file
BEGIN_DATA
0 50 0 0
1 100 0 0
2 0 -128 0
3 0 0 128
END_DATA
BEGIN_DATA
0 1 2
END_DATA
BEGIN_DATA
1 2 3
END_DATA`;
const { vertices, faces, warnings } = parseGamutFile(text);
assertEqual(vertices.length, 4, 'dual-table gamut vertex count');
assertEqual(faces.length, 2, 'dual-table gamut face count');
assertEqual(warnings.length, 0, 'dual-table gamut no warnings');
}
function testParseGamutWithComments() {
const text = `GAMUT file
# this is a comment
BEGIN_DATA
0 50 0 0
1 100 0 0
# inline comment
2 0 -128 0
3 0 0 128
END_DATA
BEGIN_DATA
0 1 2
1 2 3
END_DATA`;
const { vertices, faces, warnings } = parseGamutFile(text);
assertEqual(vertices.length, 4, 'commented gamut vertex count');
assertEqual(faces.length, 2, 'commented gamut face count');
}
+2
View File
@@ -574,6 +574,7 @@ export function initPrinttarg() {
showNotification("error", "Please select a destination printer first."); showNotification("error", "Please select a destination printer first.");
return; return;
} }
wizardState.printerName = printerName;
const options = getSelectedPrintOptions(); const options = getSelectedPrintOptions();
const origBtnContent = triggeringButton ? triggeringButton.innerHTML : ""; const origBtnContent = triggeringButton ? triggeringButton.innerHTML : "";
@@ -619,6 +620,7 @@ export function initPrinttarg() {
showNotification("error", "Please select a destination printer first."); showNotification("error", "Please select a destination printer first.");
return; return;
} }
wizardState.printerName = printerName;
const options = getSelectedPrintOptions(); const options = getSelectedPrintOptions();
const cwd = stage1Cwd; const cwd = stage1Cwd;
+533 -52
View File
@@ -14,6 +14,423 @@ export function setStage4Result(basename, cwd) {
profileBasename = basename || wizardState.basename; profileBasename = basename || wizardState.basename;
profileCwd = cwd || wizardState.cwd; profileCwd = cwd || wizardState.cwd;
wizardState.setTarget(profileBasename, profileCwd); wizardState.setTarget(profileBasename, profileCwd);
loadVerificationHistory();
}
/**
* Parse profcheck output for Average, Peak, and RMS delta-E values and patch count.
* Supports Argyll's -u JSON summary object, text summary line, and legacy plain-text output.
* @param {string} stdout - Full profcheck stdout.
* @returns {{ avgDe: number, maxDe: number, rmsDe: number, patchCount: number, warnings: string[] }}
*/
export function parseProfcheckReport(stdout) {
let avgDe = 0.0;
let maxDe = 0.0;
let rmsDe = 0.0;
let patchCount = 0;
const warnings = [];
if (!stdout || typeof stdout !== 'string') {
return { avgDe, maxDe, rmsDe, patchCount, warnings: ['No stdout received from profcheck.'] };
}
// Parse patch count from "No of test patches = (\d+)"
const patchMatch = stdout.match(/No\s+of\s+test\s+patches\s*=\s*(\d+)/i);
if (patchMatch) {
patchCount = parseInt(patchMatch[1], 10);
}
// Argyll's JSON output can appear either as a compact object on a single
// line or embedded inside larger text. Accept objects with event === "report"
// or containing any of avg_de, avg_de2000, peak_de, peak_de2000, rms, rms_de.
const jsonObjects = [];
const re = /\{[\s\S]*?\}/g;
let m;
while ((m = re.exec(stdout)) !== null) {
try {
const parsed = JSON.parse(m[0]);
if (typeof parsed === 'object' && parsed !== null) {
if (
parsed.event === 'report' ||
'avg_de' in parsed ||
'avg_de2000' in parsed ||
'peak_de' in parsed ||
'peak_de2000' in parsed ||
'rms' in parsed ||
'rms_de' in parsed
) {
jsonObjects.push(parsed);
}
}
} catch (e) {
// Not a valid JSON object, ignore.
}
}
if (jsonObjects.length > 0) {
// When several report objects exist (de2000, de94, de), prefer *de2000 object matching -k
const de2000Obj = jsonObjects.find(o => 'avg_de2000' in o || 'peak_de2000' in o);
const targetJson = de2000Obj || jsonObjects[jsonObjects.length - 1];
avgDe = typeof targetJson.avg_de2000 === 'number' ? targetJson.avg_de2000 :
(typeof targetJson.avg_de === 'number' ? targetJson.avg_de : 0);
maxDe = typeof targetJson.peak_de2000 === 'number' ? targetJson.peak_de2000 :
(typeof targetJson.max_de === 'number' ? targetJson.max_de :
(typeof targetJson.peak_de === 'number' ? targetJson.peak_de : 0));
rmsDe = typeof targetJson.rms === 'number' ? targetJson.rms :
(typeof targetJson.rms_de === 'number' ? targetJson.rms_de : 0);
} else {
// Check for standard Argyll text summary line:
// Profile check complete, errors...: max. = %f, avg. = %f, RMS = %f
const summaryMatch = stdout.match(/Profile check complete,\s*errors[^\:]*:\s*max\.\s*=\s*([\d\.]+),\s*avg\.\s*=\s*([\d\.]+),\s*RMS\s*=\s*([\d\.]+)/i);
if (summaryMatch) {
maxDe = parseFloat(summaryMatch[1]);
avgDe = parseFloat(summaryMatch[2]);
rmsDe = parseFloat(summaryMatch[3]);
} else {
// Regex fallbacks for standard profcheck text output
const avgPatterns = [
/avg(?:\.?|erage)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
/average\s+(?:dE\s*)?([\d\.]+)/i,
/mean\s+(?:dE\s*)?([\d\.]+)/i,
/dE\s+average[^\d]*([\d\.]+)/i,
];
const maxPatterns = [
/max(?:\.?|imum)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
/peak\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
/worst\s*(?:dE\s*)?([\d\.]+)/i,
/dE\s+max[^\d]*([\d\.]+)/i,
];
const rmsPatterns = [
/RMS(?:\.?)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
/rms(?:\.?)\s*(?:dE\s*)?([\d\.]+)/i,
/root\s+mean\s+sq(?:uare)?\s*(?:dE\s*)?([\d\.]+)/i,
];
const find = (patterns) => {
for (const p of patterns) {
const match = stdout.match(p);
if (match) return match;
}
return null;
};
const avgMatch = find(avgPatterns);
const maxMatch = find(maxPatterns);
const rmsMatch = find(rmsPatterns);
if (avgMatch) avgDe = parseFloat(avgMatch[1]);
else warnings.push('Could not detect Average ΔE in profcheck output.');
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
else warnings.push('Could not detect Peak ΔE in profcheck output.');
if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]);
else warnings.push('Could not detect RMS ΔE in profcheck output.');
if (!avgMatch && !maxMatch && !rmsMatch) {
warnings.push('No delta-E values were found in profcheck output.');
}
}
}
return { avgDe, maxDe, rmsDe, patchCount, warnings };
}
/**
* Checks for a printer drift breach condition:
* Returns an alert string if the last >=2 consecutive records have avg_de >= 3.5
* and span distinct calendar dates (or are >= 1 hour apart).
* @param {Array<object>} records - Array of verification records sorted ascending by timestamp.
* @returns {string|null} Alert text or null if no breach.
*/
export function checkBreachAlert(records) {
if (!records || records.length < 2) return null;
let count = 0;
let firstBreach = null;
let latestBreach = null;
for (let i = records.length - 1; i >= 0; i--) {
if (records[i].avg_de >= 3.5) {
count++;
if (!latestBreach) latestBreach = records[i];
firstBreach = records[i];
} else {
break;
}
}
if (count >= 2 && firstBreach && latestBreach) {
const tFirst = new Date(firstBreach.timestamp).getTime();
const tLatest = new Date(latestBreach.timestamp).getTime();
const diffHours = (tLatest - tFirst) / (1000 * 60 * 60);
const dFirst = firstBreach.timestamp.slice(0, 10);
const dLatest = latestBreach.timestamp.slice(0, 10);
if (dFirst !== dLatest || diffHours >= 1.0) {
const fmt = (ts) => {
try {
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} catch (_) {
return ts;
}
};
return `⚠️ Re-profiling Recommended — ${count} consecutive verifications out of tolerance (first: ${fmt(firstBreach.timestamp)}, latest: ${fmt(latestBreach.timestamp)})`;
}
}
return null;
}
/**
* Loads verification history records and updates the Stage 5 drift analytics UI.
*/
export async function loadVerificationHistory(selectedPrinter = "") {
const profileName = profileBasename || wizardState.basename || "";
const driftSection = document.getElementById("driftHistorySection");
if (!driftSection) return;
const alertCard = document.getElementById("driftAlertCard");
const alertText = document.getElementById("driftAlertText");
const emptyState = document.getElementById("driftEmptyState");
const chartWrap = document.getElementById("driftChartWrap");
const chartSvg = document.getElementById("driftTrendChart");
const tbody = document.getElementById("verificationHistoryTbody");
const btnExport = document.getElementById("btnExportHistoryCsv");
const btnClear = document.getElementById("btnClearHistory");
const printerFilterSelect = document.getElementById("driftPrinterFilter");
const filterRow = document.getElementById("driftFilterRow");
try {
// 1. Fetch all records for current profile to populate printer options
const allProfileRecords = await invoke("get_verification_history", {
profileName: profileName || null,
printerName: null,
});
if (printerFilterSelect) {
const distinctPrinters = Array.from(new Set(allProfileRecords.map(r => r.printer_name).filter(Boolean)));
printerFilterSelect.innerHTML = `<option value="">All Printers</option>`;
distinctPrinters.forEach(p => {
const opt = document.createElement("option");
opt.value = p;
opt.textContent = p;
if (p === selectedPrinter) opt.selected = true;
printerFilterSelect.appendChild(opt);
});
if (filterRow) {
filterRow.classList.toggle("hidden", distinctPrinters.length <= 1);
}
}
// 2. Fetch filtered records
const records = await invoke("get_verification_history", {
profileName: profileName || null,
printerName: selectedPrinter || null,
});
// 3. Breach Alert check (evaluated against profile records chronologically)
const breachMessage = checkBreachAlert(records);
if (alertCard && alertText) {
if (breachMessage) {
alertText.textContent = breachMessage;
alertCard.classList.remove("hidden");
} else {
alertCard.classList.add("hidden");
}
}
const hasRecords = records.length > 0;
if (btnExport) btnExport.disabled = !hasRecords;
if (btnClear) btnClear.disabled = allProfileRecords.length === 0;
if (!hasRecords) {
if (emptyState) emptyState.classList.remove("hidden");
if (chartWrap) chartWrap.classList.add("hidden");
if (tbody) tbody.innerHTML = "";
return;
}
if (emptyState) emptyState.classList.add("hidden");
if (chartWrap) chartWrap.classList.remove("hidden");
// 4. Render Hand-rolled SVG Trend Chart
if (chartSvg) {
renderDriftTrendChart(chartSvg, records);
}
// 5. Render Run-Log Table (newest first)
if (tbody) {
tbody.innerHTML = "";
const reversed = [...records].reverse();
reversed.forEach(r => {
const tr = document.createElement("tr");
const tdDate = document.createElement("td");
try {
tdDate.textContent = new Date(r.timestamp).toLocaleString();
} catch (_) {
tdDate.textContent = r.timestamp;
}
const tdPrinter = document.createElement("td");
tdPrinter.textContent = r.printer_name || "Unknown";
const tdAvg = document.createElement("td");
tdAvg.textContent = r.avg_de.toFixed(2);
const tdMax = document.createElement("td");
tdMax.textContent = r.max_de.toFixed(2);
const tdRms = document.createElement("td");
tdRms.textContent = r.rms_de.toFixed(2);
const tdPatches = document.createElement("td");
tdPatches.textContent = r.patch_count || "-";
const tdStatus = document.createElement("td");
const badge = document.createElement("span");
const statusClass = r.status === 'excellent' ? 'badge-excellent' :
r.status === 'good' ? 'badge-good' :
r.status === 'acceptable' ? 'badge-acceptable' : 'badge-poor';
badge.className = `status-badge ${statusClass}`;
badge.textContent = r.status ? r.status.toUpperCase() : "UNKNOWN";
tdStatus.appendChild(badge);
tr.appendChild(tdDate);
tr.appendChild(tdPrinter);
tr.appendChild(tdAvg);
tr.appendChild(tdMax);
tr.appendChild(tdRms);
tr.appendChild(tdPatches);
tr.appendChild(tdStatus);
tbody.appendChild(tr);
});
}
} catch (e) {
logger.warn(`Failed to load verification history: ${e}`, 'Stage5-Profcheck');
}
}
/**
* Hand-rolled SVG line chart rendering verification drift trends with ICCery threshold bands.
*/
function renderDriftTrendChart(svg, records) {
// Downsample to the last 50 points if necessary
const data = records.length > 50 ? records.slice(records.length - 50) : records;
const width = 640;
const height = 220;
const padLeft = 45;
const padRight = 30;
const padTop = 20;
const padBottom = 28;
const plotW = width - padLeft - padRight;
const plotH = height - padTop - padBottom;
// Compute max Y (minimum 4.0 for all threshold bands)
let maxY = 4.0;
data.forEach(d => {
if (d.max_de > maxY) maxY = d.max_de;
if (d.avg_de > maxY) maxY = d.avg_de;
});
maxY = Math.ceil(maxY * 1.1);
const getY = (val) => padTop + plotH - (val / maxY) * plotH;
const getX = (idx) => {
if (data.length <= 1) return padLeft + plotW / 2;
return padLeft + (idx / (data.length - 1)) * plotW;
};
let elements = [];
// Threshold background bands
// Bands at: 0-1.0 (Excellent), 1.0-2.0 (Good), 2.0-3.5 (Acceptable), 3.5-maxY (Warning)
const bands = [
{ from: 0.0, to: 1.0, color: "rgba(34, 197, 94, 0.08)", label: "Excellent (< 1.0)" },
{ from: 1.0, to: 2.0, color: "rgba(59, 130, 246, 0.08)", label: "Good (< 2.0)" },
{ from: 2.0, to: 3.5, color: "rgba(245, 158, 11, 0.08)", label: "Acceptable (< 3.5)" },
{ from: 3.5, to: maxY, color: "rgba(239, 68, 68, 0.08)", label: "Warning (≥ 3.5)" },
];
bands.forEach(b => {
const yTop = getY(Math.min(b.to, maxY));
const yBot = getY(b.from);
const bandH = Math.max(0, yBot - yTop);
elements.push(`<rect x="${padLeft}" y="${yTop}" width="${plotW}" height="${bandH}" fill="${b.color}" />`);
});
// Threshold lines
[1.0, 2.0, 3.5].forEach(thresh => {
if (thresh <= maxY) {
const y = getY(thresh);
elements.push(`<line x1="${padLeft}" y1="${y}" x2="${padLeft + plotW}" y2="${y}" stroke="rgba(255,255,255,0.15)" stroke-dasharray="3,3" />`);
elements.push(`<text x="${padLeft + plotW - 4}" y="${y - 3}" class="drift-band-label">${thresh.toFixed(1)} ΔE</text>`);
}
});
// Verification bands caption
elements.push(`<text x="${padLeft + 6}" y="${padTop + 12}" fill="rgba(255,255,255,0.35)" font-size="9px" font-family="sans-serif">ICCery verification bands</text>`);
// Y Axis ticks
const ySteps = [0, 1, 2, 3.5];
if (maxY > 5) ySteps.push(Math.floor(maxY));
ySteps.forEach(val => {
const y = getY(val);
elements.push(`<line x1="${padLeft - 4}" y1="${y}" x2="${padLeft}" y2="${y}" stroke="rgba(255,255,255,0.3)" />`);
elements.push(`<text x="${padLeft - 8}" y="${y + 3}" text-anchor="end" class="drift-chart-text">${val.toFixed(1)}</text>`);
});
// X Axis baseline
elements.push(`<line x1="${padLeft}" y1="${padTop + plotH}" x2="${padLeft + plotW}" y2="${padTop + plotH}" stroke="rgba(255,255,255,0.3)" />`);
// Series points & paths
if (data.length === 1) {
const x = getX(0);
const yAvg = getY(data[0].avg_de);
const yMax = getY(data[0].max_de);
// Dashed horizontal line across plot for single point
elements.push(`<line x1="${padLeft}" y1="${yAvg}" x2="${padLeft + plotW}" y2="${yAvg}" stroke="#3b82f6" stroke-dasharray="4,4" stroke-opacity="0.5" />`);
elements.push(`<circle cx="${x}" cy="${yAvg}" r="5" class="drift-dot-avg"><title>Avg ΔE: ${data[0].avg_de.toFixed(2)} (${data[0].timestamp})</title></circle>`);
elements.push(`<circle cx="${x}" cy="${yMax}" r="4" class="drift-dot-max"><title>Peak ΔE: ${data[0].max_de.toFixed(2)}</title></circle>`);
} else {
// Polylines
let ptsAvg = [];
let ptsMax = [];
data.forEach((d, idx) => {
const x = getX(idx);
const yA = getY(d.avg_de);
const yM = getY(d.max_de);
ptsAvg.push(`${x.toFixed(1)},${yA.toFixed(1)}`);
ptsMax.push(`${x.toFixed(1)},${yM.toFixed(1)}`);
});
elements.push(`<polyline points="${ptsMax.join(' ')}" class="drift-line-max" />`);
elements.push(`<polyline points="${ptsAvg.join(' ')}" class="drift-line-avg" />`);
// Draw dots
data.forEach((d, idx) => {
const x = getX(idx);
const yA = getY(d.avg_de);
const yM = getY(d.max_de);
const dateStr = d.timestamp.slice(0, 10);
elements.push(`<circle cx="${x.toFixed(1)}" cy="${yA.toFixed(1)}" r="4" class="drift-dot-avg"><title>Avg: ${d.avg_de.toFixed(2)} (${dateStr})</title></circle>`);
elements.push(`<circle cx="${x.toFixed(1)}" cy="${yM.toFixed(1)}" r="3" class="drift-dot-max"><title>Peak: ${d.max_de.toFixed(2)} (${dateStr})</title></circle>`);
});
// Start and End date labels on X axis
const startStr = data[0].timestamp.slice(5, 10);
const endStr = data[data.length - 1].timestamp.slice(5, 10);
elements.push(`<text x="${padLeft}" y="${height - 8}" text-anchor="start" class="drift-chart-text">${startStr}</text>`);
elements.push(`<text x="${padLeft + plotW}" y="${height - 8}" text-anchor="end" class="drift-chart-text">${endStr}</text>`);
}
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.innerHTML = elements.join("\n");
} }
export function initProfcheck() { export function initProfcheck() {
@@ -25,9 +442,68 @@ export function initProfcheck() {
const maxDeEl = document.getElementById("profcheckMaxDe"); const maxDeEl = document.getElementById("profcheckMaxDe");
const rmsDeEl = document.getElementById("profcheckRmsDe"); const rmsDeEl = document.getElementById("profcheckRmsDe");
const badgeEl = document.getElementById("profcheckBadge"); const badgeEl = document.getElementById("profcheckBadge");
const btnExport = document.getElementById("btnExportHistoryCsv");
const btnClear = document.getElementById("btnClearHistory");
const printerFilterSelect = document.getElementById("driftPrinterFilter");
if (!btnVerify) return; if (!btnVerify) return;
// Listen for printer filter changes in drift history
if (printerFilterSelect) {
printerFilterSelect.addEventListener("change", () => {
loadVerificationHistory(printerFilterSelect.value);
});
}
// Export CSV button handler
if (btnExport) {
btnExport.addEventListener("click", async () => {
try {
const profileName = profileBasename || wizardState.basename || "verification";
const defaultName = `${profileName}_history.csv`;
const chosenPath = await invoke("select_csv_save_path", { defaultName });
if (chosenPath) {
const printerFilter = printerFilterSelect ? printerFilterSelect.value : "";
const count = await invoke("export_verification_history_csv", {
destPath: chosenPath,
profileName: profileName || null,
printerName: printerFilter || null,
});
wizardState.showNotice(`✓ Exported ${count} verification records to ${chosenPath}`, "success");
}
} catch (err) {
logger.error(`CSV Export failed: ${err}`, 'Stage5-Profcheck');
wizardState.showNotice(`Failed to export CSV: ${err}`, "error");
}
});
}
// Clear History button handler
if (btnClear) {
btnClear.addEventListener("click", async () => {
if (confirm("Are you sure you want to clear all verification history records? This cannot be undone.")) {
try {
await invoke("clear_verification_history");
await loadVerificationHistory();
wizardState.showNotice("Verification history cleared.", "info");
} catch (err) {
logger.error(`Clear history failed: ${err}`, 'Stage5-Profcheck');
wizardState.showNotice(`Failed to clear history: ${err}`, "error");
}
}
});
}
// Listen for Stage 5 navigation to load history
window.addEventListener("stage-changed", (event) => {
if (event.detail && event.detail.stage === 5) {
loadVerificationHistory();
}
});
// Initial load
loadVerificationHistory();
btnVerify.addEventListener("click", async () => { btnVerify.addEventListener("click", async () => {
const basename = profileBasename || wizardState.basename; const basename = profileBasename || wizardState.basename;
const cwd = profileCwd || wizardState.cwd; const cwd = profileCwd || wizardState.cwd;
@@ -94,11 +570,66 @@ export function initProfcheck() {
if (event.payload.code === 0) { if (event.payload.code === 0) {
logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n"; logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n";
parseAndRenderReport(stdoutAccumulator); const report = parseProfcheckReport(stdoutAccumulator);
reportCard.classList.remove("hidden");
if (report.warnings.length > 0) {
logPre.textContent += `\n[WARN] ${report.warnings.join(' ')}\n`;
}
avgDeEl.textContent = report.avgDe.toFixed(2);
maxDeEl.textContent = report.maxDe.toFixed(2);
rmsDeEl.textContent = report.rmsDe.toFixed(2);
// Quality verdict
badgeEl.className = "report-badge";
if (report.avgDe < 1.0) {
badgeEl.textContent = "EXCELLENT";
badgeEl.classList.add("badge-excellent");
} else if (report.avgDe < 2.0) {
badgeEl.textContent = "GOOD";
badgeEl.classList.add("badge-good");
} else if (report.avgDe < 3.5) {
badgeEl.textContent = "ACCEPTABLE";
badgeEl.classList.add("badge-acceptable");
} else {
badgeEl.textContent = "POOR";
badgeEl.classList.add("badge-poor");
}
// Auto-save record to verification history
const record = {
id: "",
timestamp: new Date().toISOString(),
printer_name: wizardState.printerName || "Unknown",
profile_name: profileBasename || wizardState.basename || "Unknown",
avg_de: report.avgDe,
max_de: report.maxDe,
rms_de: report.rmsDe,
patch_count: report.patchCount,
status: "",
};
try {
await invoke("save_verification_record", { record });
} catch (saveErr) {
logger.warn(`Could not auto-save verification record: ${saveErr}`, 'Stage5-Profcheck');
logPre.textContent += `\n[WARN] Could not auto-save verification record: ${saveErr}\n`;
}
// Refresh verification history display
await loadVerificationHistory();
// Ensure gamut mesh is loaded into 3D viewer // Ensure gamut mesh is loaded into 3D viewer
const gamFilePath = cwd ? `${cwd}${sep}${basename}.gam` : `${basename}.gam`; const gamFilePath = cwd ? `${cwd}${sep}${basename}.gam` : `${basename}.gam`;
loadGamutMesh(gamFilePath, 0x3b82f6); try {
const result = await loadGamutMesh(gamFilePath);
if (!result) {
logPre.textContent += `\n[WARN] Could not render 3D gamut mesh from ${gamFilePath}.\n`;
}
} catch (gamErr) {
logPre.textContent += `\n[WARN] 3D gamut render failed: ${gamErr}\n`;
}
} else { } else {
logPre.textContent += `\n[ERROR] profcheck exited with code ${event.payload.code}.\n`; logPre.textContent += `\n[ERROR] profcheck exited with code ${event.payload.code}.\n`;
} }
@@ -112,54 +643,4 @@ export function initProfcheck() {
btnVerify.disabled = false; btnVerify.disabled = false;
} }
}); });
function parseAndRenderReport(stdout) {
reportCard.classList.remove("hidden");
let avgDe = 0.0;
let maxDe = 0.0;
let rmsDe = 0.0;
// Check if JSON output is present
const jsonMatch = stdout.match(/\{[\s\S]*"avg_de"[\s\S]*\}/);
if (jsonMatch) {
try {
const json = JSON.parse(jsonMatch[0]);
avgDe = json.avg_de || 0;
maxDe = json.max_de || json.peak_de || 0;
rmsDe = json.rms_de || 0;
} catch (e) {
console.error("JSON parse error:", e);
}
} else {
// Regex fallbacks for standard profcheck output
const avgMatch = stdout.match(/avg\.\s*(?:dE\s*)?=\s*([\d\.]+)/i) || stdout.match(/average\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i);
const maxMatch = stdout.match(/max\.\s*(?:dE\s*)?=\s*([\d\.]+)/i) || stdout.match(/peak\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i);
const rmsMatch = stdout.match(/RMS\s*(?:dE\s*)?=\s*([\d\.]+)/i) || stdout.match(/rms\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i);
if (avgMatch) avgDe = parseFloat(avgMatch[1]);
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]);
}
avgDeEl.textContent = avgDe.toFixed(2);
maxDeEl.textContent = maxDe.toFixed(2);
rmsDeEl.textContent = rmsDe.toFixed(2);
// Quality verdict
badgeEl.className = "report-badge";
if (avgDe < 1.0) {
badgeEl.textContent = "EXCELLENT";
badgeEl.classList.add("badge-excellent");
} else if (avgDe < 2.0) {
badgeEl.textContent = "GOOD";
badgeEl.classList.add("badge-good");
} else if (avgDe < 4.0) {
badgeEl.textContent = "ACCEPTABLE";
badgeEl.classList.add("badge-acceptable");
} else {
badgeEl.textContent = "POOR";
badgeEl.classList.add("badge-poor");
}
}
} }
+136
View File
@@ -0,0 +1,136 @@
// Unit & console tests for profcheck.js parsing and breach alert logic.
// Can be run in browser devtools console:
// import('./profcheck.test.js').then(m => m.runAll())
// Or in Node:
// node src/js/profcheck.test.js
// Node environment polyfill for browser globals
if (typeof window === 'undefined') {
globalThis.window = {
__TAURI__: {
core: { invoke: () => Promise.resolve() },
event: { listen: () => Promise.resolve(() => {}) }
},
addEventListener: () => {},
dispatchEvent: () => {}
};
globalThis.document = {
getElementById: () => null,
querySelectorAll: () => []
};
}
const { parseProfcheckReport, checkBreachAlert } = await import('./profcheck.js');
export function runAll() {
console.group('Profcheck Report Parser & Drift Tests');
let passed = 0;
let total = 0;
function assert(actual, expected, message) {
total++;
const ok = JSON.stringify(actual) === JSON.stringify(expected);
if (ok) {
console.log('PASS:', message);
passed++;
} else {
console.error('FAIL:', message, '\nExpected:', expected, '\nGot:', actual);
}
return ok;
}
// 1. Real Argyll -u JSON payload
const realUOutput = `
profcheck: Checking profile accuracy...
No of test patches = 52
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02
`;
const res1 = parseProfcheckReport(realUOutput);
assert(res1.avgDe, 0.85, 'real -u JSON avgDe');
assert(res1.maxDe, 2.41, 'real -u JSON maxDe');
assert(res1.rmsDe, 1.02, 'real -u JSON rmsDe');
assert(res1.patchCount, 52, 'real -u patchCount');
assert(res1.warnings.length, 0, 'real -u no warnings');
// 2. Preference for *de2000 object when multiple JSON objects appear
const multiJsonOutput = `
{"event": "report", "peak_de": 3.10, "avg_de": 1.20, "rms": 1.50}
{"event": "report", "peak_de2000": 2.15, "avg_de2000": 0.72, "rms": 0.95}
`;
const res2 = parseProfcheckReport(multiJsonOutput);
assert(res2.avgDe, 0.72, 'prefers *de2000 avg');
assert(res2.maxDe, 2.15, 'prefers *de2000 peak');
assert(res2.rmsDe, 0.95, 'prefers *de2000 rms');
// 3. Standard text summary line without JSON
const textSummaryOutput = `
Header information...
No of test patches = 120
Profile check complete, errors(CIEDE2000): max. = 1.95, avg. = 0.65, RMS = 0.88
Done.
`;
const res3 = parseProfcheckReport(textSummaryOutput);
assert(res3.avgDe, 0.65, 'text summary line avgDe');
assert(res3.maxDe, 1.95, 'text summary line maxDe');
assert(res3.rmsDe, 0.88, 'text summary line rmsDe');
assert(res3.patchCount, 120, 'text summary patchCount');
// 4. Legacy regex fallbacks
const legacyOutput = `
Summary:
avg. dE = 1.15
max. dE = 3.42
rms. dE = 1.65
`;
const res4 = parseProfcheckReport(legacyOutput);
assert(res4.avgDe, 1.15, 'legacy text avgDe');
assert(res4.maxDe, 3.42, 'legacy text maxDe');
assert(res4.rmsDe, 1.65, 'legacy text rmsDe');
// 5. checkBreachAlert tests
assert(checkBreachAlert([]), null, 'empty records returns null');
assert(checkBreachAlert([{ avg_de: 4.0, timestamp: '2026-09-01T10:00:00Z' }]), null, 'single record returns null');
// Two records in same minute >= 3.5 -> no alert
const sameTimeRecords = [
{ avg_de: 3.8, timestamp: '2026-09-05T12:00:10Z' },
{ avg_de: 3.9, timestamp: '2026-09-05T12:00:45Z' },
];
assert(checkBreachAlert(sameTimeRecords), null, 'two records in same minute do not fire alert');
// Two records on distinct calendar dates >= 3.5 -> alert fires
const distinctDateRecords = [
{ avg_de: 1.0, timestamp: '2026-08-15T10:00:00Z' },
{ avg_de: 3.6, timestamp: '2026-09-01T10:00:00Z' },
{ avg_de: 3.8, timestamp: '2026-09-05T10:00:00Z' },
];
const alert = checkBreachAlert(distinctDateRecords);
assert(typeof alert === 'string' && alert.includes('Re-profiling Recommended') && alert.includes('2 consecutive'), true, 'distinct dates breach alert fires');
// Two records >= 1 hour apart on same day -> alert fires
const hourApartRecords = [
{ avg_de: 3.7, timestamp: '2026-09-05T10:00:00Z' },
{ avg_de: 3.9, timestamp: '2026-09-05T12:30:00Z' },
];
const alert2 = checkBreachAlert(hourApartRecords);
assert(typeof alert2 === 'string' && alert2.includes('Re-profiling Recommended'), true, 'records >=1 hr apart breach alert fires');
// Trailing record is good -> no alert
const recoveredRecords = [
{ avg_de: 3.7, timestamp: '2026-09-01T10:00:00Z' },
{ avg_de: 3.9, timestamp: '2026-09-02T10:00:00Z' },
{ avg_de: 0.9, timestamp: '2026-09-03T10:00:00Z' },
];
assert(checkBreachAlert(recoveredRecords), null, 'recovered profile returns null');
console.log(`Profcheck tests complete: ${passed}/${total} passed`);
console.groupEnd();
return passed === total;
}
// Auto-run if executed directly in Node
if (typeof process !== 'undefined' && process.argv && process.argv[1] && process.argv[1].endsWith('profcheck.test.js')) {
const ok = runAll();
if (!ok) process.exit(1);
}
+48
View File
@@ -1,6 +1,13 @@
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
import { logger } from './logger.js'; import { logger } from './logger.js';
function getInputValueAsFloat(id, fallback) {
const el = document.getElementById(id);
if (!el) return fallback;
const v = parseFloat(el.value);
return Number.isNaN(v) ? fallback : v;
}
export async function initSettings() { export async function initSettings() {
const dialog = document.getElementById('settingsDialog'); const dialog = document.getElementById('settingsDialog');
const openBtn = document.getElementById('openSettingsBtn'); const openBtn = document.getElementById('openSettingsBtn');
@@ -11,6 +18,10 @@ export async function initSettings() {
const btnCopyLogPath = document.getElementById('btnCopyLogPath'); const btnCopyLogPath = document.getElementById('btnCopyLogPath');
const btnCopyLogExcerpt = document.getElementById('btnCopyLogExcerpt'); const btnCopyLogExcerpt = document.getElementById('btnCopyLogExcerpt');
const logPathDisplay = document.getElementById('logPathDisplay'); const logPathDisplay = document.getElementById('logPathDisplay');
const deltaEGoodMax = document.getElementById('deltaEGoodMax');
const deltaEWarningMax = document.getElementById('deltaEWarningMax');
const deltaEThresholdError = document.getElementById('deltaEThresholdError');
const enableI1Pro2Leds = document.getElementById('enable_i1pro2_leds');
if (!dialog || !openBtn) return; if (!dialog || !openBtn) return;
@@ -24,14 +35,42 @@ export async function initSettings() {
} }
} }
function validateDeltaEThresholds() {
if (!deltaEGoodMax || !deltaEWarningMax) return true;
const good = parseFloat(deltaEGoodMax.value);
const warn = parseFloat(deltaEWarningMax.value);
const valid = !Number.isNaN(good) && !Number.isNaN(warn) && good < warn && good >= 0 && warn >= 0;
if (deltaEThresholdError) {
deltaEThresholdError.style.display = valid ? 'none' : 'block';
}
return valid;
}
if (deltaEGoodMax) {
deltaEGoodMax.addEventListener('input', validateDeltaEThresholds);
}
if (deltaEWarningMax) {
deltaEWarningMax.addEventListener('input', validateDeltaEThresholds);
}
openBtn.addEventListener('click', async () => { openBtn.addEventListener('click', async () => {
try { try {
const settings = await invoke('load_settings'); const settings = await invoke('load_settings');
document.getElementById('argyll_binary_dir').value = settings.argyll_binary_dir || ''; document.getElementById('argyll_binary_dir').value = settings.argyll_binary_dir || '';
document.getElementById('default_instrument').value = settings.default_instrument || ''; document.getElementById('default_instrument').value = settings.default_instrument || '';
if (enableI1Pro2Leds) {
enableI1Pro2Leds.checked = Boolean(settings.enable_i1pro2_leds);
}
if (logLevelSelect && settings.log_level) { if (logLevelSelect && settings.log_level) {
logLevelSelect.value = settings.log_level; logLevelSelect.value = settings.log_level;
} }
if (deltaEGoodMax) {
deltaEGoodMax.value = Number(settings.delta_e_good_max ?? 2.0).toFixed(1);
}
if (deltaEWarningMax) {
deltaEWarningMax.value = Number(settings.delta_e_warning_max ?? 5.0).toFixed(1);
}
validateDeltaEThresholds();
await refreshLogPath(); await refreshLogPath();
dialog.showModal(); dialog.showModal();
} catch (e) { } catch (e) {
@@ -99,18 +138,27 @@ export async function initSettings() {
if (saveBtn) { if (saveBtn) {
saveBtn.addEventListener('click', async () => { saveBtn.addEventListener('click', async () => {
try { try {
if (!validateDeltaEThresholds()) {
throw new Error('Invalid ΔE thresholds');
}
const currentSettings = await invoke('load_settings').catch(() => ({})); const currentSettings = await invoke('load_settings').catch(() => ({}));
const settings = { const settings = {
...currentSettings, ...currentSettings,
argyll_binary_dir: document.getElementById('argyll_binary_dir').value.trim() || null, argyll_binary_dir: document.getElementById('argyll_binary_dir').value.trim() || null,
default_instrument: document.getElementById('default_instrument').value.trim() || null, default_instrument: document.getElementById('default_instrument').value.trim() || null,
log_level: logLevelSelect ? logLevelSelect.value : (currentSettings.log_level || 'info'), log_level: logLevelSelect ? logLevelSelect.value : (currentSettings.log_level || 'info'),
delta_e_good_max: getInputValueAsFloat('deltaEGoodMax', 2.0),
delta_e_warning_max: getInputValueAsFloat('deltaEWarningMax', 5.0),
enable_i1pro2_leds: enableI1Pro2Leds ? enableI1Pro2Leds.checked : false,
}; };
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');
// Notify other UI modules (e.g. swatch grid) that settings changed.
window.dispatchEvent(new CustomEvent('settings-saved', { detail: settings }));
dialog.close(); dialog.close();
} catch (e) { } catch (e) {
logger.error(`Failed to save settings: ${e}`, 'Settings'); logger.error(`Failed to save settings: ${e}`, 'Settings');
alert(`Failed to save settings: ${e}`);
} }
}); });
} }
+3
View File
@@ -4,6 +4,7 @@ export const wizardState = {
currentStage: 1, currentStage: 1,
basename: "", basename: "",
cwd: "", cwd: "",
printerName: "",
noticeTimer: null, noticeTimer: null,
setTarget(basename, cwd) { setTarget(basename, cwd) {
@@ -75,6 +76,8 @@ export const wizardState = {
s.classList.add('hidden'); s.classList.add('hidden');
} }
}); });
window.dispatchEvent(new CustomEvent('stage-changed', { detail: { stage: stageNumber } }));
}, },
async navigateToStage(stageNumber) { async navigateToStage(stageNumber) {
+85 -12
View File
@@ -1,10 +1,76 @@
import { computeDeltaE00 } from './delta_e.js'; import { computeDeltaE00 } from './delta_e.js';
import { labToCss, deviceRgbToCss, deviceCmykToCss } from './color_convert.js'; import { labToCss, deviceRgbToCss, deviceCmykToCss } from './color_convert.js';
const { listen } = window.__TAURI__.event; const { listen, emit } = window.__TAURI__.event;
const { invoke } = window.__TAURI__.core;
let unlistenJsonRow = null; let unlistenJsonRow = null;
// Cached ΔE thresholds; refreshed from settings on load and when settings change.
let deltaEThresholds = { goodMax: 2.0, warnMax: 5.0 };
/**
* Load the ΔE traffic-light thresholds from persisted app settings.
* Falls back to 2.0 / 5.0 if settings are missing or malformed.
*/
async function refreshDeltaEThresholds() {
try {
const s = await invoke('load_settings');
deltaEThresholds = {
goodMax: s.delta_e_good_max ?? 2.0,
warnMax: s.delta_e_warning_max ?? 5.0,
};
// Enforce the invariant at runtime in case a user hand-edited settings.json.
if (deltaEThresholds.goodMax > deltaEThresholds.warnMax) {
console.warn('ΔE thresholds invalid (good > warning); resetting to defaults.');
deltaEThresholds = { goodMax: 2.0, warnMax: 5.0 };
}
} catch (err) {
console.warn('Could not load ΔE thresholds, using defaults:', err);
deltaEThresholds = { goodMax: 2.0, warnMax: 5.0 };
}
}
/**
* Classify a CIEDE2000 value using the current thresholds.
* @param {number} deltaE
* @returns {{ label: string, cssClass: string }}
*/
function classifyDeltaE(deltaE) {
if (deltaE < deltaEThresholds.goodMax) {
return { label: "Good", cssClass: "de-good" };
} else if (deltaE < deltaEThresholds.warnMax) {
return { label: "Warning", cssClass: "de-warning" };
} else {
return { label: "Bad", cssClass: "de-bad" };
}
}
/**
* Re-classify all already-rendered swatches using the current thresholds.
* Call this after the user changes thresholds in Settings.
*/
function reclassifySwatches() {
document.querySelectorAll('.swatch-patch[data-delta-e]').forEach((patchEl) => {
const deltaE = parseFloat(patchEl.dataset.deltaE);
if (Number.isNaN(deltaE)) return;
patchEl.classList.remove('de-good', 'de-warning', 'de-bad');
const { cssClass, label } = classifyDeltaE(deltaE);
patchEl.classList.add(cssClass);
// Update the classification word in the title without re-parsing everything.
if (patchEl.title) {
patchEl.title = patchEl.title.replace(/\s*\([^)]*\)$/, '') + ` (${label})`;
}
});
}
// Listen for the custom event emitted by settings.js after a successful save.
window.addEventListener('settings-saved', () => {
refreshDeltaEThresholds().then(reclassifySwatches);
});
/** /**
* Start listening for row events from a chartread process. * Start listening for row events from a chartread process.
* @param {string} processId - The process ID (e.g. "chartread_my_profile") * @param {string} processId - The process ID (e.g. "chartread_my_profile")
@@ -16,6 +82,9 @@ export async function startSwatchListener(processId, onRowComplete) {
const progressText = document.getElementById("readProgressText"); const progressText = document.getElementById("readProgressText");
const statsPanel = document.getElementById("readStats"); const statsPanel = document.getElementById("readStats");
// Load thresholds at listener start
await refreshDeltaEThresholds();
// Clear previous state // Clear previous state
grid.innerHTML = ""; grid.innerHTML = "";
let totalPatches = 0; let totalPatches = 0;
@@ -51,6 +120,8 @@ export async function startSwatchListener(processId, onRowComplete) {
if (progressText) progressText.textContent = `Strip ${data.row_id}${data.row_index + 1} / ${data.total_rows}`; if (progressText) progressText.textContent = `Strip ${data.row_id}${data.row_index + 1} / ${data.total_rows}`;
// Create row container // Create row container
// Row and patch order from chartread (A->Z, 1->N) is rendered
// left-to-right / top-to-bottom to match the printtarg output.
const rowEl = document.createElement("div"); const rowEl = document.createElement("div");
rowEl.className = "swatch-row"; rowEl.className = "swatch-row";
@@ -63,6 +134,10 @@ export async function startSwatchListener(processId, onRowComplete) {
rowPatches.className = "swatch-row-patches"; rowPatches.className = "swatch-row-patches";
for (const patch of data.patches) { for (const patch of data.patches) {
// Argyll marks spacer/boundary patches with is_pad. Some printtarg
// layouts also flag white reference patches (-e white steps) as pads,
// but those carry valid expected or measured device data. Only skip
// pads that have no measurement and no non-zero device coordinates.
if (patch.is_pad && !patch.measured && (!patch.device || patch.device.every(v => v === 0))) continue; if (patch.is_pad && !patch.measured && (!patch.device || patch.device.every(v => v === 0))) continue;
const patchEl = document.createElement("div"); const patchEl = document.createElement("div");
@@ -93,11 +168,12 @@ export async function startSwatchListener(processId, onRowComplete) {
patchEl.appendChild(swatch); patchEl.appendChild(swatch);
// Build a structured tooltip for the patch
let titleStr = `${patch.loc} (ID: ${patch.id})`; let titleStr = `${patch.loc} (ID: ${patch.id})`;
if (patch.expected && patch.expected.Lab) { if (patch.expected && patch.expected.Lab) {
titleStr += `\nIntended Lab: ${patch.expected.Lab.map(v => v.toFixed(1)).join(', ')}`; titleStr += `\nIntended Lab: ${patch.expected.Lab.map(v => v.toFixed(1)).join(', ')}`;
} else if (patch.device) { } else if (patch.device) {
titleStr += `\nDevice: ${patch.device.map(v => v.toFixed(1)).join(', ')}`; titleStr += `\nIntended Device: ${patch.device.map(v => v.toFixed(1)).join(', ')}`;
} }
if (patch.measured && patch.measured.Lab) { if (patch.measured && patch.measured.Lab) {
@@ -107,22 +183,19 @@ export async function startSwatchListener(processId, onRowComplete) {
// Compute and display ΔE₀₀ if both expected and measured Lab are present // Compute and display ΔE₀₀ if both expected and measured Lab are present
if (patch.expected && patch.expected.Lab && patch.measured && patch.measured.Lab) { if (patch.expected && patch.expected.Lab && patch.measured && patch.measured.Lab) {
const deltaE = computeDeltaE00(patch.expected.Lab, patch.measured.Lab); const deltaE = computeDeltaE00(patch.expected.Lab, patch.measured.Lab);
const { label, cssClass } = classifyDeltaE(deltaE);
// Persist the raw ΔE on the element so re-classification works after
// the user changes thresholds in Settings.
patchEl.dataset.deltaE = deltaE.toFixed(4);
const deLabel = document.createElement("div"); const deLabel = document.createElement("div");
deLabel.className = "swatch-de"; deLabel.className = "swatch-de";
deLabel.textContent = deltaE.toFixed(1); deLabel.textContent = deltaE.toFixed(1);
titleStr += `\nΔE₀₀: ${deltaE.toFixed(2)}`; titleStr += `\nΔE₀₀: ${deltaE.toFixed(2)} (${label})`;
// Traffic light classification
if (deltaE < 2) {
patchEl.classList.add("de-good"); // Green
} else if (deltaE < 5) {
patchEl.classList.add("de-warning"); // Amber
} else {
patchEl.classList.add("de-bad"); // Red
}
patchEl.classList.add(cssClass);
patchEl.appendChild(deLabel); patchEl.appendChild(deLabel);
// Accumulate stats // Accumulate stats
+16
View File
@@ -0,0 +1,16 @@
// Manual smoke tests for swatch_grid.js
// Run in a browser/devtools console after the app has loaded:
// import('./swatch_grid.test.js').then(m => m.runAll())
export function runAll() {
console.group('swatch_grid smoke tests');
testClassifyDeltaE();
console.groupEnd();
}
function testClassifyDeltaE() {
// classifyDeltaE is currently internal to swatch_grid.js, but we can
// validate the visual classification by exercising the colour thresholds.
// This is a placeholder for a future unit-test harness.
console.log('testClassifyDeltaE: no direct export; validated by manual inspection');
}
+371 -23
View File
@@ -5,6 +5,11 @@
--text-color: #d4d4d4; --text-color: #d4d4d4;
--accent-color: #007acc; --accent-color: #007acc;
--border-color: #333333; --border-color: #333333;
--btn-height-sm: 28px;
--btn-height-md: 36px;
--btn-height-lg: 40px;
--btn-radius-sm: 4px;
--btn-radius-md: 6px;
} }
body { body {
@@ -127,25 +132,27 @@ button.primary {
background: linear-gradient(135deg, var(--accent-color), #2d73a8); background: linear-gradient(135deg, var(--accent-color), #2d73a8);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 6px; border-radius: var(--btn-radius-md);
padding: 0 18px; padding: 0 18px;
height: 36px; height: var(--btn-height-md);
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: opacity 0.2s, transform 0.1s; transition: opacity 0.2s, transform 0.1s;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
} }
button.primary:hover { button.primary:hover {
opacity: 0.9; opacity: 0.9;
} }
/* Button sizing system */ /* Button sizing system — use CSS variables for consistency across the UI */
.btn-sm { height: 28px; padding: 0 10px; font-size: 0.78rem; border-radius: 4px; } .btn-sm { height: var(--btn-height-sm); padding: 0 10px; font-size: 0.78rem; border-radius: var(--btn-radius-sm); box-sizing: border-box; }
.btn-md { height: 36px; padding: 0 16px; font-size: 0.875rem; border-radius: 6px; } .btn-md { height: var(--btn-height-md); padding: 0 16px; font-size: 0.875rem; border-radius: var(--btn-radius-md); box-sizing: border-box; }
.btn-lg { height: 40px; padding: 0 20px; font-size: 0.95rem; font-weight: 600; border-radius: 6px; } .btn-lg { height: var(--btn-height-lg); padding: 0 20px; font-size: 0.95rem; font-weight: 600; border-radius: var(--btn-radius-md); box-sizing: border-box; }
.btn-icon-sq { width: 36px; height: 36px; min-width: 36px; padding: 0; display: inline-flex; align-items: center; justify-content: center; } button.primary.btn-lg { height: var(--btn-height-lg); }
.btn-icon-sq { width: var(--btn-height-md); height: var(--btn-height-md); min-width: var(--btn-height-md); padding: 0; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; }
/* Action containers */ /* Action containers */
.stage-actions { .stage-actions {
@@ -163,10 +170,63 @@ button.primary:hover {
justify-content: flex-end; justify-content: flex-end;
} }
/* Utility for a horizontal row of buttons (replaces inline style="display:flex;..." on parent divs) */
.btn-row {
display: flex;
align-items: center;
gap: 8px;
}
.btn-row-end {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.btn-row-sm {
display: flex;
align-items: center;
gap: 6px;
}
.btn-row.wrap,
.btn-row-sm.wrap {
flex-wrap: wrap;
}
.form-group { .form-group {
margin: 14px 0; margin: 14px 0;
} }
.form-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.form-group-header label {
margin: 0;
}
.form-group-label-small {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.7;
font-weight: 600;
}
.form-group-title {
font-weight: 600;
}
.form-group-header h3 {
margin: 0;
font-size: 1rem;
}
.form-group:first-child { .form-group:first-child {
margin-top: 0; margin-top: 0;
} }
@@ -254,6 +314,17 @@ select option:checked {
align-items: stretch; align-items: stretch;
} }
.input-row-sm {
display: flex;
gap: 8px;
align-items: center;
}
.input-row-sm > input,
.input-row-sm > select {
flex: 1;
}
.input-row input { .input-row input {
flex: 1; flex: 1;
} }
@@ -284,9 +355,10 @@ button.secondary {
color: white; color: white;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
padding: 0 16px; padding: 0 16px;
height: 36px; height: var(--btn-height-md);
border-radius: 4px; border-radius: var(--btn-radius-sm);
cursor: pointer; cursor: pointer;
box-sizing: border-box;
} }
button.secondary:hover { button.secondary:hover {
@@ -489,6 +561,7 @@ button:disabled {
.chartread-actions { .chartread-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center;
margin-bottom: 16px; margin-bottom: 16px;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -497,9 +570,10 @@ button.danger {
background-color: #c62828; background-color: #c62828;
color: white; color: white;
border: none; border: none;
padding: 10px 20px; padding: 0 16px;
font-size: 1rem; height: var(--btn-height-md);
border-radius: 4px; font-size: 0.875rem;
border-radius: var(--btn-radius-sm);
cursor: pointer; cursor: pointer;
} }
@@ -779,12 +853,12 @@ button.danger:hover {
font-size: 1.2rem; font-size: 1.2rem;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
border-radius: 4px; border-radius: var(--btn-radius-sm);
color: #d1d5db; color: #d1d5db;
transition: color 0.15s ease, background-color 0.15s ease; transition: color 0.15s ease, background-color 0.15s ease;
width: 28px; width: var(--btn-height-sm);
height: 28px; height: var(--btn-height-sm);
min-width: 28px; min-width: var(--btn-height-sm);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -973,8 +1047,9 @@ button.danger:hover {
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-color); color: var(--text-color);
padding: 6px 12px; padding: 0 12px;
border-radius: 4px; height: var(--btn-height-md);
border-radius: var(--btn-radius-sm);
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -984,6 +1059,7 @@ button.danger:hover {
gap: 6px; gap: 6px;
transition: all 0.2s ease; transition: all 0.2s ease;
flex-shrink: 0; flex-shrink: 0;
box-sizing: border-box;
} }
.btn-properties:hover { .btn-properties:hover {
@@ -1042,20 +1118,24 @@ button.danger:hover {
font-weight: 600; font-weight: 600;
} }
/* Legacy alias: .btn-icon should not be used for new buttons; prefer .btn-icon-sq */
.btn-icon { .btn-icon {
width: var(--btn-height-md);
height: var(--btn-height-md);
min-width: var(--btn-height-md);
padding: 0;
background: var(--bg-color); background: var(--bg-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-color); color: var(--text-color);
width: 34px; border-radius: var(--btn-radius-sm);
height: 34px; display: inline-flex;
border-radius: 4px;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
transition: all 0.2s; transition: all 0.2s;
flex-shrink: 0; flex-shrink: 0;
box-sizing: border-box;
} }
.btn-icon:hover { .btn-icon:hover {
@@ -1078,7 +1158,7 @@ button.danger:hover {
border: 1px solid rgba(76, 175, 80, 0.3); border: 1px solid rgba(76, 175, 80, 0.3);
} }
.badge-printing { .badge-printing, .badge-primary {
background: rgba(33, 150, 243, 0.15); background: rgba(33, 150, 243, 0.15);
color: #64b5f6; color: #64b5f6;
border: 1px solid rgba(33, 150, 243, 0.3); border: 1px solid rgba(33, 150, 243, 0.3);
@@ -1450,6 +1530,43 @@ button.danger:hover {
margin-bottom: 0; margin-bottom: 0;
} }
.gamut-legend-item.gamut-slider-item {
margin: -2px 0 8px 27px;
gap: 6px;
}
.gamut-slider-label {
font-size: 0.7rem;
opacity: 0.65;
min-width: 46px;
}
.gamut-slider-item input[type="range"] {
flex: 1;
margin: 0;
height: 4px;
accent-color: var(--accent-color, #3b82f6);
}
.gamut-controls-actions {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.09);
display: flex;
flex-direction: column;
gap: 6px;
}
.gamut-controls-actions button {
width: 100%;
}
.gamut-keyboard-hint {
font-size: 0.68rem;
opacity: 0.5;
text-align: center;
}
.gamut-legend-label { .gamut-legend-label {
flex: 1; flex: 1;
white-space: nowrap; white-space: nowrap;
@@ -1554,5 +1671,236 @@ button.danger:hover {
opacity: 0.55; opacity: 0.55;
} }
/*
Longitudinal Printer Drift Tracking & Verification History Stage 5
*/
.drift-history {
margin-top: 16px;
margin-bottom: 20px;
}
.drift-filter-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
font-size: 0.85rem;
}
.drift-filter-row select {
max-width: 240px;
padding: 4px 8px;
height: 30px;
font-size: 0.85rem;
}
.drift-chart-wrap {
width: 100%;
height: 240px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border-color, #333);
border-radius: 6px;
margin-bottom: 14px;
overflow: hidden;
position: relative;
}
#driftTrendChart {
width: 100%;
height: 100%;
display: block;
}
.drift-chart-grid line {
stroke: rgba(255, 255, 255, 0.08);
stroke-width: 1;
}
.drift-chart-axis line,
.drift-chart-axis path {
stroke: rgba(255, 255, 255, 0.2);
stroke-width: 1;
}
.drift-chart-text {
fill: #888;
font-size: 10px;
font-family: monospace;
}
.drift-band-label {
fill: rgba(255, 255, 255, 0.45);
font-size: 9px;
text-anchor: end;
}
.drift-line-avg {
fill: none;
stroke: #3b82f6;
stroke-width: 2.5;
stroke-linejoin: round;
stroke-linecap: round;
}
.drift-line-max {
fill: none;
stroke: #f59e0b;
stroke-width: 1.5;
stroke-dasharray: 4, 3;
stroke-linejoin: round;
stroke-linecap: round;
}
.drift-dot-avg {
fill: #3b82f6;
stroke: #1e293b;
stroke-width: 2;
cursor: pointer;
transition: r 0.15s ease, fill 0.15s ease;
}
.drift-dot-avg:hover {
r: 6;
fill: #60a5fa;
}
.drift-dot-max {
fill: #f59e0b;
stroke: #1e293b;
stroke-width: 1.5;
cursor: pointer;
transition: r 0.15s ease, fill 0.15s ease;
}
.drift-dot-max:hover {
r: 5;
fill: #fbbf24;
}
.drift-table-wrap {
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--border-color, #333);
border-radius: 6px;
margin-bottom: 14px;
}
.drift-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
text-align: left;
}
.drift-table th {
position: sticky;
top: 0;
background: var(--bg-surface, #1e1e1e);
color: var(--text-muted, #aaa);
font-weight: 600;
padding: 8px 10px;
border-bottom: 1px solid var(--border-color, #333);
z-index: 1;
}
.drift-table td {
padding: 7px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.drift-table tbody tr:hover {
background: rgba(255, 255, 255, 0.03);
}
.drift-table tbody tr:last-child td {
border-bottom: none;
}
/*
XY Automated Scanning Table Sequence Stage 3
*/
.xy-table-panel {
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border-color, #333);
border-radius: 6px;
padding: 12px 14px;
margin-bottom: 16px;
}
.xy-table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.xy-table-header h4 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-color, #eee);
}
.xy-steps-list {
list-style: none;
counter-reset: xy-step-counter;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
}
.xy-step {
counter-increment: xy-step-counter;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
padding: 8px 10px;
font-size: 0.8rem;
color: var(--text-muted, #888);
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
}
.xy-step::before {
content: counter(xy-step-counter);
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: #ccc;
font-size: 0.7rem;
font-weight: 600;
flex-shrink: 0;
}
.xy-step.active {
background: rgba(59, 130, 246, 0.15);
border-color: rgba(59, 130, 246, 0.5);
color: #93c5fd;
font-weight: 500;
}
.xy-step.active::before {
background: var(--accent-color, #3b82f6);
color: #fff;
}
.xy-step.completed {
color: #86efac;
border-color: rgba(34, 197, 94, 0.3);
}
.xy-step.completed::before {
content: "✓";
background: rgba(34, 197, 94, 0.2);
color: #86efac;
}