Compare commits
55
Commits
fb438a7880
...
v0.8.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6373c243b | ||
|
|
dc7c8ada96 | ||
|
|
0741c961e6 | ||
|
|
9769d397b9 | ||
|
|
18a6d9394c | ||
|
|
9016932547 | ||
|
|
2f7d8f3ca7 | ||
|
|
41efac68ed | ||
|
|
9aad7b083a | ||
|
|
5ee1b07263 | ||
|
|
53ce298bc1 | ||
|
|
45ed7dbfe5 | ||
|
|
f553f10399 | ||
|
|
a9112445bb | ||
|
|
a584b9746f | ||
|
|
c2fdfafaa7 | ||
|
|
b60d0c3bce | ||
|
|
8a0065274b | ||
|
|
ab2f6e562e | ||
|
|
3026e7081c | ||
|
|
84df0b7a7d | ||
|
|
9f8b2c8567 | ||
|
|
2c97e98dc3 | ||
|
|
0603faca59 | ||
|
|
956b5ff499 | ||
|
|
d50c3c4f3c | ||
|
|
a90b6c6d77 | ||
|
|
d51bae705d | ||
|
|
45b2208718 | ||
|
|
9a365acd2b | ||
|
|
a5e090b323 | ||
|
|
a4612bf065 | ||
|
|
8b01c98463 | ||
|
|
37b0c13263 | ||
|
|
3e844fdf8b | ||
|
|
bafed60b20 | ||
|
|
7b8d9e644a | ||
|
|
ff01e97ccf | ||
|
|
8f8c73d46b | ||
|
|
2273882330 | ||
|
|
53d5ab20b9 | ||
|
|
313bdfdee4 | ||
|
|
ce5e0ef2b5 | ||
|
|
47a5373a8e | ||
|
|
6a4551bab6 | ||
|
|
0b6974d060 | ||
|
|
75cba657cd | ||
|
|
a8764d90fd | ||
|
|
b78002b856 | ||
|
|
aad79681af | ||
|
|
be0f55aefc | ||
|
|
9a3f841f9f | ||
|
|
9768252f6b | ||
|
|
c37662daed | ||
|
|
2079556d61 |
@@ -0,0 +1,57 @@
|
||||
# Auto detect text files and perform LF normalization on commit
|
||||
* text=auto eol=lf
|
||||
|
||||
# Explicit text files normalized to LF
|
||||
*.rs text eol=lf
|
||||
*.js text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.json text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.svg text eol=lf
|
||||
*.md text eol=lf
|
||||
*.py text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.lock text eol=lf
|
||||
.gitignore text eol=lf
|
||||
.gitattributes text eol=lf
|
||||
.editorconfig text eol=lf
|
||||
|
||||
# ArgyllCMS datasets and test fixtures (CGATS text format)
|
||||
*.ti1 text eol=lf
|
||||
*.ti2 text eol=lf
|
||||
*.ti3 text eol=lf
|
||||
*.gam text eol=lf
|
||||
*.mock text eol=lf
|
||||
|
||||
# Windows scripts and installers
|
||||
*.cmd text eol=crlf
|
||||
*.bat text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
*.nsh text eol=crlf
|
||||
|
||||
# Binary file declarations (prevent EOL conversions)
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.bmp binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.icns binary
|
||||
*.tif binary
|
||||
*.tiff binary
|
||||
*.icc binary
|
||||
*.icm binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
*.so binary
|
||||
*.dylib binary
|
||||
*.zip binary
|
||||
*.tar binary
|
||||
*.tar.gz binary
|
||||
*.tgz binary
|
||||
*.7z binary
|
||||
@@ -13,17 +13,17 @@ jobs:
|
||||
matrix:
|
||||
platform:
|
||||
- name: Intel
|
||||
os: macos-15-intel
|
||||
os: macos
|
||||
target: x86_64-apple-darwin
|
||||
binary_dir: macos-x86_64
|
||||
arch: x86_64
|
||||
- name: Apple Silicon
|
||||
os: macos-15
|
||||
os: macos
|
||||
target: aarch64-apple-darwin
|
||||
binary_dir: macos-aarch64
|
||||
arch: arm64
|
||||
- name: Universal (Intel + Apple Silicon)
|
||||
os: macos-15
|
||||
os: macos
|
||||
target: universal-apple-darwin
|
||||
binary_dir: macos-universal
|
||||
arch: universal
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Rust stable
|
||||
@@ -44,9 +44,6 @@ jobs:
|
||||
toolchain: stable
|
||||
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
|
||||
run: npm ci
|
||||
|
||||
@@ -62,6 +59,21 @@ jobs:
|
||||
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/instlist"
|
||||
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/targen"
|
||||
|
||||
- name: Run Rust tests
|
||||
shell: bash
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_TARGET_DIR: "${{ runner.temp }}/cargo-target"
|
||||
run: |
|
||||
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 }}
|
||||
fi
|
||||
|
||||
- name: Set Release Environment
|
||||
id: set_env
|
||||
shell: bash
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
matrix:
|
||||
platform:
|
||||
- name: Intel
|
||||
os: macos-latest
|
||||
os: macos-26-intel
|
||||
target: x86_64-apple-darwin
|
||||
binary_dir: macos-x86_64
|
||||
arch: x86_64
|
||||
@@ -74,7 +74,22 @@ jobs:
|
||||
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
|
||||
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Warm up macOS GUI for DMG layout
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
# Tauri's DMG bundler runs an AppleScript that asks Finder to set the
|
||||
# background picture and icon positions. On CI runners, Finder or the
|
||||
# Aqua session may be idle, which can cause AppleEvent timeouts. Try to
|
||||
# start/awaken Finder and System Events before the real build.
|
||||
open -g -a Finder || true
|
||||
open -g -a "System Events" || true
|
||||
# Send a harmless probe to force Finder to initialise a scripting session.
|
||||
osascript -e 'tell application "Finder" to get name' || true
|
||||
sleep 10
|
||||
|
||||
- name: Build Tauri App
|
||||
env:
|
||||
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
||||
run: npm run tauri build -- --target ${{ matrix.platform.target }}
|
||||
|
||||
- name: Prepare Release Assets
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# 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
|
||||
|
||||
- **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 tests**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo test`
|
||||
- **Frontend**: `cd src-tauri && npm run build` (or `npm run dev` for development)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
- **Backend**: Rust + Tauri v2 (`src-tauri/`)
|
||||
- **Frontend**: Vanilla JS modules (`src/js/`), HTML (`src/index.html`)
|
||||
- **Print subsystem**: Platform-specific code under `src-tauri/src/print/`
|
||||
- `mod.rs`: Shared types (`PrintOptions`, `Printer`, `PrinterCapabilities`, `PrinterDevModeStore`)
|
||||
- `macos.rs`: macOS-specific `lp` spooling and native `NSPrintPanel` integration
|
||||
- `unix.rs`: Generic Unix/CUPS utilities (printer enumeration, PPD parsing, `lp` args)
|
||||
- `windows.rs`: Windows-specific printing via Win32 API and DEVMODE
|
||||
|
||||
## Cross-Platform `Printer` Field Notes
|
||||
|
||||
When adding fields to `Printer` in `src-tauri/src/print/mod.rs`, update every platform-specific constructor in `src-tauri/src/print/windows.rs`, `src-tauri/src/print/macos.rs`, and `src-tauri/src/print/unix.rs` to avoid build regressions on any target. Use `..Default::default()` where possible, or explicitly provide values (e.g. `display_name: None` on Windows).
|
||||
|
||||
## macOS Print Properties (Issue #188)
|
||||
|
||||
The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI or System Settings).
|
||||
- The CUPS destination ID is bound to the panel via Core Printing `PMPrinterCreateFromPrinterID` and `PMSessionSetCurrentPMPrinter`
|
||||
- A `Printer.display_name` (from CUPS `printer-info`) is cached at enumeration as a fallback for `NSPrinter::printerWithName`
|
||||
- Pre-configured with both `AP_ColorMatchingMode=AP_ApplicationColorMatching` and `AP.ColorMatchingMode=AP_ApplicationColorMatching` (dot-notation) as a locked PMPrintSettings value and in the `NSPrintInfo` job ticket
|
||||
- Uses the private Core Printing `PMSessionSetColorMatchingMode` / `PMSessionSetColorMatchingModeLock` / `PMSessionSetColorMatchingModeNoLock` SPI (resolved at runtime via `dlsym`) to gray out and lock the Color Matching controls; all three symbols use the 2-argument `(PMPrintSession, *const CFString)` signature; `PMSessionSetColorMatchingModeLock` sets and locks in one call; `NoLock` sets the mode without locking; falls back to the public `PMPrintSettingsSetValue` setting if the SPI is absent
|
||||
- Pre-selects the driver-specific "no color adjustment" PPD option (Canon `CNIJIntent2=4`, Epson `EPIJ_CMat=3`, etc.) in the native panel and on the `lp` command line
|
||||
- Captures user's media type / quality selections as a CUPS options string with `PMPrintSettingsToOptions`
|
||||
- Returns a `PrintPropertiesResult` with the effective `selected_printer` and captured `PrintOptions`
|
||||
- Cancellation is returned as `None`, not an error
|
||||
- Captured options are stored in frontend `capturedCupsOptions` map and passed via `PrintOptions.cups_options`
|
||||
- `build_lp_args` in `macos.rs` always adds both `-o AP_ColorMatchingMode=AP_ApplicationColorMatching` and `-o AP.ColorMatchingMode=AP_ApplicationColorMatching`, and forwards captured options
|
||||
- Only `AP_ApplicationColorMatching` and `ApplicationColorMatching` are passed to the private SPI; `AP_ColorSyncMatching` and `AP_VendorColorMatching` are intentionally avoided because they would enable color management on profiling targets
|
||||
|
||||
## Key Dependencies (macOS only)
|
||||
|
||||
- `objc2` 0.6 — MainThreadMarker, rc
|
||||
- `objc2-app-kit` 0.3.2 — NSPrintPanel, NSPrintInfo, NSPrinter
|
||||
- `objc2-foundation` 0.3.2 — NSString
|
||||
- `objc2-core-foundation` 0.3.2 — CFString, CFType
|
||||
- `objc2-application-services` 0.3.2 — PMCore (PMPrintSettings, PMPrinter, PMSession, etc.)
|
||||
|
||||
## PPD Option Detection
|
||||
|
||||
- Epson media type key: `EPIJ_Medi` (in addition to `CNIJMediaType`, `MediaType`, `StpMediaType`)
|
||||
- Epson color bypass: `EPIJ_CMat=3` (Off / No Color Adjustment)
|
||||
- Canon color bypass: `CNIJIntent2=4` or `CNIJIntent=4`
|
||||
- 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.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS.
|
||||
|
||||
[](https://git.i3omb.com/gronod/ICCery)
|
||||
[](https://git.i3omb.com/gronod/ICCery)
|
||||
[](https://git.i3omb.com/gronod/ICCery)
|
||||
[](https://tauri.app)
|
||||
[](LICENCE.md)
|
||||
@@ -16,13 +16,14 @@
|
||||
- 🪄 **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.
|
||||
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.
|
||||
4. **Stage 4 — Profile Calculation (`colprof`)**: Generate high-precision cLUT mathematical ICC/ICM profiles with configurable algorithm quality, 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.
|
||||
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, 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, 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.
|
||||
- 🐧 **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.
|
||||
- 🌐 **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.
|
||||
|
||||
---
|
||||
|
||||
+16
-5
@@ -79,7 +79,9 @@ ICCery is a native, cross-platform desktop application built with:
|
||||
- [x] **Stage 1 Layout Normalization & Stage 2 Deterministic Target Generation (#162, #163)** (`v0.5.5`): Reorganized Stage 1 Advanced Options into structured 2-column grids with normalized heights; enforced deterministic `-R 1` target generation with custom seed and raster order (`-r`) support in Stage 2.
|
||||
- [x] **macOS Universal Binary Target (#164)** (`v0.5.5`): Added macOS Universal Binary (`universal-apple-darwin`) build target combining Intel (`x86_64`) and Apple Silicon (`arm64`), ArgyllCMS universal sidecar packaging, runtime fallback resolution, and CI release asset automation.
|
||||
|
||||
### Milestone 11 — Enterprise Colour Workflow (`v0.6.0` – `v0.7.0`)
|
||||
### Milestone 11 — Enterprise Colour Workflow (`v0.6.0` – `v0.7.2`)
|
||||
- [x] **macOS Driver Colour Management Bypass & Media Type Selection (#188)** (`v0.7.2`): Direct media type extraction via PPD/`lpoptions`, vendor uncorrected color bypass detection (Canon `CNIJIntent2`, Epson `ColorCorrection`), `AP_ColorMatchingMode=AP_ApplicationColorMatching` ColorSync suppression, CUPS printer management links, and Media Type selection UI.
|
||||
- [x] **3D Gamut Visualisation Rework (#185)** (`v0.7.1`): Purpose-built CIELAB axis scaffold with tick marks and crisp CSS2D HTML text labels, clean sRGB reference rendering using `THREE.EdgesGeometry` with faint transparent solid volume, per-vertex true-colour profile gamut shading via `labToSrgb()`, and glassmorphic legend overlay with independent layer visibility toggles.
|
||||
- [x] **Workflow & Visualizer Enhancements (#176, #177, #178, #179)** (`v0.7.0`): Stage 4 OBA/FWA compensation and viewing conditions UI (`-c`, `-d`), global button standardization, Stage 3 swatch grid diagonal split rendering for CIEDE2000 visual comparison, and robust gamut `.gam` dual-table face parsing with regex fixes for profcheck.
|
||||
- [x] **CGATS Dataset Interoperability (#94)** (`v0.6.0`): Native Rust CGATS and Argyll `.ti3` dataset parser, canonical normalizer (0-255 scaling, field aliasing, metadata synthesis), and direct-jump workflow to Stage 4 (Profile Generation) and Stage 5 (Verification) using imported external datasets.
|
||||
- [x] **Stage 1 Layout Normalization (#162)** (`v0.6.1`): Standardized control heights and structural flexbox auto-margin layout for multi-column Stage 1 advanced settings.
|
||||
@@ -95,8 +97,17 @@ ICCery is a native, cross-platform desktop application built with:
|
||||
### Milestone 9 — macOS Native Support & Enhanced Print Spooling (`v0.4.0`)
|
||||
- [ ] **macOS Platform Bundle**: Build and sign universal macOS `.dmg` bundles with notarization.
|
||||
- [ ] **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 12 — Future Workflow & Advanced Analytics (Deferred)
|
||||
- [ ] **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).
|
||||
### Milestone 13 — UI/UX & Workflow Polish
|
||||
|
||||
- [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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "iccery",
|
||||
"private": true,
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"fetch-argyll": "node scripts/fetch-argyll.mjs",
|
||||
|
||||
Generated
+68
-1
@@ -1423,11 +1423,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iccery"
|
||||
version = "0.6.6"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-application-services",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -2037,8 +2042,31 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-text",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-application-services"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69282c2b5bc58fba07cb9de2113619532eb551e98efe3d8d695509ef45fbd53b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-services",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
@@ -2059,6 +2087,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@@ -2070,7 +2099,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@@ -2107,6 +2138,18 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-services"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
@@ -2119,6 +2162,19 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-video"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -2170,6 +2226,17 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-security"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.3.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "iccery"
|
||||
version = "0.7.0"
|
||||
version = "0.8.2"
|
||||
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
||||
authors = ["Gordon"]
|
||||
edition = "2021"
|
||||
@@ -28,6 +28,13 @@ log = "0.4"
|
||||
base64 = "0.22"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "tiff"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3.2", features = ["NSPrintInfo", "NSPrintPanel", "NSPrinter"] }
|
||||
objc2-foundation = "0.3.2"
|
||||
objc2-core-foundation = "0.3.2"
|
||||
objc2-application-services = { version = "0.3.2", features = ["PMCore", "PMDefinitions", "PrintCore"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
|
||||
@@ -2,6 +2,51 @@
|
||||
# Mock script for chartread -u
|
||||
# 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."
|
||||
|
||||
# We don't really wait for input, just wait 1 second
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Mock script for profcheck
|
||||
# Simulates profcheck verification output
|
||||
# Simulates real ArgyllCMS profcheck -v -k -s -u output
|
||||
|
||||
echo "profcheck: Checking profile accuracy..."
|
||||
echo "No of test patches = 52"
|
||||
sleep 1
|
||||
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
|
||||
echo "Summary:"
|
||||
echo " avg. dE = 0.85"
|
||||
echo " max. dE = 2.41"
|
||||
echo " rms. dE = 1.02"
|
||||
echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02"
|
||||
exit 0
|
||||
|
||||
+118
-9
@@ -425,6 +425,34 @@ pub async fn select_target_file(
|
||||
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]
|
||||
pub async fn select_directory(
|
||||
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()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct ChartreadConfig {
|
||||
pub basename: String,
|
||||
pub cwd: String,
|
||||
pub port: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enable_i1pro2_leds: Option<bool>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -857,8 +892,12 @@ pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
|
||||
pub async fn run_chartread(
|
||||
app: AppHandle,
|
||||
state: State<'_, ProcessManager>,
|
||||
config: ChartreadConfig,
|
||||
mut config: ChartreadConfig,
|
||||
) -> 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 args = build_chartread_args(&config);
|
||||
let id = format!("chartread_{}", config.basename);
|
||||
@@ -1073,6 +1112,7 @@ pub fn build_profcheck_args(config: &ProfcheckConfig) -> Vec<String> {
|
||||
"-v".to_string(),
|
||||
"-k".to_string(),
|
||||
"-s".to_string(),
|
||||
"-u".to_string(),
|
||||
config.ti3_path.clone(),
|
||||
config.icc_path.clone(),
|
||||
]
|
||||
@@ -1152,26 +1192,29 @@ pub async fn get_printer_capabilities(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn show_printer_properties(
|
||||
app: AppHandle,
|
||||
state: State<'_, crate::print::PrinterDevModeStore>,
|
||||
printer_name: String,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<Option<crate::print::PrintPropertiesResult>, String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
crate::print::windows::show_printer_properties(&printer_name, &state)
|
||||
let _ = app;
|
||||
crate::print::windows::show_printer_properties(&printer_name, &state)?;
|
||||
Ok(None)
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = state;
|
||||
crate::print::macos::show_printer_properties(&printer_name)
|
||||
crate::print::macos::show_printer_properties(&printer_name, &app).await
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let _ = (state, printer_name);
|
||||
Ok(())
|
||||
let _ = (app, state, printer_name);
|
||||
Ok(None)
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
{
|
||||
let _ = (state, printer_name);
|
||||
let _ = (app, state, printer_name);
|
||||
Err("Printer properties dialog is not supported on this platform.".to_string())
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1598,7 @@ mod tests {
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/home/user".to_string(),
|
||||
port: None,
|
||||
enable_i1pro2_leds: None,
|
||||
};
|
||||
let args = build_chartread_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
|
||||
@@ -1566,6 +1610,7 @@ mod tests {
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/home/user".to_string(),
|
||||
port: Some("".to_string()),
|
||||
enable_i1pro2_leds: None,
|
||||
};
|
||||
let args = build_chartread_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
|
||||
@@ -1577,11 +1622,48 @@ mod tests {
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/home/user".to_string(),
|
||||
port: Some("1".to_string()),
|
||||
enable_i1pro2_leds: None,
|
||||
};
|
||||
let args = build_chartread_args(&config);
|
||||
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]
|
||||
fn test_build_average_args() {
|
||||
let config = AverageConfig {
|
||||
@@ -1780,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]
|
||||
fn test_build_profcheck_args() {
|
||||
let config = ProfcheckConfig {
|
||||
@@ -1788,7 +1897,7 @@ mod tests {
|
||||
cwd: "/home/user".to_string(),
|
||||
};
|
||||
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]
|
||||
|
||||
@@ -5,6 +5,7 @@ mod commands;
|
||||
mod events;
|
||||
mod print;
|
||||
mod process_manager;
|
||||
mod quality_store;
|
||||
mod settings;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@@ -74,6 +75,11 @@ pub fn run() {
|
||||
commands::get_printer_capabilities,
|
||||
commands::show_printer_properties,
|
||||
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::save_settings,
|
||||
settings::get_all_presets,
|
||||
|
||||
+788
-38
@@ -1,15 +1,522 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::ffi::CStr;
|
||||
use std::path::Path;
|
||||
use crate::print::{
|
||||
PrintOptions, Printer, PrinterCapabilities,
|
||||
};
|
||||
pub use crate::print::unix::{
|
||||
get_printer_capabilities, get_printers, merge_printer_info, parse_lpoptions_l,
|
||||
parse_lpstat_d, parse_lpstat_e, parse_lpstat_p,
|
||||
};
|
||||
use std::ptr::NonNull;
|
||||
|
||||
/// Construct `lp` command line arguments for target printing on macOS with ColorSync bypass flags.
|
||||
use objc2_application_services::PMPrintSession;
|
||||
|
||||
use crate::print::{PrintOptions, PrintPropertiesResult};
|
||||
pub use crate::print::unix::{get_printer_capabilities, get_printers};
|
||||
|
||||
extern "C" {
|
||||
fn free(ptr: *mut std::ffi::c_void);
|
||||
}
|
||||
|
||||
/// Run `lpoptions -p <printer> -l` and return the raw PPD option listing,
|
||||
/// or `None` if the command is not available or the printer is not found.
|
||||
fn get_lpoptions_output(printer_name: &str) -> Option<String> {
|
||||
std::process::Command::new("lpoptions")
|
||||
.args(["-p", printer_name, "-l"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
extern "C" {
|
||||
fn dlsym(handle: *mut std::ffi::c_void, symbol: *const std::ffi::c_char) -> *mut std::ffi::c_void;
|
||||
}
|
||||
|
||||
/// Set the Core Printing session's color matching mode to "application" and
|
||||
/// lock it. This is the private SPI used by Photoshop, Lightroom and X-Rite
|
||||
/// i1Profiler to gray-out the Color Matching controls in driver PDEs.
|
||||
///
|
||||
/// The symbols are resolved at runtime with `dlsym` so the binary does not
|
||||
/// hard-depend on an undocumented symbol. All three exported variants
|
||||
/// (PMSessionSetColorMatchingMode, PMSessionSetColorMatchingModeLock,
|
||||
/// PMSessionSetColorMatchingModeNoLock) use the same 2-argument C signature:
|
||||
/// `(PMPrintSession, *const CFString) -> i32`. The "Lock" variant sets the
|
||||
/// mode and locks the UI in a single call; "NoLock" sets it without locking.
|
||||
/// We only attempt application-managed color-matching modes.
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe fn set_session_color_matching_mode(pm_session: PMPrintSession) -> bool {
|
||||
use std::ffi::{c_char, c_void};
|
||||
use objc2_core_foundation::CFString;
|
||||
use objc2_application_services::{PMSessionGetCurrentPrinter, PMPrinter};
|
||||
|
||||
if pm_session.is_null() {
|
||||
log::warn!("pm_session is null; skipping private color-matching SPI");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify session has a printer attached
|
||||
let mut current_printer: PMPrinter = std::ptr::null_mut();
|
||||
let printer_status = PMSessionGetCurrentPrinter(pm_session, (&mut current_printer).into());
|
||||
if printer_status != 0 || current_printer.is_null() {
|
||||
log::warn!(
|
||||
"PMSessionGetCurrentPrinter failed ({}) or returned null printer; skipping SPI",
|
||||
printer_status
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const RTLD_DEFAULT: *mut c_void = (-2isize) as *mut c_void;
|
||||
|
||||
// Load all relevant symbols once. Order matches the priority below:
|
||||
// Lock first (sets and locks in one call), then main, then NoLock.
|
||||
let sym_lock = c"PMSessionSetColorMatchingModeLock";
|
||||
let sym_main = c"PMSessionSetColorMatchingMode";
|
||||
let sym_nolock = c"PMSessionSetColorMatchingModeNoLock";
|
||||
|
||||
let lock_ptr = dlsym(RTLD_DEFAULT, sym_lock.as_ptr() as *const c_char);
|
||||
let main_ptr = dlsym(RTLD_DEFAULT, sym_main.as_ptr() as *const c_char);
|
||||
let nolock_ptr = dlsym(RTLD_DEFAULT, sym_nolock.as_ptr() as *const c_char);
|
||||
|
||||
log::info!(
|
||||
"PMSessionSetColorMatchingMode SPI symbols: lock={:p} main={:p} nolock={:p}",
|
||||
lock_ptr, main_ptr, nolock_ptr
|
||||
);
|
||||
|
||||
// Only application-managed color-matching modes are appropriate for
|
||||
// profiling targets. ColorSync or vendor modes would apply color
|
||||
// management and corrupt the target patches.
|
||||
const MODES: &[&str] = &[
|
||||
"AP_ApplicationColorMatching", // kPMApplicationColorMatching
|
||||
"ApplicationColorMatching", // without AP_ prefix
|
||||
];
|
||||
|
||||
// All three symbols have the same (PMPrintSession, *const CFString) signature.
|
||||
// PMSessionSetColorMatchingModeLock sets and locks in one call, so try it first.
|
||||
type ModeFn = unsafe extern "C" fn(PMPrintSession, *const CFString) -> i32;
|
||||
|
||||
let candidates = [
|
||||
("PMSessionSetColorMatchingModeLock", lock_ptr),
|
||||
("PMSessionSetColorMatchingMode", main_ptr),
|
||||
("PMSessionSetColorMatchingModeNoLock", nolock_ptr),
|
||||
];
|
||||
|
||||
let mut set_success = false;
|
||||
|
||||
for &mode_str in MODES {
|
||||
let mode = CFString::from_str(mode_str);
|
||||
let mode_ptr = &*mode as *const CFString;
|
||||
|
||||
for (name, fn_ptr) in candidates {
|
||||
if !fn_ptr.is_null() {
|
||||
let set_fn: ModeFn = std::mem::transmute(fn_ptr);
|
||||
let status = set_fn(pm_session, mode_ptr);
|
||||
log::info!("{}({}) returned {}", name, mode_str, status);
|
||||
if status == 0 {
|
||||
set_success = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if set_success {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !set_success {
|
||||
log::warn!(
|
||||
"All PMSessionSetColorMatchingMode calls failed; color controls may not be grayed"
|
||||
);
|
||||
}
|
||||
|
||||
set_success
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
unsafe fn set_session_color_matching_mode(_pm_session: PMPrintSession) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// PPD/driver-relevant CUPS option key names that we forward to `lp` when
|
||||
/// captured from the native print panel. Keys not in this set (internal Apple
|
||||
/// ticket keys, generic CUPS bookkeeping, etc.) are discarded.
|
||||
const RELEVANT_CUPS_OPTION_KEYS: &[&str] = &[
|
||||
// Media type
|
||||
"MediaType",
|
||||
"CNIJMediaType",
|
||||
"EPIJ_Medi",
|
||||
"StpMediaType",
|
||||
// Input slot / tray
|
||||
"InputSlot",
|
||||
"AP_D_InputSlot",
|
||||
// Paper size
|
||||
"PageSize",
|
||||
// Driver color management bypass
|
||||
"CNIJIntent2",
|
||||
"CNIJIntent",
|
||||
"EPIJ_CMat",
|
||||
"EPIJ_CCor",
|
||||
"EPIJ_OSColMat",
|
||||
"ColorCorrection",
|
||||
"StpColorCorrection",
|
||||
"EpsonColorMode",
|
||||
"ColorModel",
|
||||
// Quality / resolution
|
||||
"Resolution",
|
||||
"cupsPrintQuality",
|
||||
"Quality",
|
||||
"EPIJ_Quality",
|
||||
"CNIJQuality",
|
||||
"StpQuality",
|
||||
"OutputMode",
|
||||
// Duplex
|
||||
"Duplex",
|
||||
"sides",
|
||||
];
|
||||
|
||||
/// Return true if a CUPS option key/value pair captured from the native print
|
||||
/// panel should be forwarded to the `lp` command.
|
||||
fn is_relevant_cups_option(key: &str, value: &str) -> bool {
|
||||
if key.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Never forward internal Apple ticket keys.
|
||||
if key.starts_with("com.apple.") {
|
||||
return false;
|
||||
}
|
||||
// We always set AP_ColorMatchingMode (and dot-notation) ourselves in build_lp_args.
|
||||
if key == "AP_ColorMatchingMode" || key == "AP.ColorMatchingMode" {
|
||||
return false;
|
||||
}
|
||||
// Skip empty values (e.g. "AP_D_InputSlot=").
|
||||
if value.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Skip generic CUPS bookkeeping keys that don't affect color or quality.
|
||||
if matches!(
|
||||
key,
|
||||
"collate" | "copies" | "pserrorhandler-requested" | "job-sheets"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Keep known PPD option keys, or any other non-Apple key that looks
|
||||
// driver-specific (permissive: unknown driver keys are kept).
|
||||
RELEVANT_CUPS_OPTION_KEYS.contains(&key) || !key.starts_with("com.")
|
||||
}
|
||||
|
||||
/// Filter a raw CUPS options string (as produced by PMPrintSettingsToOptions)
|
||||
/// down to only the PPD-relevant pairs, returning them as a space-separated
|
||||
/// `"key=value key=value"` string.
|
||||
fn filter_cups_options_string(raw: &str) -> Option<String> {
|
||||
let pairs = crate::print::unix::parse_cups_options_string(raw);
|
||||
let filtered: Vec<(String, String)> = pairs
|
||||
.into_iter()
|
||||
.filter(|(k, v)| is_relevant_cups_option(k, v))
|
||||
.collect();
|
||||
if filtered.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
filtered
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the media type value from a filtered CUPS options string, looking
|
||||
/// for the first known media-type key.
|
||||
fn extract_media_type_from_options(options: &str) -> Option<String> {
|
||||
let pairs = crate::print::unix::parse_cups_options_string(options);
|
||||
for (key, value) in &pairs {
|
||||
if matches!(
|
||||
key.as_str(),
|
||||
"MediaType" | "CNIJMediaType" | "EPIJ_Medi" | "StpMediaType"
|
||||
) {
|
||||
if !value.is_empty() {
|
||||
return Some(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Show the native macOS `NSPrintPanel` for the selected printer, pre-configured
|
||||
/// with application-managed color so the driver's color-management controls are
|
||||
/// greyed out. Returns the user-selected printer and captured print settings as
|
||||
/// a `PrintPropertiesResult` snapshot, or `None` if the user cancelled the
|
||||
/// dialog.
|
||||
///
|
||||
/// This function must be called on the Cocoa main thread. The caller is
|
||||
/// responsible for dispatching via `AppHandle::run_on_main_thread`.
|
||||
fn run_native_print_panel(
|
||||
printer_name: &str,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<Option<PrintPropertiesResult>, String> {
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSPrintInfo, NSPrintPanel, NSPrintPanelOptions, NSPrinter};
|
||||
use objc2_application_services::{
|
||||
PMPageFormat, PMPrinter, PMPrinterCreateFromPrinterID,
|
||||
PMPrinterGetID, PMPrintSettings, PMPrintSettingsSetValue,
|
||||
PMPrintSettingsToOptions, PMRelease, PMSessionDefaultPageFormat,
|
||||
PMSessionDefaultPrintSettings, PMSessionGetCurrentPrinter,
|
||||
PMSessionSetCurrentPMPrinter,
|
||||
};
|
||||
use objc2_core_foundation::CFString;
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
let mtm = MainThreadMarker::new()
|
||||
.ok_or("Print panel must be invoked on the main thread")?;
|
||||
|
||||
// Create a fresh NSPrintInfo and initialize it.
|
||||
let print_info = NSPrintInfo::new();
|
||||
print_info.setUpPrintOperationDefaultValues();
|
||||
|
||||
// Bind the CUPS destination (Printer ID) to the session. CUPS destination
|
||||
// IDs are what macOS Core Printing calls "Printer IDs"; they are not the
|
||||
// human-readable display names used by NSPrinter::printerWithName.
|
||||
let printer_id_cf = CFString::from_str(printer_name);
|
||||
let pm_printer: PMPrinter = unsafe { PMPrinterCreateFromPrinterID(&*printer_id_cf) };
|
||||
let printer_from_id = !pm_printer.is_null();
|
||||
|
||||
if pm_printer.is_null() {
|
||||
if let Some(dn) = display_name {
|
||||
// Fallback: try the human-readable display name via NSPrinter. This is
|
||||
// less robust than a direct PMPrinter lookup but allows the panel to
|
||||
// open when the queue is not currently registered in the Core Printing
|
||||
// session under its CUPS ID.
|
||||
let name_ns = NSString::from_str(dn);
|
||||
if let Some(ns_printer) = NSPrinter::printerWithName(&name_ns) {
|
||||
print_info.setPrinter(&ns_printer);
|
||||
print_info.setUpPrintOperationDefaultValues();
|
||||
} else {
|
||||
log::warn!(
|
||||
"PMPrinterCreateFromPrinterID('{}') and NSPrinter::printerWithName('{}') both failed",
|
||||
printer_name, dn
|
||||
);
|
||||
return Err(format!(
|
||||
"No printer found for '{}' (display name: {:?}). The native print panel cannot be opened.",
|
||||
printer_name, display_name
|
||||
));
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
"PMPrinterCreateFromPrinterID('{}') failed and no display name was available",
|
||||
printer_name
|
||||
);
|
||||
return Err(format!(
|
||||
"No printer found for '{}' (display name: {:?}). The native print panel cannot be opened.",
|
||||
printer_name, display_name
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Obtain the underlying Core Printing session and settings now that
|
||||
// NSPrintInfo has been configured. These are valid as long as `print_info`
|
||||
// is retained.
|
||||
let pm_session: PMPrintSession = print_info.PMPrintSession().as_ptr() as PMPrintSession;
|
||||
let pm_settings: PMPrintSettings = print_info.PMPrintSettings().as_ptr() as PMPrintSettings;
|
||||
let pm_page_format = print_info.PMPageFormat().as_ptr() as PMPageFormat;
|
||||
|
||||
if !pm_printer.is_null() {
|
||||
let set_status = unsafe { PMSessionSetCurrentPMPrinter(pm_session, pm_printer) };
|
||||
if set_status != 0 {
|
||||
unsafe { PMRelease(pm_printer as _) };
|
||||
return Err(format!(
|
||||
"PMSessionSetCurrentPMPrinter failed with status {}",
|
||||
set_status
|
||||
));
|
||||
}
|
||||
|
||||
let default_status = unsafe { PMSessionDefaultPrintSettings(pm_session, pm_settings) };
|
||||
if default_status != 0 {
|
||||
log::warn!(
|
||||
"PMSessionDefaultPrintSettings returned status {}",
|
||||
default_status
|
||||
);
|
||||
}
|
||||
|
||||
let default_page_status = unsafe { PMSessionDefaultPageFormat(pm_session, pm_page_format) };
|
||||
if default_page_status != 0 {
|
||||
log::warn!(
|
||||
"PMSessionDefaultPageFormat returned status {}",
|
||||
default_page_status
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the private PMSessionSetColorMatchingMode SPI to gray out and lock
|
||||
// the Color Matching controls in the driver PDE (ColorSync vs. vendor
|
||||
// color). This is separate from the AP_ColorMatchingMode spool setting.
|
||||
let spi_ok = unsafe { set_session_color_matching_mode(pm_session) };
|
||||
if !spi_ok {
|
||||
log::warn!("Native color-matching controls may not be locked; PMPrintSettingsSetValue fallback will be used");
|
||||
}
|
||||
|
||||
// Set AP_ColorMatchingMode = AP_ApplicationColorMatching (and the
|
||||
// dot-notation variant) so the CUPS backend/cgpdftoraster knows the
|
||||
// application already handled color matching.
|
||||
let cm_key = CFString::from_str("AP_ColorMatchingMode");
|
||||
let cm_dot_key = CFString::from_str("AP.ColorMatchingMode");
|
||||
let cm_val = CFString::from_str("AP_ApplicationColorMatching");
|
||||
let cm_val_ref: &objc2_core_foundation::CFType = &*cm_val;
|
||||
for cm_key_ref in [&cm_key, &cm_dot_key] {
|
||||
let set_status = unsafe {
|
||||
PMPrintSettingsSetValue(pm_settings, cm_key_ref, Some(cm_val_ref), true)
|
||||
};
|
||||
if set_status != 0 {
|
||||
log::warn!(
|
||||
"PMPrintSettingsSetValue({}) returned status {}",
|
||||
cm_key_ref,
|
||||
set_status
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-select the driver-specific "no color adjustment" PPD option in the
|
||||
// print settings so the panel shows it as the current choice.
|
||||
let lpoptions = get_lpoptions_output(printer_name);
|
||||
if let Some(ref output) = lpoptions {
|
||||
if let Some((bypass_key, bypass_val)) =
|
||||
crate::print::unix::detect_driver_color_bypass(output)
|
||||
{
|
||||
let bypass_key_cf = CFString::from_str(bypass_key);
|
||||
let bypass_val_cf = CFString::from_str(bypass_val);
|
||||
let bypass_val_ref: &objc2_core_foundation::CFType = &*bypass_val_cf;
|
||||
let set_status = unsafe {
|
||||
PMPrintSettingsSetValue(pm_settings, &bypass_key_cf, Some(bypass_val_ref), false)
|
||||
};
|
||||
if set_status != 0 {
|
||||
log::warn!(
|
||||
"PMPrintSettingsSetValue({}={}) returned status {}",
|
||||
bypass_key, bypass_val, set_status
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print_info.updateFromPMPageFormat();
|
||||
|
||||
// Sync the PMPrintSettings changes back into the NSPrintInfo object.
|
||||
print_info.updateFromPMPrintSettings();
|
||||
|
||||
// Also write the color-matching keys directly into the NSPrintInfo
|
||||
// printSettings dictionary. This is the dictionary that AppKit print
|
||||
// dialog extensions (PDEs) and some raster drivers inspect, so the
|
||||
// value must appear here as well as in the PMPrintSettings object.
|
||||
let cm_key_ns = NSString::from_str("AP_ColorMatchingMode");
|
||||
let cm_dot_key_ns = NSString::from_str("AP.ColorMatchingMode");
|
||||
let cm_val_ns = NSString::from_str("AP_ApplicationColorMatching");
|
||||
|
||||
// Safety: `NSString` has the same memory layout as its root `AnyObject`
|
||||
// (`isa`), so we can borrow it as `&AnyObject` for the dictionary.
|
||||
let as_any = |s: &NSString| -> &objc2::runtime::AnyObject {
|
||||
unsafe { &*(s as *const _ as *const objc2::runtime::AnyObject) }
|
||||
};
|
||||
|
||||
let print_settings = unsafe { print_info.printSettings() };
|
||||
print_settings.insert(&*cm_key_ns, as_any(&*cm_val_ns));
|
||||
print_settings.insert(&*cm_dot_key_ns, as_any(&*cm_val_ns));
|
||||
|
||||
// Also surface the driver-specific color bypass in printSettings so the
|
||||
// panel PDE sees it even if PMPrintSettings didn't sync it.
|
||||
if let Some(ref output) = lpoptions {
|
||||
if let Some((bypass_key, bypass_val)) =
|
||||
crate::print::unix::detect_driver_color_bypass(output)
|
||||
{
|
||||
let bypass_key_ns = NSString::from_str(bypass_key);
|
||||
let bypass_val_ns = NSString::from_str(bypass_val);
|
||||
print_settings.insert(&*bypass_key_ns, as_any(&*bypass_val_ns));
|
||||
}
|
||||
}
|
||||
|
||||
// Create and configure the print panel.
|
||||
let panel = NSPrintPanel::printPanel(mtm);
|
||||
let mut opts = NSPrintPanelOptions::all();
|
||||
opts.insert(NSPrintPanelOptions::ShowsPageSetupAccessory);
|
||||
panel.setOptions(opts);
|
||||
panel.setDefaultButtonTitle(Some(&NSString::from_str("Use Settings")));
|
||||
|
||||
// Run the modal dialog.
|
||||
let response = panel.runModalWithPrintInfo(&print_info);
|
||||
|
||||
// NSModalResponseOK == NSOKButton == 1.
|
||||
if response != 1 {
|
||||
if !pm_printer.is_null() {
|
||||
unsafe { PMRelease(pm_printer as _) };
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Capture the effective printer from the session.
|
||||
let mut current_printer: PMPrinter = std::ptr::null_mut();
|
||||
let get_status = unsafe {
|
||||
PMSessionGetCurrentPrinter(
|
||||
pm_session,
|
||||
NonNull::new(&mut current_printer).unwrap(),
|
||||
)
|
||||
};
|
||||
let selected_printer = if get_status == 0 && !current_printer.is_null() {
|
||||
unsafe {
|
||||
PMPrinterGetID(current_printer).map(|id| format!("{}", id))
|
||||
}
|
||||
} else {
|
||||
if printer_from_id {
|
||||
Some(printer_name.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Extract the updated print settings from the panel.
|
||||
let updated_info = panel.printInfo();
|
||||
let updated_settings: PMPrintSettings =
|
||||
updated_info.PMPrintSettings().as_ptr() as PMPrintSettings;
|
||||
|
||||
let mut opts_ptr: *mut std::ffi::c_char = std::ptr::null_mut();
|
||||
let to_opts_status = unsafe {
|
||||
PMPrintSettingsToOptions(updated_settings, NonNull::new(&mut opts_ptr).unwrap())
|
||||
};
|
||||
|
||||
if to_opts_status != 0 || opts_ptr.is_null() {
|
||||
if !pm_printer.is_null() {
|
||||
unsafe { PMRelease(pm_printer as _) };
|
||||
}
|
||||
return Err(format!(
|
||||
"PMPrintSettingsToOptions failed with status {}",
|
||||
to_opts_status
|
||||
));
|
||||
}
|
||||
|
||||
let opts_cstr = unsafe { CStr::from_ptr(opts_ptr) };
|
||||
let opts_str = opts_cstr.to_string_lossy().into_owned();
|
||||
unsafe { free(opts_ptr as *mut _) };
|
||||
if !pm_printer.is_null() {
|
||||
unsafe { PMRelease(pm_printer as _) };
|
||||
}
|
||||
|
||||
// Filter to PPD-relevant options.
|
||||
let cups_options = filter_cups_options_string(&opts_str);
|
||||
|
||||
// Try to extract a media type from the captured options.
|
||||
let media_type = cups_options
|
||||
.as_ref()
|
||||
.and_then(|s| extract_media_type_from_options(s));
|
||||
|
||||
Ok(Some(PrintPropertiesResult {
|
||||
selected_printer,
|
||||
options: PrintOptions {
|
||||
media_type,
|
||||
cups_options,
|
||||
ppd_uncorrected_passthrough: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// Construct `lp` command line arguments for target printing on macOS with
|
||||
/// ColorSync bypass flags and any user-captured PPD options.
|
||||
pub fn build_lp_args(
|
||||
printer_name: &str,
|
||||
tiff_path: &str,
|
||||
@@ -18,7 +525,9 @@ pub fn build_lp_args(
|
||||
let path = Path::new(tiff_path);
|
||||
let title = format!(
|
||||
"ICCery Target - {}",
|
||||
path.file_name().and_then(|n| n.to_str()).unwrap_or("Profiling Target")
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("Profiling Target")
|
||||
);
|
||||
|
||||
let mut args = vec![
|
||||
@@ -28,38 +537,103 @@ pub fn build_lp_args(
|
||||
title,
|
||||
];
|
||||
|
||||
let ppd_fallback = options
|
||||
.and_then(|o| o.ppd_uncorrected_passthrough)
|
||||
.unwrap_or(false);
|
||||
|
||||
if ppd_fallback {
|
||||
args.push("-o".to_string());
|
||||
args.push("ColorModel=Gray".to_string());
|
||||
args.push("-o".to_string());
|
||||
args.push("cm-calibration".to_string());
|
||||
} else {
|
||||
args.push("-o".to_string());
|
||||
args.push("raw".to_string());
|
||||
}
|
||||
|
||||
// macOS Apple ColorSync suppression flags
|
||||
// Always apply ColorSync bypass on macOS for targeting. The dot-notation
|
||||
// variant is a legacy form used by some raster drivers and PDEs.
|
||||
args.push("-o".to_string());
|
||||
args.push("AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string());
|
||||
args.push("-o".to_string());
|
||||
args.push("AP.ColorMatchingMode=AP_ApplicationColorMatching".to_string());
|
||||
|
||||
// Track which option keys have already been added from cups_options so we
|
||||
// don't duplicate them from the explicit PrintOptions fields.
|
||||
let mut added_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
// If we have captured CUPS options from the native print panel, add them
|
||||
// first. These take precedence over auto-detected defaults.
|
||||
if let Some(opts) = options {
|
||||
if let Some(ref cups_opts) = opts.cups_options {
|
||||
if !cups_opts.trim().is_empty() {
|
||||
for (key, value) in crate::print::unix::parse_cups_options_string(cups_opts) {
|
||||
if !key.is_empty() {
|
||||
args.push("-o".to_string());
|
||||
args.push(format!("{}={}", key, value));
|
||||
added_keys.insert(key.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch lpoptions for this printer to detect capabilities.
|
||||
let lpoptions_output: Option<String> = get_lpoptions_output(printer_name);
|
||||
|
||||
// Add media type from explicit options if not already in cups_options.
|
||||
if let Some(opts) = options {
|
||||
if let Some(ref media_type) = opts.media_type {
|
||||
if !media_type.trim().is_empty()
|
||||
&& !added_keys.contains("mediatype")
|
||||
&& !added_keys.contains("cnijmediatype")
|
||||
&& !added_keys.contains("epij_medi")
|
||||
&& !added_keys.contains("stpmediatype")
|
||||
{
|
||||
if let Some(ref output_str) = lpoptions_output {
|
||||
let key = crate::print::unix::detect_media_type_key(output_str);
|
||||
args.push("-o".to_string());
|
||||
args.push(format!("{}={}", key, media_type.trim()));
|
||||
added_keys.insert(key.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always detect and add the driver color bypass (not gated on
|
||||
// ppd_uncorrected_passthrough). If cups_options already contains a color
|
||||
// bypass key, skip this to avoid duplicates.
|
||||
let color_bypass_keys = [
|
||||
"cnijintent2",
|
||||
"cnijintent",
|
||||
"epij_cmat",
|
||||
"epij_ccor",
|
||||
"epij_oscolmat",
|
||||
"colorcorrection",
|
||||
"stpcolorcorrection",
|
||||
"epsoncolormode",
|
||||
];
|
||||
let has_color_bypass = added_keys
|
||||
.iter()
|
||||
.any(|k| color_bypass_keys.contains(&k.as_str()));
|
||||
if !has_color_bypass {
|
||||
if let Some(ref output_str) = lpoptions_output {
|
||||
if let Some((bypass_key, bypass_val)) =
|
||||
crate::print::unix::detect_driver_color_bypass(output_str)
|
||||
{
|
||||
args.push("-o".to_string());
|
||||
args.push(format!("{}={}", bypass_key, bypass_val));
|
||||
added_keys.insert(bypass_key.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add orientation and paper size from explicit options if not already
|
||||
// present in cups_options.
|
||||
if let Some(opts) = options {
|
||||
if let Some(ref orient) = opts.orientation {
|
||||
args.push("-o".to_string());
|
||||
if orient.eq_ignore_ascii_case("landscape") {
|
||||
args.push("orientation-requested=4".to_string());
|
||||
} else {
|
||||
args.push("orientation-requested=3".to_string());
|
||||
if !added_keys.contains("orientation-requested") {
|
||||
args.push("-o".to_string());
|
||||
if orient.eq_ignore_ascii_case("landscape") {
|
||||
args.push("orientation-requested=4".to_string());
|
||||
} else {
|
||||
args.push("orientation-requested=3".to_string());
|
||||
}
|
||||
added_keys.insert("orientation-requested".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref page_size) = opts.paper_size {
|
||||
if !page_size.trim().is_empty() {
|
||||
if !page_size.trim().is_empty() && !added_keys.contains("pagesize") {
|
||||
args.push("-o".to_string());
|
||||
args.push(format!("PageSize={}", page_size.trim()));
|
||||
added_keys.insert("pagesize".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,13 +676,189 @@ pub fn print_target(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open macOS printer queue / properties management or inspect queue options.
|
||||
pub fn show_printer_properties(printer_name: &str) -> Result<(), String> {
|
||||
// On macOS, open Print & Scan preference pane or query printer status via lpstat
|
||||
let _ = std::process::Command::new("open")
|
||||
.args(["x-apple.systempreferences:com.apple.preference.printfax"])
|
||||
.spawn();
|
||||
/// Open the native macOS `NSPrintPanel` for the selected printer.
|
||||
///
|
||||
/// The panel is pre-configured with `AP_ColorMatchingMode=AP_ApplicationColorMatching`
|
||||
/// so the driver's color-management controls are greyed out (application manages
|
||||
/// color). On OK, the user's media type / quality choices are captured and
|
||||
/// returned as a `PrintPropertiesResult` snapshot for the frontend to feed back
|
||||
/// into `print_target_native`.
|
||||
///
|
||||
/// This function dispatches the dialog to the Cocoa main thread via
|
||||
/// `AppHandle::run_on_main_thread` and awaits the result.
|
||||
pub async fn show_printer_properties(
|
||||
printer_name: &str,
|
||||
app: &tauri::AppHandle,
|
||||
) -> Result<Option<PrintPropertiesResult>, String> {
|
||||
// Resolve the human-readable display name before entering the main thread
|
||||
// so we have a fallback if PMPrinterCreateFromPrinterID fails.
|
||||
let display_name = crate::print::unix::get_printer_display_name(printer_name);
|
||||
|
||||
let _ = printer_name;
|
||||
Ok(())
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<Result<Option<PrintPropertiesResult>, String>>();
|
||||
let printer_name_owned = printer_name.to_string();
|
||||
let display_name_owned = display_name;
|
||||
|
||||
app.run_on_main_thread(move || {
|
||||
let display_name_ref = display_name_owned.as_deref();
|
||||
let result = run_native_print_panel(&printer_name_owned, display_name_ref);
|
||||
let _ = tx.send(result);
|
||||
})
|
||||
.map_err(|e| format!("Failed to dispatch print panel to main thread: {}", e))?;
|
||||
|
||||
rx.await
|
||||
.map_err(|e| format!("Print panel channel closed unexpectedly: {}", e))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_filter_cups_options_string_basic() {
|
||||
let raw = "MediaType=92 EPIJ_CMat=3 com.apple.print.PrintSettings.PMCopies..n.=1 collate=False PageSize=A4 AP_ColorMatchingMode=AP_ApplicationColorMatching AP_D_InputSlot= copies=1";
|
||||
let filtered = filter_cups_options_string(raw).unwrap();
|
||||
// Should contain MediaType, EPIJ_CMat, PageSize but not com.apple.*,
|
||||
// collate, copies, AP_ColorMatchingMode, or empty AP_D_InputSlot.
|
||||
assert!(filtered.contains("MediaType=92"));
|
||||
assert!(filtered.contains("EPIJ_CMat=3"));
|
||||
assert!(filtered.contains("PageSize=A4"));
|
||||
assert!(!filtered.contains("com.apple."));
|
||||
assert!(!filtered.contains("collate"));
|
||||
assert!(!filtered.contains("copies"));
|
||||
assert!(!filtered.contains("AP_ColorMatchingMode"));
|
||||
assert!(!filtered.contains("AP_D_InputSlot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_cups_options_string_empty() {
|
||||
let raw = "com.apple.print.PrintSettings.PMCopies..n.=1 collate=False";
|
||||
assert!(filter_cups_options_string(raw).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_media_type_from_options() {
|
||||
let opts = "MediaType=92 EPIJ_CMat=3 PageSize=A4";
|
||||
assert_eq!(
|
||||
extract_media_type_from_options(opts),
|
||||
Some("92".to_string())
|
||||
);
|
||||
|
||||
let opts2 = "EPIJ_Medi=13 PageSize=A4";
|
||||
assert_eq!(
|
||||
extract_media_type_from_options(opts2),
|
||||
Some("13".to_string())
|
||||
);
|
||||
|
||||
let opts3 = "PageSize=A4";
|
||||
assert_eq!(extract_media_type_from_options(opts3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_with_cups_options() {
|
||||
let opts = PrintOptions {
|
||||
cups_options: Some("MediaType=92 EPIJ_CMat=3 PageSize=A4".to_string()),
|
||||
ppd_uncorrected_passthrough: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts));
|
||||
assert!(args.contains(&"AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string()));
|
||||
assert!(args.contains(&"MediaType=92".to_string()));
|
||||
assert!(args.contains(&"EPIJ_CMat=3".to_string()));
|
||||
assert!(args.contains(&"PageSize=A4".to_string()));
|
||||
assert_eq!(args.last().unwrap(), "/tmp/target.tif");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_no_duplicate_media_type() {
|
||||
// When cups_options contains a MediaType, the explicit media_type
|
||||
// field should NOT also be added.
|
||||
let opts = PrintOptions {
|
||||
cups_options: Some("MediaType=92".to_string()),
|
||||
media_type: Some("13".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts));
|
||||
// Should contain MediaType=92 (from cups_options) but NOT MediaType=13
|
||||
assert!(args.contains(&"MediaType=92".to_string()));
|
||||
assert!(!args.contains(&"MediaType=13".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_no_duplicate_color_bypass() {
|
||||
// When cups_options contains a color bypass key, the auto-detected
|
||||
// bypass should NOT also be added.
|
||||
let opts = PrintOptions {
|
||||
cups_options: Some("EPIJ_CMat=3".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts));
|
||||
// Should contain EPIJ_CMat=3 exactly once (from cups_options).
|
||||
let count = args.iter().filter(|a| *a == "EPIJ_CMat=3").count();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
// Same for Canon's CNIJIntent2 path.
|
||||
let opts2 = PrintOptions {
|
||||
cups_options: Some("CNIJIntent2=4".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let args2 = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts2));
|
||||
let count2 = args2.iter().filter(|a| *a == "CNIJIntent2=4").count();
|
||||
assert_eq!(count2, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_colorsync_always_present() {
|
||||
// Even with no options, AP_ColorMatchingMode and the dot-notation
|
||||
// variant should be present.
|
||||
let args = build_lp_args("Test_Printer", "/tmp/target.tif", None);
|
||||
assert!(args.contains(&"AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string()));
|
||||
assert!(args.contains(&"AP.ColorMatchingMode=AP_ApplicationColorMatching".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_orientation_not_duplicated() {
|
||||
let opts = PrintOptions {
|
||||
cups_options: Some("orientation-requested=4".to_string()),
|
||||
orientation: Some("portrait".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts));
|
||||
// Should contain orientation-requested=4 from cups_options, but NOT
|
||||
// orientation-requested=3 from the explicit portrait setting.
|
||||
assert!(args.contains(&"orientation-requested=4".to_string()));
|
||||
assert!(!args.contains(&"orientation-requested=3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_cups_options_string_keeps_color_bypass_keys() {
|
||||
let raw = "com.apple.print.PrintSettings.PMCopies..n.=1 collate=False CNIJIntent2=4 EPIJ_CMat=3 ColorCorrection=Uncorrected PageSize=A4";
|
||||
let filtered = filter_cups_options_string(raw).unwrap();
|
||||
assert!(filtered.contains("CNIJIntent2=4"));
|
||||
assert!(filtered.contains("EPIJ_CMat=3"));
|
||||
assert!(filtered.contains("ColorCorrection=Uncorrected"));
|
||||
assert!(filtered.contains("PageSize=A4"));
|
||||
assert!(!filtered.contains("com.apple."));
|
||||
assert!(!filtered.contains("collate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_driver_color_bypass_canon_and_epson() {
|
||||
let canon = "CNIJIntent2/Color Mode: 1 4 3 *StpColorCorrection/Color Correction: None Correct Uncorrected";
|
||||
assert_eq!(
|
||||
crate::print::unix::detect_driver_color_bypass(canon),
|
||||
Some(("CNIJIntent2", "4"))
|
||||
);
|
||||
|
||||
let epson = "EPIJ_CMat/Color Settings: 1 2 *3 StpColorCorrection/Color Correction: None Correct Uncorrected";
|
||||
assert_eq!(
|
||||
crate::print::unix::detect_driver_color_bypass(epson),
|
||||
Some(("EPIJ_CMat", "3"))
|
||||
);
|
||||
|
||||
let gutenprint = "StpColorCorrection/Color Correction: None Correct *Uncorrected";
|
||||
assert_eq!(
|
||||
crate::print::unix::detect_driver_color_bypass(gutenprint),
|
||||
Some(("StpColorCorrection", "Uncorrected"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ pub struct Printer {
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub is_default: bool,
|
||||
/// Human-readable name from CUPS `printer-info` (macOS display name).
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -21,10 +24,18 @@ pub struct PrinterPaperSize {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PrinterMediaType {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PrinterCapabilities {
|
||||
pub trays: Vec<PrinterTray>,
|
||||
pub paper_sizes: Vec<PrinterPaperSize>,
|
||||
#[serde(default)]
|
||||
pub media_types: Vec<PrinterMediaType>,
|
||||
pub supports_orientation: bool,
|
||||
}
|
||||
|
||||
@@ -33,7 +44,20 @@ pub struct PrintOptions {
|
||||
pub paper_source: Option<u16>,
|
||||
pub orientation: Option<String>,
|
||||
pub paper_size: Option<String>,
|
||||
pub media_type: Option<String>,
|
||||
pub ppd_uncorrected_passthrough: Option<bool>,
|
||||
/// Optional CUPS option string ("name=value name=value ...") captured from
|
||||
/// the native macOS print panel. On macOS these are appended to the `lp`
|
||||
/// invocation as additional `-o name=value` arguments, taking precedence
|
||||
/// over auto-detected defaults. On other platforms this field is ignored.
|
||||
#[serde(default)]
|
||||
pub cups_options: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PrintPropertiesResult {
|
||||
pub selected_printer: Option<String>,
|
||||
pub options: PrintOptions,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
use crate::print::{
|
||||
PrintOptions, Printer, PrinterCapabilities, PrinterDevModeStore, PrinterPaperSize,
|
||||
PrinterTray,
|
||||
PrintOptions, Printer, PrinterCapabilities, PrinterDevModeStore, PrinterMediaType,
|
||||
PrinterPaperSize, PrinterTray,
|
||||
};
|
||||
use crate::print::unix::{build_lp_args, parse_lpstat_d, parse_lpstat_e, parse_lpstat_p, print_target};
|
||||
|
||||
@@ -12,6 +12,7 @@ mod integration_tests {
|
||||
name: "Epson-Stylus-Pro-4900".to_string(),
|
||||
status: "Idle".to_string(),
|
||||
is_default: true,
|
||||
display_name: Some("Epson Stylus Pro 4900".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&printer).expect("Failed to serialize printer");
|
||||
@@ -46,6 +47,10 @@ mod integration_tests {
|
||||
name: "Letter (8.5 x 11 in)".to_string(),
|
||||
},
|
||||
],
|
||||
media_types: vec![PrinterMediaType {
|
||||
id: "1".to_string(),
|
||||
name: "Plain Paper".to_string(),
|
||||
}],
|
||||
supports_orientation: true,
|
||||
};
|
||||
|
||||
@@ -53,6 +58,7 @@ mod integration_tests {
|
||||
assert!(json.contains("Auto Sheet Feeder"));
|
||||
assert!(json.contains("Rear Manual Feed"));
|
||||
assert!(json.contains("A4 (210 x 297 mm)"));
|
||||
assert!(json.contains("Plain Paper"));
|
||||
|
||||
let deserialized: PrinterCapabilities =
|
||||
serde_json::from_str(&json).expect("Failed to deserialize capabilities");
|
||||
@@ -65,7 +71,9 @@ mod integration_tests {
|
||||
paper_source: Some(2),
|
||||
orientation: Some("landscape".to_string()),
|
||||
paper_size: Some("A4".to_string()),
|
||||
media_type: Some("1".to_string()),
|
||||
ppd_uncorrected_passthrough: Some(false),
|
||||
cups_options: Some("MediaType=1".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&opts).expect("Failed to serialize PrintOptions");
|
||||
@@ -74,6 +82,27 @@ mod integration_tests {
|
||||
assert_eq!(deserialized.paper_source, Some(2));
|
||||
assert_eq!(deserialized.orientation.as_deref(), Some("landscape"));
|
||||
assert_eq!(deserialized.paper_size.as_deref(), Some("A4"));
|
||||
assert_eq!(deserialized.media_type.as_deref(), Some("1"));
|
||||
assert_eq!(deserialized.cups_options.as_deref(), Some("MediaType=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_print_options_cups_options_default_none() {
|
||||
// When cups_options is not set, it should default to None and
|
||||
// serialize/deserialize correctly.
|
||||
let opts = PrintOptions {
|
||||
paper_source: None,
|
||||
orientation: None,
|
||||
paper_size: None,
|
||||
media_type: None,
|
||||
ppd_uncorrected_passthrough: None,
|
||||
cups_options: None,
|
||||
};
|
||||
let json = serde_json::to_string(&opts).expect("Failed to serialize");
|
||||
assert!(!json.contains("cups_options") || json.contains("\"cups_options\":null"));
|
||||
let deserialized: PrintOptions =
|
||||
serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
assert_eq!(deserialized.cups_options, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -221,7 +250,9 @@ printer Custom_Queue unknown state\n\
|
||||
paper_source: Some(3),
|
||||
orientation: Some("landscape".to_string()),
|
||||
paper_size: None,
|
||||
media_type: Some("4".to_string()),
|
||||
ppd_uncorrected_passthrough: None,
|
||||
cups_options: None,
|
||||
};
|
||||
|
||||
let mut work_buf = retrieved;
|
||||
@@ -235,6 +266,8 @@ printer Custom_Queue unknown state\n\
|
||||
assert_eq!((*p_work_devmode).Anonymous1.Anonymous1.dmDefaultSource, 3);
|
||||
// Verify orientation override
|
||||
assert_eq!((*p_work_devmode).Anonymous1.Anonymous1.dmOrientation, 2); // DMORIENT_LANDSCAPE = 2
|
||||
// Verify media type override
|
||||
assert_eq!((*p_work_devmode).dmMediaType, 4);
|
||||
}
|
||||
|
||||
// Verify private OEM payload was NOT corrupted or overwritten
|
||||
@@ -255,11 +288,10 @@ printer Custom_Queue unknown state\n\
|
||||
assert_eq!(args_raw[1], printer);
|
||||
assert_eq!(args_raw[2], "-t");
|
||||
assert_eq!(args_raw[3], "ICCery Target - target_page_1.tif");
|
||||
assert_eq!(args_raw[4], "-o");
|
||||
assert_eq!(args_raw[5], "raw");
|
||||
assert_eq!(args_raw[6], "-o");
|
||||
assert_eq!(args_raw[7], "AP_ColorMatchingMode=AP_ApplicationColorMatching");
|
||||
assert_eq!(args_raw[8], path);
|
||||
assert!(args_raw.contains(&"-o".to_string()));
|
||||
assert!(args_raw.contains(&"AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string()));
|
||||
assert!(args_raw.contains(&"AP.ColorMatchingMode=AP_ApplicationColorMatching".to_string()));
|
||||
assert_eq!(args_raw.last().unwrap(), path);
|
||||
|
||||
// PPD uncorrected passthrough mode with options
|
||||
let opts = PrintOptions {
|
||||
@@ -269,17 +301,12 @@ printer Custom_Queue unknown state\n\
|
||||
..Default::default()
|
||||
};
|
||||
let args_ppd = macos_build_lp_args(printer, path, Some(&opts));
|
||||
assert_eq!(args_ppd[4], "-o");
|
||||
assert_eq!(args_ppd[5], "ColorModel=Gray");
|
||||
assert_eq!(args_ppd[6], "-o");
|
||||
assert_eq!(args_ppd[7], "cm-calibration");
|
||||
assert_eq!(args_ppd[8], "-o");
|
||||
assert_eq!(args_ppd[9], "AP_ColorMatchingMode=AP_ApplicationColorMatching");
|
||||
assert_eq!(args_ppd[10], "-o");
|
||||
assert_eq!(args_ppd[11], "orientation-requested=4");
|
||||
assert_eq!(args_ppd[12], "-o");
|
||||
assert_eq!(args_ppd[13], "PageSize=A4");
|
||||
assert_eq!(args_ppd[14], path);
|
||||
assert!(args_ppd.contains(&"-o".to_string()));
|
||||
assert!(args_ppd.contains(&"AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string()));
|
||||
assert!(args_ppd.contains(&"AP.ColorMatchingMode=AP_ApplicationColorMatching".to_string()));
|
||||
assert!(args_ppd.contains(&"orientation-requested=4".to_string()));
|
||||
assert!(args_ppd.contains(&"PageSize=A4".to_string()));
|
||||
assert_eq!(args_ppd.last().unwrap(), path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+254
-89
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::path::Path;
|
||||
use crate::print::{
|
||||
PrintOptions, Printer, PrinterCapabilities, PrinterPaperSize, PrinterTray,
|
||||
PrintOptions, Printer, PrinterCapabilities, PrinterPaperSize, PrinterTray, PrinterMediaType,
|
||||
};
|
||||
|
||||
/// Parse `lpstat -e` output into a list of printer destination names.
|
||||
@@ -55,6 +55,8 @@ pub fn parse_lpstat_p(output: &str) -> Vec<(String, String)> {
|
||||
}
|
||||
|
||||
/// Merge printer lists from `lpstat -e`, `lpstat -p`, and `lpstat -d`.
|
||||
/// Optionally enriches each printer with a display name fetched from
|
||||
/// `lpoptions -p <name>`.
|
||||
pub fn merge_printer_info(
|
||||
destinations: &[String],
|
||||
statuses: &[(String, String)],
|
||||
@@ -67,6 +69,18 @@ pub fn merge_printer_info(
|
||||
.map(|(name, status)| (name.as_str(), status.as_str()))
|
||||
.collect();
|
||||
|
||||
fn get_display_name(name: &str) -> Option<String> {
|
||||
let out = std::process::Command::new("lpoptions")
|
||||
.args(["-p", name])
|
||||
.output()
|
||||
.ok()?;
|
||||
if out.status.success() {
|
||||
extract_printer_display_name(&String::from_utf8_lossy(&out.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
|
||||
@@ -78,6 +92,7 @@ pub fn merge_printer_info(
|
||||
name: name.clone(),
|
||||
status: status.to_string(),
|
||||
is_default,
|
||||
display_name: get_display_name(name),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -89,6 +104,7 @@ pub fn merge_printer_info(
|
||||
name: name.clone(),
|
||||
status: status.clone(),
|
||||
is_default,
|
||||
display_name: get_display_name(name),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -96,6 +112,33 @@ pub fn merge_printer_info(
|
||||
result
|
||||
}
|
||||
|
||||
/// Extract the CUPS `printer-info` display name from `lpoptions -p <name>` output.
|
||||
/// This is the human-readable queue name that macOS uses in the print panel.
|
||||
pub fn extract_printer_display_name(output: &str) -> Option<String> {
|
||||
for line in output.lines() {
|
||||
if let Some(info) = line.strip_prefix("printer-info=") {
|
||||
let trimmed = info.trim().trim_matches('\'');
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Fetch the CUPS `printer-info` display name for a single destination.
|
||||
pub fn get_printer_display_name(name: &str) -> Option<String> {
|
||||
let out = std::process::Command::new("lpoptions")
|
||||
.args(["-p", name])
|
||||
.output()
|
||||
.ok()?;
|
||||
if out.status.success() {
|
||||
extract_printer_display_name(&String::from_utf8_lossy(&out.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Query available CUPS printers, connection statuses, and default destination.
|
||||
pub fn get_printers() -> Result<Vec<Printer>, String> {
|
||||
let e_output = std::process::Command::new("lpstat")
|
||||
@@ -134,10 +177,11 @@ pub fn get_printers() -> Result<Vec<Printer>, String> {
|
||||
Ok(merge_printer_info(&destinations, &statuses, default_dest.as_deref()))
|
||||
}
|
||||
|
||||
/// Parse `lpoptions -p <printer> -l` output into trays and paper sizes.
|
||||
pub fn parse_lpoptions_l(output: &str) -> (Vec<PrinterTray>, Vec<PrinterPaperSize>) {
|
||||
/// Parse `lpoptions -p <printer> -l` output into trays and paper sizes and media types.
|
||||
pub fn parse_lpoptions_l(output: &str) -> (Vec<PrinterTray>, Vec<PrinterPaperSize>, Vec<PrinterMediaType>) {
|
||||
let mut trays = Vec::new();
|
||||
let mut paper_sizes = Vec::new();
|
||||
let mut media_types = Vec::new();
|
||||
|
||||
for line in output.lines() {
|
||||
let trimmed = line.trim();
|
||||
@@ -165,11 +209,126 @@ pub fn parse_lpoptions_l(output: &str) -> (Vec<PrinterTray>, Vec<PrinterPaperSiz
|
||||
name: clean_val.to_string(),
|
||||
});
|
||||
}
|
||||
} else if key_name.eq_ignore_ascii_case("CNIJMediaType")
|
||||
|| key_name.eq_ignore_ascii_case("MediaType")
|
||||
|| key_name.eq_ignore_ascii_case("StpMediaType")
|
||||
|| key_name.eq_ignore_ascii_case("EPIJ_Medi")
|
||||
{
|
||||
for val in values {
|
||||
let clean_val = val.trim_start_matches('*');
|
||||
media_types.push(PrinterMediaType {
|
||||
id: clean_val.to_string(),
|
||||
name: clean_val.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(trays, paper_sizes)
|
||||
(trays, paper_sizes, media_types)
|
||||
}
|
||||
|
||||
pub fn parse_ppd_media_types(ppd_content: &str) -> Vec<PrinterMediaType> {
|
||||
let mut media = Vec::new();
|
||||
for line in ppd_content.lines() {
|
||||
if line.starts_with("*CNIJMediaType ")
|
||||
|| line.starts_with("*MediaType ")
|
||||
|| line.starts_with("*StpMediaType ")
|
||||
|| line.starts_with("*EPIJ_Medi ")
|
||||
{
|
||||
if let Some((id_part, name_part)) = line.split_once('/') {
|
||||
let id = id_part.split_whitespace().last().unwrap_or("").trim().to_string();
|
||||
let name = name_part.split(':').next().unwrap_or("").trim().to_string();
|
||||
if !id.is_empty() {
|
||||
media.push(PrinterMediaType { id, name });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
media
|
||||
}
|
||||
|
||||
pub fn detect_driver_color_bypass(output: &str) -> Option<(&'static str, &'static str)> {
|
||||
if output.contains("CNIJIntent2") {
|
||||
Some(("CNIJIntent2", "4"))
|
||||
} else if output.contains("CNIJIntent") {
|
||||
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") {
|
||||
// Epson "Color Settings" option. The value 3 is "Off (No Color
|
||||
// Adjustment)" which disables driver-side color management.
|
||||
Some(("EPIJ_CMat", "3"))
|
||||
} else if output.contains("StpColorCorrection") {
|
||||
Some(("StpColorCorrection", "Uncorrected"))
|
||||
} else if output.contains("ColorCorrection") {
|
||||
Some(("ColorCorrection", "Uncorrected"))
|
||||
} else if output.contains("EpsonColorMode") {
|
||||
Some(("EpsonColorMode", "Off"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect_media_type_key(output: &str) -> &'static str {
|
||||
if output.contains("CNIJMediaType") {
|
||||
"CNIJMediaType"
|
||||
} else if output.contains("EPIJ_Medi") {
|
||||
"EPIJ_Medi"
|
||||
} else if output.contains("StpMediaType") {
|
||||
"StpMediaType"
|
||||
} else {
|
||||
"MediaType"
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a CUPS options string of the form `"name=value name=value ..."` into
|
||||
/// an ordered vector of `(name, value)` pairs. Tokens are separated by
|
||||
/// whitespace. Values may be quoted with double quotes; the quotes are
|
||||
/// preserved in the value so the caller can decide whether to strip them.
|
||||
/// Malformed tokens without `=` are skipped.
|
||||
pub fn parse_cups_options_string(options: &str) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = options.chars().peekable();
|
||||
|
||||
while chars.peek().is_some() {
|
||||
// Skip leading whitespace.
|
||||
while chars.peek().map_or(false, |c| c.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
|
||||
// Collect the token, respecting double-quoted substrings.
|
||||
let mut token = String::new();
|
||||
let mut in_quote = false;
|
||||
while let Some(c) = chars.peek() {
|
||||
match c {
|
||||
'"' => {
|
||||
in_quote = !in_quote;
|
||||
token.push(*c);
|
||||
chars.next();
|
||||
}
|
||||
c if c.is_whitespace() && !in_quote => break,
|
||||
_ => {
|
||||
token.push(*c);
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if token.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((name, value)) = token.split_once('=') {
|
||||
let name = name.trim().trim_matches('"').to_string();
|
||||
let value = value.trim().trim_matches('"').to_string();
|
||||
if !name.is_empty() {
|
||||
out.push((name, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Query CUPS printer capabilities via `lpoptions -p <printer> -l`.
|
||||
@@ -178,16 +337,24 @@ pub fn get_printer_capabilities(printer_name: &str) -> Result<PrinterCapabilitie
|
||||
.args(["-p", printer_name, "-l"])
|
||||
.output();
|
||||
|
||||
let (trays, paper_sizes) = match output {
|
||||
let (trays, paper_sizes, mut media_types) = match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
parse_lpoptions_l(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
_ => (Vec::new(), Vec::new(), Vec::new()),
|
||||
};
|
||||
|
||||
if let Ok(ppd_content) = std::fs::read_to_string(format!("/etc/cups/ppd/{}.ppd", printer_name)) {
|
||||
let ppd_media = parse_ppd_media_types(&ppd_content);
|
||||
if !ppd_media.is_empty() {
|
||||
media_types = ppd_media;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PrinterCapabilities {
|
||||
trays,
|
||||
paper_sizes,
|
||||
media_types,
|
||||
supports_orientation: true,
|
||||
})
|
||||
}
|
||||
@@ -241,6 +408,14 @@ pub fn build_lp_args(
|
||||
args.push(format!("PageSize={}", page_size.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref media_type) = opts.media_type {
|
||||
if !media_type.trim().is_empty() {
|
||||
// Here we just use a generic MediaType, but macos.rs will use the detected key
|
||||
args.push("-o".to_string());
|
||||
args.push(format!("MediaType={}", media_type.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
args.push(tiff_path.to_string());
|
||||
@@ -343,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);
|
||||
assert_eq!(merged.len(), 4);
|
||||
|
||||
assert_eq!(
|
||||
merged[0],
|
||||
Printer {
|
||||
name: "Epson-Stylus-SX420W".to_string(),
|
||||
status: "Idle".to_string(),
|
||||
is_default: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
merged[1],
|
||||
Printer {
|
||||
name: "HP_LaserJet".to_string(),
|
||||
status: "Printing".to_string(),
|
||||
is_default: false,
|
||||
}
|
||||
);
|
||||
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,
|
||||
}
|
||||
);
|
||||
assert_eq!(merged[0].name, "Epson-Stylus-SX420W".to_string());
|
||||
assert_eq!(merged[0].status, "Idle".to_string());
|
||||
assert!(merged[0].is_default);
|
||||
|
||||
assert_eq!(merged[1].name, "HP_LaserJet".to_string());
|
||||
assert_eq!(merged[1].status, "Printing".to_string());
|
||||
assert!(!merged[1].is_default);
|
||||
|
||||
assert_eq!(merged[2].name, "Virtual_PDF".to_string());
|
||||
assert_eq!(merged[2].status, "Idle".to_string());
|
||||
assert!(!merged[2].is_default);
|
||||
|
||||
assert_eq!(merged[3].name, "Zebra_Label".to_string());
|
||||
assert_eq!(merged[3].status, "Stopped".to_string());
|
||||
assert!(!merged[3].is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -384,7 +542,7 @@ PageSize/Media Size: *A4 Letter Legal A3\n\
|
||||
InputSlot/Media Source: *Auto Upper Lower Rear Manual\n\
|
||||
Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\
|
||||
";
|
||||
let (trays, paper_sizes) = parse_lpoptions_l(sample);
|
||||
let (trays, paper_sizes, _media_types) = parse_lpoptions_l(sample);
|
||||
assert_eq!(trays.len(), 5);
|
||||
assert_eq!(trays[0].name, "Auto");
|
||||
assert_eq!(trays[1].name, "Upper");
|
||||
@@ -396,43 +554,64 @@ Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_raw() {
|
||||
let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", None);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-d",
|
||||
"Epson-Stylus-SX420W",
|
||||
"-t",
|
||||
"ICCery Target - target.tif",
|
||||
"-o",
|
||||
"raw",
|
||||
"/tmp/target.tif"
|
||||
]
|
||||
);
|
||||
fn test_parse_ppd_media_types() {
|
||||
let sample = "\
|
||||
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: \"\"
|
||||
*CNIJMediaType 43/Photo Paper Pro Platinum: \"\"
|
||||
";
|
||||
let media = parse_ppd_media_types(sample);
|
||||
assert_eq!(media.len(), 2);
|
||||
assert_eq!(media[0].id, "42");
|
||||
assert_eq!(media[0].name, "Photo Paper Plus Semi-gloss");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_ppd_fallback() {
|
||||
let opts = PrintOptions {
|
||||
ppd_uncorrected_passthrough: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", Some(&opts));
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-d",
|
||||
"Epson-Stylus-SX420W",
|
||||
"-t",
|
||||
"ICCery Target - target.tif",
|
||||
"-o",
|
||||
"ColorModel=Gray",
|
||||
"-o",
|
||||
"cm-calibration",
|
||||
"/tmp/target.tif"
|
||||
]
|
||||
);
|
||||
fn test_detect_driver_color_bypass() {
|
||||
assert_eq!(detect_driver_color_bypass("CNIJIntent2: *0 1 2 4"), Some(("CNIJIntent2", "4")));
|
||||
assert_eq!(detect_driver_color_bypass("EPIJ_CCor: *0 3 4 6 12"), Some(("EPIJ_CCor", "0")));
|
||||
assert_eq!(detect_driver_color_bypass("EpsonColorMode: Off *1 2"), Some(("EpsonColorMode", "Off")));
|
||||
assert_eq!(detect_driver_color_bypass("SomethingElse: 1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_media_type_key_epson() {
|
||||
assert_eq!(detect_media_type_key("EPIJ_Medi: *0 92 13"), "EPIJ_Medi");
|
||||
assert_eq!(detect_media_type_key("CNIJMediaType: *1 2"), "CNIJMediaType");
|
||||
assert_eq!(detect_media_type_key("StpMediaType: *1"), "StpMediaType");
|
||||
assert_eq!(detect_media_type_key("MediaType: *Plain"), "MediaType");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cups_options_string() {
|
||||
let opts = parse_cups_options_string("MediaType=92 EPIJ_CCor=0 PageSize=A4");
|
||||
assert_eq!(opts, vec![
|
||||
("MediaType".to_string(), "92".to_string()),
|
||||
("EPIJ_CCor".to_string(), "0".to_string()),
|
||||
("PageSize".to_string(), "A4".to_string()),
|
||||
]);
|
||||
|
||||
// Empty / malformed tokens are skipped
|
||||
let empty = parse_cups_options_string("");
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let no_eq = parse_cups_options_string("foo bar baz");
|
||||
assert!(no_eq.is_empty());
|
||||
|
||||
let empty_val = parse_cups_options_string("AP_D_InputSlot=");
|
||||
assert_eq!(empty_val, vec![("AP_D_InputSlot".to_string(), "".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_raw() {
|
||||
let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", None);
|
||||
assert_eq!(args.len(), 7);
|
||||
assert_eq!(args[0], "-d");
|
||||
assert_eq!(args[1], "Epson-Stylus-SX420W");
|
||||
assert_eq!(args[2], "-t");
|
||||
assert_eq!(args[3], "ICCery Target - target.tif");
|
||||
assert_eq!(args[4], "-o");
|
||||
assert_eq!(args[5], "raw");
|
||||
assert_eq!(args[6], "/tmp/target.tif");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -444,22 +623,8 @@ Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", Some(&opts));
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-d",
|
||||
"Epson-Stylus-SX420W",
|
||||
"-t",
|
||||
"ICCery Target - target.tif",
|
||||
"-o",
|
||||
"raw",
|
||||
"-o",
|
||||
"orientation-requested=4",
|
||||
"-o",
|
||||
"PageSize=A4",
|
||||
"/tmp/target.tif"
|
||||
]
|
||||
);
|
||||
assert!(args.contains(&"orientation-requested=4".to_string()));
|
||||
assert!(args.contains(&"PageSize=A4".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -17,7 +17,8 @@ use windows::Win32::Graphics::Printing::{
|
||||
use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
|
||||
|
||||
use crate::print::{
|
||||
PrintOptions, Printer, PrinterCapabilities, PrinterDevModeStore, PrinterPaperSize, PrinterTray,
|
||||
PrintOptions, Printer, PrinterCapabilities, PrinterDevModeStore, PrinterMediaType,
|
||||
PrinterPaperSize, PrinterTray,
|
||||
};
|
||||
|
||||
#[repr(C)]
|
||||
@@ -54,11 +55,14 @@ const DM_ICMMETHOD: u32 = 0x00800000;
|
||||
const DMICMMETHOD_NONE: u32 = 1;
|
||||
const DMORIENT_PORTRAIT: i16 = 1;
|
||||
const DMORIENT_LANDSCAPE: i16 = 2;
|
||||
const DM_MEDIATYPE: u32 = 0x02000000;
|
||||
|
||||
const DC_PAPERS: u16 = 2;
|
||||
const DC_BINS: u16 = 6;
|
||||
const DC_BINNAMES: u16 = 12;
|
||||
const DC_PAPERNAMES: u16 = 16;
|
||||
const DC_MEDIATYPENAMES: u16 = 34;
|
||||
const DC_MEDIATYPES: u16 = 35;
|
||||
const IDOK: i32 = 1;
|
||||
|
||||
fn to_wide(s: &str) -> Vec<u16> {
|
||||
@@ -124,6 +128,7 @@ pub fn get_printers() -> Result<Vec<Printer>, String> {
|
||||
name,
|
||||
status: "Ready".to_string(),
|
||||
is_default: false,
|
||||
display_name: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -154,6 +159,7 @@ pub fn get_printers() -> Result<Vec<Printer>, String> {
|
||||
name,
|
||||
status: "Ready".to_string(),
|
||||
is_default: false,
|
||||
display_name: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -262,9 +268,58 @@ pub fn get_printer_capabilities(printer_name: &str) -> Result<PrinterCapabilitie
|
||||
}
|
||||
}
|
||||
|
||||
// Query media types
|
||||
let num_media = DeviceCapabilitiesW(
|
||||
PCWSTR(printer_wide.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
DC_MEDIATYPES,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null(),
|
||||
);
|
||||
|
||||
let mut media_types = Vec::new();
|
||||
if num_media > 0 {
|
||||
let mut media_ids = vec![0u32; num_media as usize];
|
||||
let mut media_names_raw = vec![0u16; num_media as usize * 64];
|
||||
|
||||
let res_ids = DeviceCapabilitiesW(
|
||||
PCWSTR(printer_wide.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
DC_MEDIATYPES,
|
||||
media_ids.as_mut_ptr() as *mut u16,
|
||||
std::ptr::null(),
|
||||
);
|
||||
|
||||
let res_names = DeviceCapabilitiesW(
|
||||
PCWSTR(printer_wide.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
DC_MEDIATYPENAMES,
|
||||
media_names_raw.as_mut_ptr(),
|
||||
std::ptr::null(),
|
||||
);
|
||||
|
||||
if res_ids > 0 && res_names > 0 {
|
||||
for i in 0..num_media as usize {
|
||||
let id = media_ids[i];
|
||||
let name_slice = &media_names_raw[i * 64..(i + 1) * 64];
|
||||
let name = extract_null_terminated_string(name_slice);
|
||||
let display_name = if name.is_empty() {
|
||||
format!("Media Type {}", id)
|
||||
} else {
|
||||
name
|
||||
};
|
||||
media_types.push(PrinterMediaType {
|
||||
id: id.to_string(),
|
||||
name: display_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PrinterCapabilities {
|
||||
trays,
|
||||
paper_sizes,
|
||||
media_types,
|
||||
supports_orientation: true,
|
||||
})
|
||||
}
|
||||
@@ -370,6 +425,13 @@ pub fn apply_print_options_to_devmode(
|
||||
(*p_devmode).Anonymous1.Anonymous1.dmOrientation = DMORIENT_PORTRAIT;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref media_type) = opts.media_type {
|
||||
if let Ok(media_id) = media_type.parse::<u32>() {
|
||||
(*p_devmode).dmFields |= DEVMODE_FIELD_FLAGS(DM_MEDIATYPE);
|
||||
(*p_devmode).dmMediaType = media_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ mod tests {
|
||||
let pm = ProcessManager::new();
|
||||
// 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -60,13 +60,49 @@ pub struct ProfilingPreset {
|
||||
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 argyll_binary_dir: Option<String>,
|
||||
pub default_instrument: 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)]
|
||||
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> {
|
||||
@@ -239,6 +275,7 @@ pub fn load_settings(app: AppHandle) -> Result<AppSettings, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
|
||||
settings.validate()?;
|
||||
let path = app.path().app_data_dir().unwrap();
|
||||
fs::create_dir_all(&path).map_err(|e| e.to_string())?;
|
||||
let json = serde_json::to_string_pretty(&settings).unwrap();
|
||||
@@ -380,6 +417,39 @@ mod tests {
|
||||
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]
|
||||
fn test_parse_log_level_filter() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ICCery",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.2",
|
||||
"identifier": "com.gronod.iccery",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
|
||||
+204
-34
@@ -8,6 +8,7 @@
|
||||
<link rel="stylesheet" href="./styles/main.css" />
|
||||
<script src="./js/three.min.js"></script>
|
||||
<script src="./js/OrbitControls.js"></script>
|
||||
<script src="./js/CSS2DRenderer.js"></script>
|
||||
<script type="module" src="./js/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -34,9 +35,9 @@
|
||||
|
||||
<!-- Profiling Preset Selector -->
|
||||
<div class="preset-selector-container" style="padding: 0 16px 12px; border-bottom: 1px solid var(--border-color, #2a2a30);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<label for="presetSelect" style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; font-weight: 600;">Profiling Preset</label>
|
||||
<div style="display: flex; gap: 6px; align-items: center; justify-content: flex-end;">
|
||||
<div class="form-group-header">
|
||||
<label for="presetSelect" class="form-group-label-small">Profiling Preset</label>
|
||||
<div class="btn-row-sm">
|
||||
<button type="button" id="btnSavePresetModal" class="icon-btn btn-sm" title="Save current settings as Preset">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
||||
</button>
|
||||
@@ -130,10 +131,10 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group has-tooltip">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<label style="margin: 0;">Project Directory & Filename</label>
|
||||
<div style="display:flex; justify-content:flex-end;">
|
||||
<button type="button" id="btn-import-dataset" class="secondary btn-sm" style="margin-right: 4px;">Import Dataset...</button>
|
||||
<div class="form-group-header">
|
||||
<label>Project Directory & Filename</label>
|
||||
<div class="btn-row-sm">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,8 +407,8 @@
|
||||
|
||||
<!-- 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 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<label style="margin: 0; font-weight: 600;">Target Label & Metadata (Optional)</label>
|
||||
<div class="form-group-header">
|
||||
<label class="form-group-title">Target Label & Metadata (Optional)</label>
|
||||
<button type="button" class="secondary btn-sm" id="btnToggleLabelEdit">Edit Custom Field</button>
|
||||
</div>
|
||||
<p class="sub-label" style="margin-bottom: 10px;">Printed below test patches to identify hardware, ink, and media used for this profiling run.</p>
|
||||
@@ -480,7 +481,7 @@
|
||||
<select id="printerSelect">
|
||||
<option value="" disabled selected>Detecting printers...</option>
|
||||
</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>
|
||||
<span id="printerStatusBadge" class="status-badge badge-idle hidden">Idle</span>
|
||||
</div>
|
||||
@@ -503,6 +504,13 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group media-group" id="mediaTypeGroup">
|
||||
<label for="printerMediaTypeSelect">Media Type</label>
|
||||
<select id="printerMediaTypeSelect">
|
||||
<option value="" selected>Default / Auto Select</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group orientation-group">
|
||||
<label>Orientation</label>
|
||||
<div class="orientation-toggle-group">
|
||||
@@ -513,7 +521,7 @@
|
||||
</div>
|
||||
|
||||
<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-spinner hidden"></span>
|
||||
</button>
|
||||
@@ -542,8 +550,8 @@
|
||||
<div class="form-container" style="margin-bottom: 16px;">
|
||||
<div class="form-group">
|
||||
<label for="chartreadInstrumentSelect">Communication Port</label>
|
||||
<div class="input-row" style="display: flex; gap: 8px; align-items: center;">
|
||||
<select id="chartreadInstrumentSelect" style="flex: 1;">
|
||||
<div class="input-row-sm">
|
||||
<select id="chartreadInstrumentSelect">
|
||||
<option value="">Auto (First available port)</option>
|
||||
</select>
|
||||
<button type="button" class="secondary" id="btnDetectInstruments" title="Detect connected instruments via instlist">↻ Detect</button>
|
||||
@@ -551,6 +559,25 @@
|
||||
</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 -->
|
||||
<div class="chartread-status">
|
||||
<div class="status-label">State: <span id="chartreadState">IDLE</span></div>
|
||||
@@ -585,13 +612,13 @@
|
||||
|
||||
<!-- 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 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<h3 style="margin: 0; font-size: 1rem;">Multi-Pass Target Averaging</h3>
|
||||
<div class="form-group-header">
|
||||
<h3 class="form-group-title">Multi-Pass Target Averaging</h3>
|
||||
<span id="passCounterBadge" class="status-badge badge-idle">Pass 1 Complete</span>
|
||||
</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>
|
||||
<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="primary" id="btnFinishAndAverage" title="Complete measurement (Finish) before profiling">✓ Average Passes & Proceed →</button>
|
||||
</div>
|
||||
@@ -640,7 +667,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group has-tooltip">
|
||||
<label>OBA / FWA Compensation</label>
|
||||
<select id="colprofFwa">
|
||||
<option value="D50" selected>Calculated M1 — D50 FWA Compensation (Recommended)</option>
|
||||
@@ -648,14 +675,24 @@
|
||||
<option value="D65">D65 FWA Compensation</option>
|
||||
<option value="custom">Custom Ambient Spectrum (.sp)...</option>
|
||||
</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 class="form-group hidden" id="colprofCustomSpRow">
|
||||
<div class="form-group hidden has-tooltip" id="colprofCustomSpRow">
|
||||
<label>Custom Spectrum File (.sp)</label>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<input type="text" id="colprofCustomSpPath" placeholder="Select .sp file..." readonly style="flex:1;">
|
||||
<div class="input-row-sm">
|
||||
<input type="text" id="colprofCustomSpPath" placeholder="Select .sp file..." readonly>
|
||||
<button class="secondary btn-sm" id="btnBrowseCustomSp">Browse</button>
|
||||
</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>
|
||||
|
||||
<details class="advanced-section-details">
|
||||
@@ -665,7 +702,7 @@
|
||||
</summary>
|
||||
<div class="advanced-content">
|
||||
<div class="advanced-grid-2col">
|
||||
<div class="form-group">
|
||||
<div class="form-group has-tooltip">
|
||||
<label>Standard Illuminant</label>
|
||||
<select id="colprofIlluminant">
|
||||
<option value="" selected>Default (D50)</option>
|
||||
@@ -677,8 +714,11 @@
|
||||
<option value="F8">F8</option>
|
||||
<option value="F10">F10</option>
|
||||
</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 class="form-group has-tooltip">
|
||||
<label>Standard Observer</label>
|
||||
<select id="colprofObserver">
|
||||
<option value="" selected>Default (1931 2°)</option>
|
||||
@@ -686,8 +726,11 @@
|
||||
<option value="2015_2">2015 2°</option>
|
||||
<option value="2015_10">2015 10°</option>
|
||||
</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 class="form-group has-tooltip">
|
||||
<label>Input Viewing Condition</label>
|
||||
<select id="colprofInputViewCond">
|
||||
<option value="none" selected>None</option>
|
||||
@@ -696,8 +739,11 @@
|
||||
<option value="pe">Print evaluation (CIE 116-1995)</option>
|
||||
<option value="pm">Print evaluation (partial mid-tone adaptation)</option>
|
||||
</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 class="form-group has-tooltip">
|
||||
<label>Output Viewing Condition</label>
|
||||
<select id="colprofOutputViewCond">
|
||||
<option value="none" selected>None</option>
|
||||
@@ -708,6 +754,9 @@
|
||||
<option value="jd">Projector in dark environment</option>
|
||||
<option value="tv">Television/Film Studio</option>
|
||||
</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>
|
||||
@@ -715,7 +764,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Spinner and Progress Stage -->
|
||||
@@ -728,7 +777,7 @@
|
||||
<div class="success-card hidden" id="colprofSuccessCard">
|
||||
<h3>🎉 Profile Calculation Complete</h3>
|
||||
<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>
|
||||
|
||||
<!-- Process Log -->
|
||||
@@ -744,7 +793,7 @@
|
||||
<p>Check the numerical accuracy of your profile against the original measurement data.</p>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Report Card -->
|
||||
@@ -774,10 +823,104 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification History & Drift Tracking -->
|
||||
<details class="log-container drift-history" id="driftHistorySection">
|
||||
<summary>Verification History & 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 -->
|
||||
<div class="gamut-viewer-section">
|
||||
<h3>3D Gamut Visualization (CIELAB)</h3>
|
||||
<div id="gamutViewerContainer"></div>
|
||||
<div class="gamut-viewer-header">
|
||||
<h3>3D Gamut Visualisation <span class="gamut-viewer-subtitle">(CIELAB L* a* b*)</span></h3>
|
||||
<p class="gamut-viewer-hint">Drag to rotate • Scroll to zoom • Right-drag to pan</p>
|
||||
</div>
|
||||
|
||||
<div class="gamut-viewer-wrap" id="gamutViewerWrap">
|
||||
<!-- Three.js canvas is appended here by gamut_viewer.js -->
|
||||
<div id="gamutViewerContainer"></div>
|
||||
|
||||
<!-- Legend & layer controls overlay -->
|
||||
<div class="gamut-controls-panel" id="gamutControlsPanel">
|
||||
<div class="gamut-legend-title">Layers</div>
|
||||
|
||||
<div class="gamut-legend-item">
|
||||
<span class="gamut-swatch gamut-swatch-profile"></span>
|
||||
<span class="gamut-legend-label">Profile Gamut</span>
|
||||
<label class="gamut-toggle" title="Show / hide profile gamut">
|
||||
<input type="checkbox" id="chkProfileGamut" checked>
|
||||
<span class="gamut-toggle-slider"></span>
|
||||
</label>
|
||||
</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">
|
||||
<span class="gamut-swatch gamut-swatch-srgb"></span>
|
||||
<span class="gamut-legend-label">sRGB Reference</span>
|
||||
<label class="gamut-toggle" title="Show / hide sRGB reference">
|
||||
<input type="checkbox" id="chkSrgbReference" checked>
|
||||
<span class="gamut-toggle-slider"></span>
|
||||
</label>
|
||||
</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">
|
||||
<span class="gamut-swatch gamut-swatch-axes"></span>
|
||||
<span class="gamut-legend-label">CIELAB Axes</span>
|
||||
<label class="gamut-toggle" title="Show / hide CIELAB axis scaffold">
|
||||
<input type="checkbox" id="chkLabAxes" checked>
|
||||
<span class="gamut-toggle-slider"></span>
|
||||
</label>
|
||||
</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>
|
||||
|
||||
<!-- Process Log -->
|
||||
@@ -797,10 +940,37 @@
|
||||
<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)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="default_instrument">Default Instrument Override</label>
|
||||
<input type="text" id="default_instrument" placeholder="e.g. i1">
|
||||
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
||||
<label style="font-weight: 600;">Instrument & 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">
|
||||
</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);">
|
||||
<label style="font-weight: 600;">Diagnostics & Logging</label>
|
||||
<div class="input-row" style="margin-top: 8px;">
|
||||
@@ -815,7 +985,7 @@
|
||||
</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>
|
||||
</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="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>
|
||||
@@ -837,7 +1007,7 @@
|
||||
<img src="./assets/ICCery-logo.svg" alt="ICCery Logo" class="about-logo" />
|
||||
</div>
|
||||
<h2>About ICCery</h2>
|
||||
<p><strong>Version:</strong> <span id="aboutVersion">v0.3.2</span> • <strong>Build Date:</strong> <span id="aboutBuildDate">August 2026</span></p>
|
||||
<p><strong>Version:</strong> <span id="aboutVersion">v0.8.2</span> • <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
|
||||
<p><strong>Copyright © 2026 Gordon Bolton. All rights reserved.</strong></p>
|
||||
<h3>Licences & EULA</h3>
|
||||
<div class="license-text-container">
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* CSS2DRenderer - Three.js addon
|
||||
* Adapted from three.js examples/jsm/renderers/CSS2DRenderer.js
|
||||
* Modified to use global THREE object (IIFE pattern) for compatibility
|
||||
* with projects using three.min.js as a script tag (not ES module).
|
||||
*
|
||||
* Source: https://github.com/mrdoob/three.js
|
||||
* License: MIT
|
||||
*/
|
||||
( function () {
|
||||
|
||||
const _vector = new THREE.Vector3();
|
||||
const _viewMatrix = new THREE.Matrix4();
|
||||
const _viewProjectionMatrix = new THREE.Matrix4();
|
||||
const _a = new THREE.Vector3();
|
||||
const _b = new THREE.Vector3();
|
||||
|
||||
class CSS2DObject extends THREE.Object3D {
|
||||
|
||||
constructor( element = document.createElement( 'div' ) ) {
|
||||
super();
|
||||
this.isCSS2DObject = true;
|
||||
this.element = element;
|
||||
this.element.style.position = 'absolute';
|
||||
this.element.style.userSelect = 'none';
|
||||
this.element.setAttribute( 'draggable', false );
|
||||
this.center = new THREE.Vector2( 0.5, 0.5 );
|
||||
this.rotation2D = 0;
|
||||
|
||||
this.addEventListener( 'removed', function () {
|
||||
this.traverse( function ( object ) {
|
||||
if (
|
||||
object.element &&
|
||||
object.element instanceof object.element.ownerDocument.defaultView.Element &&
|
||||
object.element.parentNode !== null
|
||||
) {
|
||||
object.element.remove();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
copy( source, recursive ) {
|
||||
super.copy( source, recursive );
|
||||
this.element = source.element.cloneNode( true );
|
||||
this.center = source.center;
|
||||
this.rotation2D = source.rotation2D;
|
||||
return this;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class CSS2DRenderer {
|
||||
|
||||
constructor( parameters = {} ) {
|
||||
const domElement = parameters.element !== undefined
|
||||
? parameters.element
|
||||
: document.createElement( 'div' );
|
||||
|
||||
domElement.style.overflow = 'hidden';
|
||||
this.domElement = domElement;
|
||||
|
||||
this._width = 0;
|
||||
this._height = 0;
|
||||
this._widthHalf = 0;
|
||||
this._heightHalf = 0;
|
||||
|
||||
const cache = { objects: new WeakMap() };
|
||||
|
||||
this.getSize = function () {
|
||||
return { width: this._width, height: this._height };
|
||||
};
|
||||
|
||||
this.render = function ( scene, camera ) {
|
||||
if ( scene.matrixWorldAutoUpdate === true ) scene.updateMatrixWorld();
|
||||
if ( camera.parent === null && camera.matrixWorldAutoUpdate === true ) camera.updateMatrixWorld();
|
||||
_viewMatrix.copy( camera.matrixWorldInverse );
|
||||
_viewProjectionMatrix.multiplyMatrices( camera.projectionMatrix, _viewMatrix );
|
||||
this.renderObject( scene, scene, camera );
|
||||
this.zOrder( scene );
|
||||
};
|
||||
|
||||
this.renderObject = function ( object, scene, camera ) {
|
||||
if ( object.isCSS2DObject ) {
|
||||
_vector.setFromMatrixPosition( object.matrixWorld );
|
||||
_vector.applyMatrix4( _viewProjectionMatrix );
|
||||
const visible = ( object.visible === true ) &&
|
||||
( _vector.z >= -1 && _vector.z <= 1 ) &&
|
||||
( object.layers.test( camera.layers ) === true );
|
||||
const element = object.element;
|
||||
element.style.display = ( visible === true ) ? '' : 'none';
|
||||
if ( visible === true ) {
|
||||
object.onBeforeRender( this, scene, camera );
|
||||
const style = 'translate(' +
|
||||
( -100 * object.center.x ) + '%,' +
|
||||
( -100 * object.center.y ) + '%) ' +
|
||||
'translate(' +
|
||||
( _vector.x * this._widthHalf + this._widthHalf ) + 'px,' +
|
||||
( -_vector.y * this._heightHalf + this._heightHalf ) + 'px)';
|
||||
element.style.webkitTransform = style;
|
||||
element.style.MozTransform = style;
|
||||
element.style.oTransform = style;
|
||||
element.style.transform = style;
|
||||
if ( element.parentNode !== this.domElement ) {
|
||||
this.domElement.appendChild( element );
|
||||
}
|
||||
object.onAfterRender( this, scene, camera );
|
||||
}
|
||||
cache.objects.set( object, {
|
||||
distanceToCameraSquared: this.getDistanceToSquared( camera, object )
|
||||
} );
|
||||
}
|
||||
for ( let i = 0, l = object.children.length; i < l; i++ ) {
|
||||
this.renderObject( object.children[ i ], scene, camera );
|
||||
}
|
||||
};
|
||||
|
||||
this.getDistanceToSquared = function ( object1, object2 ) {
|
||||
_a.setFromMatrixPosition( object1.matrixWorld );
|
||||
_b.setFromMatrixPosition( object2.matrixWorld );
|
||||
return _a.distanceToSquared( _b );
|
||||
};
|
||||
|
||||
this.filterAndFlatten = function ( scene ) {
|
||||
const result = [];
|
||||
scene.traverse( function ( object ) {
|
||||
if ( object.isCSS2DObject ) result.push( object );
|
||||
} );
|
||||
return result;
|
||||
};
|
||||
|
||||
this.zOrder = function ( scene ) {
|
||||
const sorted = this.filterAndFlatten( scene ).sort( ( a, b ) => {
|
||||
if ( a.renderOrder !== b.renderOrder ) return b.renderOrder - a.renderOrder;
|
||||
const dA = cache.objects.get( a ).distanceToCameraSquared;
|
||||
const dB = cache.objects.get( b ).distanceToCameraSquared;
|
||||
return dA - dB;
|
||||
} );
|
||||
const zMax = sorted.length;
|
||||
for ( let i = 0, l = sorted.length; i < l; i++ ) {
|
||||
sorted[ i ].element.style.zIndex = ( zMax - i ).toString();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setSize( width, height ) {
|
||||
this._width = width;
|
||||
this._height = height;
|
||||
this._widthHalf = this._width / 2;
|
||||
this._heightHalf = this._height / 2;
|
||||
this.domElement.style.width = width + 'px';
|
||||
this.domElement.style.height = height + 'px';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Expose on global THREE object
|
||||
THREE.CSS2DObject = CSS2DObject;
|
||||
THREE.CSS2DRenderer = CSS2DRenderer;
|
||||
|
||||
} )();
|
||||
+440
-62
@@ -45,7 +45,7 @@ export function populateStage3TargetContext(metadata) {
|
||||
}
|
||||
|
||||
// State machine states
|
||||
const STATE = {
|
||||
export const STATE = {
|
||||
IDLE: "IDLE",
|
||||
CALIBRATING: "CALIBRATING",
|
||||
AWAITING_STRIP: "AWAITING_STRIP",
|
||||
@@ -53,13 +53,242 @@ const STATE = {
|
||||
ALL_STRIPS_READ: "ALL_STRIPS_READ",
|
||||
WARNING: "WARNING",
|
||||
PROMPT_CONTINUE: "PROMPT_CONTINUE",
|
||||
TABLE_PLACE_SHEET: "TABLE_PLACE_SHEET",
|
||||
TABLE_ALIGN: "TABLE_ALIGN",
|
||||
ERROR: "ERROR",
|
||||
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 currentProcessId = "";
|
||||
let measurementInProgress = false;
|
||||
let xyTableDetected = false;
|
||||
let currentPassIndex = 0;
|
||||
const recordedPasses = [];
|
||||
|
||||
@@ -83,6 +312,82 @@ export function initChartread() {
|
||||
const passCounterBadge = document.getElementById("passCounterBadge");
|
||||
const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet");
|
||||
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) {
|
||||
measurementInProgress = busy;
|
||||
@@ -182,7 +487,13 @@ export function initChartread() {
|
||||
const opt = document.createElement("option");
|
||||
// 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.textContent = `${inst.type || inst.name}${inst.port ? ` (Port ${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})` : ""}`;
|
||||
}
|
||||
instrumentSelect.appendChild(opt);
|
||||
});
|
||||
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) {
|
||||
currentState = newState;
|
||||
if (stateLabel) stateLabel.textContent = newState;
|
||||
@@ -287,6 +613,22 @@ export function initChartread() {
|
||||
}
|
||||
if (btnCancel) btnCancel.classList.remove("hidden");
|
||||
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:
|
||||
if (btnRetry) {
|
||||
btnRetry.disabled = false;
|
||||
@@ -339,6 +681,15 @@ export function initChartread() {
|
||||
setState(STATE.CALIBRATING);
|
||||
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 config = {
|
||||
@@ -365,69 +716,74 @@ export function initChartread() {
|
||||
logPre.textContent += line + "\n";
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
|
||||
// Parse prompts for state transitions
|
||||
const lineLower = line.toLowerCase();
|
||||
const classified = classifyChartreadLine(line, currentState);
|
||||
|
||||
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")
|
||||
) {
|
||||
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")
|
||||
) {
|
||||
const previousPrompt = promptText ? promptText.textContent.trim() : "";
|
||||
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())) {
|
||||
setPrompt(`${previousPrompt}\n${line.trim()}`);
|
||||
} else {
|
||||
setPrompt(line.trim());
|
||||
if (classified.matched) {
|
||||
if (
|
||||
classified.state === STATE.TABLE_PLACE_SHEET ||
|
||||
classified.state === STATE.TABLE_ALIGN ||
|
||||
(classified.meta && (classified.meta.isRemoveSheetNotice || classified.meta.sheetOk))
|
||||
) {
|
||||
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 lineLower = line.toLowerCase();
|
||||
const isContinuationPrompt = lineLower.includes("hit return to use it anyway") || lineLower.includes("use it anyway");
|
||||
if (currentState === STATE.WARNING && isContinuationPrompt && previousPrompt && !previousPrompt.includes(line.trim())) {
|
||||
setPrompt(`${previousPrompt}\n${line.trim()}`);
|
||||
} else {
|
||||
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) => {
|
||||
if (event.payload.id === currentProcessId && event.payload.line) {
|
||||
lastStderrLine = event.payload.line;
|
||||
logPre.textContent += "ERR: " + event.payload.line + "\n";
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
}
|
||||
@@ -441,6 +797,9 @@ export function initChartread() {
|
||||
stopSwatchListener();
|
||||
|
||||
if (event.payload.code === 0) {
|
||||
if (xyTableDetected) {
|
||||
updateXyTableSequence("done");
|
||||
}
|
||||
try {
|
||||
const passIndex = currentPassIndex + 1;
|
||||
const filename = await invoke("snapshot_ti3", {
|
||||
@@ -471,8 +830,10 @@ export function initChartread() {
|
||||
}
|
||||
} else {
|
||||
setState(STATE.FINISHED);
|
||||
setPrompt(`❌ chartread exited with code ${event.payload.code}.`);
|
||||
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`;
|
||||
if (logContainer) logContainer.open = true;
|
||||
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);
|
||||
@@ -610,8 +971,12 @@ export function initChartread() {
|
||||
try {
|
||||
btnAccept.disabled = true;
|
||||
await invoke("send_stdin", { id: currentProcessId, input: "\n" });
|
||||
setState(STATE.READING);
|
||||
setPrompt("Accepted. Processing...");
|
||||
if (currentState === STATE.TABLE_PLACE_SHEET || currentState === STATE.TABLE_ALIGN) {
|
||||
setPrompt("Continuing XY table sequence...");
|
||||
} else {
|
||||
setState(STATE.READING);
|
||||
setPrompt("Accepted. Processing...");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("send_stdin error:", e);
|
||||
btnAccept.disabled = false;
|
||||
@@ -677,14 +1042,27 @@ export function initChartread() {
|
||||
if (btnCancel) {
|
||||
btnCancel.addEventListener("click", async () => {
|
||||
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 });
|
||||
stopSwatchListener();
|
||||
setState(recordedPasses.length > 0 ? STATE.FINISHED : STATE.IDLE);
|
||||
setPrompt("Measurement cancelled.");
|
||||
if (xyTableDetected) {
|
||||
updateXyTableSequence("standby");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("kill_process error:", e);
|
||||
setPrompt(`Cancel failed: ${e}`);
|
||||
} finally {
|
||||
btnCancel.disabled = false;
|
||||
setMeasurementBusy(false);
|
||||
stopSwatchListener();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+486
-109
@@ -1,72 +1,89 @@
|
||||
import { computeQuickHull } from "./vendor/quickhull.js";
|
||||
import { computeQuickHull } from "./vendor/quickhull.js";
|
||||
import { labToSrgb } from "./color_convert.js";
|
||||
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
|
||||
let scene, camera, renderer, controls;
|
||||
let scene, camera, renderer, labelRenderer, controls;
|
||||
let currentProfileMesh = null;
|
||||
let sRgbMesh = null;
|
||||
let sRgbGroup = null;
|
||||
let axisScaffoldGroup = null;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public: initialise the gamut viewer
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function initGamutViewer() {
|
||||
try {
|
||||
const container = document.getElementById('gamutViewerContainer');
|
||||
if (!container || typeof THREE === 'undefined') return;
|
||||
|
||||
// Clear any existing contents if re-initialized
|
||||
// Clear any existing contents if re-initialised
|
||||
container.innerHTML = "";
|
||||
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x111116);
|
||||
scene.background = new THREE.Color(0x0e0e14);
|
||||
|
||||
const width = container.clientWidth > 0 ? container.clientWidth : 500;
|
||||
const height = container.clientHeight > 0 ? container.clientHeight : 360;
|
||||
const width = container.clientWidth > 0 ? container.clientWidth : 500;
|
||||
const height = container.clientHeight > 0 ? container.clientHeight : 400;
|
||||
|
||||
camera = new THREE.PerspectiveCamera(45, width / height, 1, 1000);
|
||||
camera.position.set(150, 110, 150);
|
||||
// ── WebGL renderer ────────────────────────────────────────────────────
|
||||
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000);
|
||||
camera.position.set(180, 120, 180);
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(window.devicePixelRatio || 1);
|
||||
renderer.domElement.style.touchAction = 'none';
|
||||
renderer.domElement.style.display = 'block';
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// ── CSS2D label renderer ──────────────────────────────────────────────
|
||||
if (typeof THREE.CSS2DRenderer !== 'undefined') {
|
||||
labelRenderer = new THREE.CSS2DRenderer();
|
||||
labelRenderer.setSize(width, height);
|
||||
labelRenderer.domElement.style.position = 'absolute';
|
||||
labelRenderer.domElement.style.top = '0';
|
||||
labelRenderer.domElement.style.left = '0';
|
||||
labelRenderer.domElement.style.pointerEvents = 'none';
|
||||
container.appendChild(labelRenderer.domElement);
|
||||
}
|
||||
|
||||
// ── Orbit controls ────────────────────────────────────────────────────
|
||||
if (typeof THREE.OrbitControls !== 'undefined') {
|
||||
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.target.set(0, 50, 0); // orbit around the centre of L*=50
|
||||
controls.update();
|
||||
controls.touches = {
|
||||
ONE: THREE.TOUCH.ROTATE,
|
||||
TWO: THREE.TOUCH.DOLLY_PAN
|
||||
};
|
||||
}
|
||||
|
||||
// Add CIELAB orientation helpers (Axes: X=a*, Y=L*, Z=b*)
|
||||
const axesHelper = new THREE.AxesHelper(100);
|
||||
scene.add(axesHelper);
|
||||
|
||||
// Add grid helper at L*=0 plane
|
||||
const gridHelper = new THREE.GridHelper(200, 20, 0x444455, 0x222233);
|
||||
gridHelper.position.y = 0;
|
||||
scene.add(gridHelper);
|
||||
|
||||
// Lights
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
|
||||
// ── Lighting ──────────────────────────────────────────────────────────
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
|
||||
scene.add(ambientLight);
|
||||
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
dirLight1.position.set(100, 200, 100);
|
||||
dirLight1.position.set(150, 250, 150);
|
||||
scene.add(dirLight1);
|
||||
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||
dirLight2.position.set(-100, -100, -100);
|
||||
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.35);
|
||||
dirLight2.position.set(-120, -80, -120);
|
||||
scene.add(dirLight2);
|
||||
|
||||
// Handle viewport resize dynamically (e.g., when switching to Stage 5 tab)
|
||||
// ── CIELAB axis scaffold + labels ─────────────────────────────────────
|
||||
buildAxisScaffold();
|
||||
|
||||
// ── Resize handling ───────────────────────────────────────────────────
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (let entry of entries) {
|
||||
for (const entry of entries) {
|
||||
const w = entry.contentRect.width;
|
||||
const h = entry.contentRect.height;
|
||||
if (w > 0 && h > 0 && renderer && camera) {
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
if (labelRenderer) labelRenderer.setSize(w, h);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -74,59 +91,308 @@ export async function initGamutViewer() {
|
||||
|
||||
animate();
|
||||
|
||||
// Load bundled sRGB reference gamut wireframe on startup
|
||||
// ── Wire toggle controls ──────────────────────────────────────────────
|
||||
_wireToggles();
|
||||
|
||||
// ── Load bundled sRGB reference gamut ─────────────────────────────────
|
||||
loadSrgbReferenceGamut();
|
||||
|
||||
} catch (err) {
|
||||
console.warn("Gamut viewer initialization notice:", err);
|
||||
console.warn("Gamut viewer initialisation notice:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Animation loop
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
if (controls) controls.update();
|
||||
if (renderer && scene && camera) renderer.render(scene, camera);
|
||||
if (renderer && scene && camera) {
|
||||
renderer.render(scene, camera);
|
||||
if (labelRenderer) labelRenderer.render(scene, camera);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Build custom CIELAB axis scaffold (replaces THREE.AxesHelper)
|
||||
// CIELAB mapping: X = a* (−128 → +128), Y = L* (0 → 100), Z = b* (−128 → +128)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function buildAxisScaffold() {
|
||||
axisScaffoldGroup = new THREE.Group();
|
||||
|
||||
const AXIS_MAT_L = new THREE.LineBasicMaterial({ color: 0xcccccc, transparent: true, opacity: 0.7 });
|
||||
const AXIS_MAT_A = new THREE.LineBasicMaterial({ color: 0x99bbcc, transparent: true, opacity: 0.7 });
|
||||
const AXIS_MAT_B = new THREE.LineBasicMaterial({ color: 0x99bbcc, transparent: true, opacity: 0.7 });
|
||||
const TICK_MAT = new THREE.LineBasicMaterial({ color: 0x556677, transparent: true, opacity: 0.8 });
|
||||
const BOX_MAT = new THREE.LineBasicMaterial({ color: 0x232336, transparent: true, opacity: 0.9 });
|
||||
const GRID_MAT = new THREE.LineBasicMaterial({ color: 0x1e1e2e });
|
||||
|
||||
// ── Bounding box (full CIELAB domain: a*/b* ±128, L* 0→100) ──────────────
|
||||
const boxGeo = new THREE.BoxGeometry(256, 100, 256);
|
||||
const edgesGeo = new THREE.EdgesGeometry(boxGeo);
|
||||
const boxLines = new THREE.LineSegments(edgesGeo, BOX_MAT);
|
||||
boxLines.position.set(0, 50, 0); // centre at a*=0, L*=50, b*=0
|
||||
axisScaffoldGroup.add(boxLines);
|
||||
|
||||
// ── Ground plane grid at L*=0 (a*/b* plane) ──────────────────────────────
|
||||
const gridHelper = new THREE.GridHelper(256, 16, 0x1e1e2e, 0x1a1a28);
|
||||
gridHelper.position.y = 0;
|
||||
axisScaffoldGroup.add(gridHelper);
|
||||
|
||||
// ── L* axis line (vertical, Y): 0 → 100 ──────────────────────────────────
|
||||
axisScaffoldGroup.add(_line([0, 0, 0], [0, 100, 0], AXIS_MAT_L));
|
||||
|
||||
// ── a* axis line (X): −128 → +128 ────────────────────────────────────────
|
||||
axisScaffoldGroup.add(_line([-128, 0, 0], [128, 0, 0], AXIS_MAT_A));
|
||||
|
||||
// ── b* axis line (Z): −128 → +128 ────────────────────────────────────────
|
||||
axisScaffoldGroup.add(_line([0, 0, -128], [0, 0, 128], AXIS_MAT_B));
|
||||
|
||||
// ── Tick marks — L* axis (y direction) ───────────────────────────────────
|
||||
for (const v of [25, 50, 75, 100]) {
|
||||
axisScaffoldGroup.add(_line([-4, v, 0], [4, v, 0], TICK_MAT));
|
||||
axisScaffoldGroup.add(_line([0, v, -4], [0, v, 4], TICK_MAT));
|
||||
}
|
||||
|
||||
// ── Tick marks — a* axis (x direction) ───────────────────────────────────
|
||||
for (const v of [-128, -64, 64, 128]) {
|
||||
axisScaffoldGroup.add(_line([v, -3, 0], [v, 3, 0], TICK_MAT));
|
||||
}
|
||||
|
||||
// ── Tick marks — b* axis (z direction) ───────────────────────────────────
|
||||
for (const v of [-128, -64, 64, 128]) {
|
||||
axisScaffoldGroup.add(_line([0, -3, v], [0, 3, v], TICK_MAT));
|
||||
}
|
||||
|
||||
scene.add(axisScaffoldGroup);
|
||||
|
||||
// ── CSS2D axis labels ─────────────────────────────────────────────────────
|
||||
if (typeof THREE.CSS2DObject !== 'undefined') {
|
||||
const labels = [
|
||||
// Primary axis name labels
|
||||
{ text: 'L*', pos: [0, 110, 0 ], cls: 'gamut-axis-label gamut-axis-primary' },
|
||||
{ text: '+a* (Red →)', pos: [140, 0, 0 ], cls: 'gamut-axis-label gamut-axis-a-pos' },
|
||||
{ text: '← −a* (Green)', pos: [-140, 0, 0 ], cls: 'gamut-axis-label gamut-axis-a-neg' },
|
||||
{ text: '+b* (Yellow →)', pos: [0, 0, 140], cls: 'gamut-axis-label gamut-axis-b-pos' },
|
||||
{ text: '← −b* (Blue)', pos: [0, 0, -140], cls: 'gamut-axis-label gamut-axis-b-neg' },
|
||||
// Tick value labels on L* axis
|
||||
{ text: '25', pos: [10, 25, 0], cls: 'gamut-axis-label gamut-tick-label' },
|
||||
{ text: '50', pos: [10, 50, 0], cls: 'gamut-axis-label gamut-tick-label' },
|
||||
{ text: '75', pos: [10, 75, 0], cls: 'gamut-axis-label gamut-tick-label' },
|
||||
{ text: '100', pos: [10, 100, 0], cls: 'gamut-axis-label gamut-tick-label' },
|
||||
];
|
||||
|
||||
for (const { text, pos, cls } of labels) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
div.className = cls;
|
||||
const label = new THREE.CSS2DObject(div);
|
||||
label.position.set(...pos);
|
||||
axisScaffoldGroup.add(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: create a THREE.Line between two points
|
||||
function _line(from, to, material) {
|
||||
const geo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(...from),
|
||||
new THREE.Vector3(...to)
|
||||
]);
|
||||
return new THREE.Line(geo, material);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* 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) {
|
||||
const lines = text.split('\n');
|
||||
const vertices = []; // [[L, a, b], ...]
|
||||
const faces = []; // [[v0, v1, v2], ...]
|
||||
const vertices = [];
|
||||
const faces = [];
|
||||
const warnings = [];
|
||||
let dataStarted = false;
|
||||
let dataBlock = 0; // 0 = not in data, 1 = vertices, 2 = faces
|
||||
let fieldCount = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('NUMBER_OF_FIELDS')) {
|
||||
fieldCount = parseInt(trimmed.split(/\s+/)[1], 10);
|
||||
}
|
||||
if (trimmed === 'BEGIN_DATA') {
|
||||
let dataBlock = 0; // 1 = vertices section, 2+ = faces sections
|
||||
|
||||
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
||||
const raw = lines[lineIdx];
|
||||
const trimmed = raw.replace(/#.*$/, '').trim(); // strip inline comments
|
||||
if (trimmed === '') continue;
|
||||
|
||||
if (trimmed.toUpperCase() === 'BEGIN_DATA') {
|
||||
dataBlock++;
|
||||
dataStarted = true;
|
||||
continue;
|
||||
}
|
||||
if (trimmed === 'END_DATA') {
|
||||
if (trimmed.toUpperCase() === 'END_DATA') {
|
||||
dataStarted = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dataStarted) {
|
||||
const parts = trimmed.split(/\s+/).map(Number);
|
||||
if (dataBlock === 1 && parts.length >= 4) {
|
||||
// Vertex: VERTEX_NO LAB_L LAB_A LAB_B
|
||||
vertices.push([parts[1], parts[2], parts[3]]); // [L, a, b]
|
||||
} else if (dataBlock === 2 && parts.length >= 3) {
|
||||
// Face: VERTEX_0 VERTEX_1 VERTEX_2
|
||||
if (!dataStarted) continue;
|
||||
|
||||
const parts = trimmed.split(/\s+/).map(Number);
|
||||
const allNumeric = parts.every(n => !Number.isNaN(n));
|
||||
if (!allNumeric) {
|
||||
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]]);
|
||||
} 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 };
|
||||
}
|
||||
|
||||
export function renderGamutFromText(text, color, isWireframe, previousMesh) {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Build a BufferGeometry from parsed gamut data
|
||||
// Returns null if insufficient data.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function _buildGeometry(vertices, faces) {
|
||||
if (vertices.length < 4) return null;
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
|
||||
if (faces.length > 0) {
|
||||
// Pre-triangulated mesh from .gam file
|
||||
const posArr = new Float32Array(vertices.length * 3);
|
||||
for (let i = 0; i < vertices.length; i++) {
|
||||
const [L, a, b] = vertices[i];
|
||||
posArr[i * 3] = a; // X = a*
|
||||
posArr[i * 3 + 1] = L; // Y = L*
|
||||
posArr[i * 3 + 2] = b; // Z = b*
|
||||
}
|
||||
const idxArr = new Uint32Array(faces.length * 3);
|
||||
for (let i = 0; i < faces.length; i++) {
|
||||
idxArr[i * 3] = faces[i][0];
|
||||
idxArr[i * 3 + 1] = faces[i][1];
|
||||
idxArr[i * 3 + 2] = faces[i][2];
|
||||
}
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(posArr, 3));
|
||||
geometry.setIndex(new THREE.BufferAttribute(idxArr, 1));
|
||||
geometry.computeVertexNormals();
|
||||
} else {
|
||||
// Fallback: convex hull from point cloud
|
||||
const pts = vertices.map(v => ({ x: v[1], y: v[0], z: v[2] }));
|
||||
const hullFaces = computeQuickHull(pts);
|
||||
if (!hullFaces || hullFaces.length === 0) return null;
|
||||
|
||||
const vList = [];
|
||||
const idxList = [];
|
||||
const ptMap = new Map();
|
||||
for (const f of hullFaces) {
|
||||
for (const p of [f.a, f.b, f.c]) {
|
||||
const key = `${p.x}_${p.y}_${p.z}`;
|
||||
if (!ptMap.has(key)) { ptMap.set(key, vList.length / 3); vList.push(p.x, p.y, p.z); }
|
||||
idxList.push(ptMap.get(key));
|
||||
}
|
||||
}
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vList), 3));
|
||||
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(idxList), 1));
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
// Re-extract vertices for colour calculation below
|
||||
const posAttr = geometry.getAttribute('position');
|
||||
vertices = [];
|
||||
for (let i = 0; i < posAttr.count; i++) {
|
||||
// Note: stored as (a, L, b) — convert back to [L, a, b]
|
||||
vertices.push([posAttr.getY(i), posAttr.getX(i), posAttr.getZ(i)]);
|
||||
}
|
||||
}
|
||||
|
||||
return { geometry, vertices };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Render sRGB reference gamut as EdgesGeometry outline + faint solid fill
|
||||
// Returned as a THREE.Group stored in sRgbGroup
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function _renderSrgbReference(text, previousGroup) {
|
||||
if (!scene) return null;
|
||||
|
||||
if (previousGroup) {
|
||||
scene.remove(previousGroup);
|
||||
previousGroup.traverse(child => {
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) child.material.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||
if (warnings.length > 0) {
|
||||
console.warn('Gamut parser warnings:', warnings.join('\n'));
|
||||
}
|
||||
const built = _buildGeometry(vertices, faces);
|
||||
if (!built) return null;
|
||||
|
||||
const { geometry } = built;
|
||||
const group = new THREE.Group();
|
||||
|
||||
// ── 1. Faint transparent solid fill ──────────────────────────────────────
|
||||
const solidMat = new THREE.MeshLambertMaterial({
|
||||
color: 0x8899bb,
|
||||
transparent: true,
|
||||
opacity: 0.07,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false
|
||||
});
|
||||
group.add(new THREE.Mesh(geometry, solidMat));
|
||||
|
||||
// ── 2. Clean structural outline via EdgesGeometry (15° threshold) ─────────
|
||||
// This removes all co-planar internal triangle edges, leaving only the
|
||||
// structural contours of the gamut surface hull.
|
||||
const edgesGeo = new THREE.EdgesGeometry(geometry, 15);
|
||||
const edgesMat = new THREE.LineBasicMaterial({
|
||||
color: 0x6688aa,
|
||||
transparent: true,
|
||||
opacity: 0.55
|
||||
});
|
||||
group.add(new THREE.LineSegments(edgesGeo, edgesMat));
|
||||
|
||||
scene.add(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Render profile gamut with per-vertex true Lab→sRGB colour
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function _renderProfileGamut(text, previousMesh) {
|
||||
if (!scene) return null;
|
||||
|
||||
if (previousMesh) {
|
||||
@@ -135,62 +401,35 @@ export function renderGamutFromText(text, color, isWireframe, previousMesh) {
|
||||
if (previousMesh.material) previousMesh.material.dispose();
|
||||
}
|
||||
|
||||
const { vertices, faces } = parseGamutFile(text);
|
||||
if (vertices.length < 4) return null;
|
||||
|
||||
let geometry = new THREE.BufferGeometry();
|
||||
|
||||
if (faces.length > 0) {
|
||||
// We have a pre-triangulated mesh
|
||||
const positionArray = new Float32Array(vertices.length * 3);
|
||||
for (let i = 0; i < vertices.length; i++) {
|
||||
const [L, a, b] = vertices[i];
|
||||
positionArray[i * 3] = a; // x = a*
|
||||
positionArray[i * 3 + 1] = L; // y = L*
|
||||
positionArray[i * 3 + 2] = b; // z = b*
|
||||
}
|
||||
|
||||
const indexArray = new Uint32Array(faces.length * 3);
|
||||
for (let i = 0; i < faces.length; i++) {
|
||||
indexArray[i * 3] = faces[i][0];
|
||||
indexArray[i * 3 + 1] = faces[i][1];
|
||||
indexArray[i * 3 + 2] = faces[i][2];
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positionArray, 3));
|
||||
geometry.setIndex(new THREE.BufferAttribute(indexArray, 1));
|
||||
geometry.computeVertexNormals();
|
||||
} else {
|
||||
// Fallback to point cloud hull if no faces are present
|
||||
const pts = vertices.map(v => ({ x: v[1], y: v[0], z: v[2] })); // { x: a, y: L, z: b }
|
||||
const hullFaces = computeQuickHull(pts);
|
||||
if (!hullFaces || hullFaces.length === 0) return null;
|
||||
|
||||
const verticesList = [];
|
||||
const indices = [];
|
||||
const ptMap = new Map();
|
||||
|
||||
for (const f of hullFaces) {
|
||||
for (const p of [f.a, f.b, f.c]) {
|
||||
const key = `${p.x}_${p.y}_${p.z}`;
|
||||
if (!ptMap.has(key)) {
|
||||
ptMap.set(key, verticesList.length / 3);
|
||||
verticesList.push(p.x, p.y, p.z);
|
||||
}
|
||||
indices.push(ptMap.get(key));
|
||||
}
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(verticesList), 3));
|
||||
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1));
|
||||
geometry.computeVertexNormals();
|
||||
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||
if (warnings.length > 0) {
|
||||
console.warn('Gamut parser warnings:', warnings.join('\n'));
|
||||
}
|
||||
const built = _buildGeometry(vertices, faces);
|
||||
if (!built) return null;
|
||||
|
||||
const { geometry } = built;
|
||||
let verts = built.vertices;
|
||||
|
||||
// ── Per-vertex colour: Lab → sRGB ─────────────────────────────────────────
|
||||
// geometry 'position' is laid out as [a, L, b] per vertex
|
||||
const posAttr = geometry.getAttribute('position');
|
||||
const colorArr = new Float32Array(posAttr.count * 3);
|
||||
for (let i = 0; i < posAttr.count; i++) {
|
||||
const a_star = posAttr.getX(i);
|
||||
const L_star = posAttr.getY(i);
|
||||
const b_star = posAttr.getZ(i);
|
||||
const [r, g, b] = labToSrgb(L_star, a_star, b_star);
|
||||
colorArr[i * 3] = r / 255;
|
||||
colorArr[i * 3 + 1] = g / 255;
|
||||
colorArr[i * 3 + 2] = b / 255;
|
||||
}
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(colorArr, 3));
|
||||
|
||||
const material = new THREE.MeshLambertMaterial({
|
||||
color: color,
|
||||
wireframe: isWireframe,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: isWireframe ? 0.35 : 0.75,
|
||||
opacity: 0.88,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
@@ -199,26 +438,164 @@ export function renderGamutFromText(text, color, isWireframe, previousMesh) {
|
||||
return mesh;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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 (0–1).
|
||||
* @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 (0–1).
|
||||
* @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 (0–1).
|
||||
* @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() {
|
||||
try {
|
||||
const response = await fetch('assets/sRGB.gam');
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
sRgbMesh = renderGamutFromText(text, 0x94a3b8, true, sRgbMesh);
|
||||
sRgbGroup = _renderSrgbReference(text, sRgbGroup);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Could not load sRGB reference gamut:", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadGamutMesh(gamFilePath, color = 0x3b82f6, isWireframe = false) {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* 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) {
|
||||
try {
|
||||
const base64Data = await invoke('read_file_base64', { path: gamFilePath });
|
||||
const text = atob(base64Data);
|
||||
currentProfileMesh = renderGamutFromText(text, color, isWireframe, currentProfileMesh);
|
||||
currentProfileMesh = _renderProfileGamut(text, currentProfileMesh);
|
||||
return currentProfileMesh;
|
||||
} catch (e) {
|
||||
console.error("Failed to load gamut mesh:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Toggle functions (exported for external use; also wired internally)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export function toggleSrgbReference(visible) {
|
||||
if (sRgbGroup) sRgbGroup.visible = visible;
|
||||
}
|
||||
|
||||
export function toggleProfileGamut(visible) {
|
||||
if (currentProfileMesh) currentProfileMesh.visible = visible;
|
||||
}
|
||||
|
||||
export function toggleAxes(visible) {
|
||||
if (axisScaffoldGroup) axisScaffoldGroup.visible = visible;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Wire legend toggle checkboxes, opacity sliders, and reset button
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function _wireToggles() {
|
||||
const bindings = [
|
||||
['chkProfileGamut', toggleProfileGamut ],
|
||||
['chkSrgbReference', toggleSrgbReference ],
|
||||
['chkLabAxes', toggleAxes ],
|
||||
];
|
||||
for (const [id, fn] of bindings) {
|
||||
const el = document.getElementById(id);
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
+69
-3
@@ -10,6 +10,11 @@ let stage1Cwd = "";
|
||||
let currentManifest = null;
|
||||
let discoveredPrinters = [];
|
||||
|
||||
// Captured CUPS options from the native macOS print panel, keyed by printer
|
||||
// name. These are fed back into print_target_native so the lp job uses the
|
||||
// user's media type / quality / color-bypass selections.
|
||||
let capturedCupsOptions = {};
|
||||
|
||||
let updateLabelPreviewFn = null;
|
||||
|
||||
/**
|
||||
@@ -146,6 +151,7 @@ export function initPrinttarg() {
|
||||
const btnRefreshPrinters = document.getElementById("btnRefreshPrinters");
|
||||
const btnPrinterProperties = document.getElementById("btnPrinterProperties");
|
||||
const printerTraySelect = document.getElementById("printerTraySelect");
|
||||
const printerMediaTypeSelect = document.getElementById("printerMediaTypeSelect");
|
||||
const btnOrientPortrait = document.getElementById("btnOrientPortrait");
|
||||
const btnOrientLandscape = document.getElementById("btnOrientLandscape");
|
||||
const printerStatusBadge = document.getElementById("printerStatusBadge");
|
||||
@@ -219,6 +225,9 @@ export function initPrinttarg() {
|
||||
async function loadPrinterCapabilities(printerName) {
|
||||
if (!printerTraySelect || !printerName) return;
|
||||
printerTraySelect.innerHTML = '<option value="" selected>Default / Auto Select</option>';
|
||||
if (printerMediaTypeSelect) {
|
||||
printerMediaTypeSelect.innerHTML = '<option value="" selected>Default / Auto Select</option>';
|
||||
}
|
||||
|
||||
try {
|
||||
const caps = await invoke("get_printer_capabilities", { printerName });
|
||||
@@ -230,6 +239,15 @@ export function initPrinttarg() {
|
||||
printerTraySelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
if (caps && caps.media_types && caps.media_types.length > 0 && printerMediaTypeSelect) {
|
||||
caps.media_types.forEach(media => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = media.id;
|
||||
opt.textContent = media.name;
|
||||
printerMediaTypeSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[ICCery Print] Could not fetch printer capabilities:", err);
|
||||
}
|
||||
@@ -242,12 +260,17 @@ export function initPrinttarg() {
|
||||
const trayVal = printerTraySelect ? printerTraySelect.value : "";
|
||||
const paperSource = trayVal ? parseInt(trayVal, 10) : null;
|
||||
const ppdFallback = chkPpdFallback ? chkPpdFallback.checked : false;
|
||||
const mediaType = printerMediaTypeSelect ? printerMediaTypeSelect.value : "";
|
||||
const activePrinter = printerSelect ? printerSelect.value : "";
|
||||
const cupsOpts = (activePrinter && capturedCupsOptions[activePrinter]) ? capturedCupsOptions[activePrinter] : null;
|
||||
|
||||
return {
|
||||
paper_source: paperSource,
|
||||
orientation: selectedOrientation,
|
||||
paper_size: pageSizeSelect ? pageSizeSelect.value : null,
|
||||
media_type: mediaType ? mediaType : null,
|
||||
ppd_uncorrected_passthrough: ppdFallback,
|
||||
cups_options: cupsOpts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -306,7 +329,10 @@ export function initPrinttarg() {
|
||||
discoveredPrinters.forEach((p, idx) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.name;
|
||||
opt.textContent = `${p.name}${p.is_default ? ' (Default)' : ''} [${p.status || 'Ready'}]`;
|
||||
const label = p.display_name
|
||||
? `${p.display_name} (${p.name})`
|
||||
: p.name;
|
||||
opt.textContent = `${label}${p.is_default ? ' (Default)' : ''} [${p.status || 'Ready'}]`;
|
||||
if (p.is_default && !defaultSelected) {
|
||||
opt.selected = true;
|
||||
defaultSelected = true;
|
||||
@@ -358,8 +384,46 @@ export function initPrinttarg() {
|
||||
|
||||
try {
|
||||
showNotification("info", `Opening native printer preferences for '${printerName}'...`);
|
||||
await invoke("show_printer_properties", { printerName });
|
||||
showNotification("success", `✓ Printer driver preferences configured for '${printerName}'.`);
|
||||
const result = await invoke("show_printer_properties", { printerName });
|
||||
|
||||
if (result === null) {
|
||||
// User cancelled the native panel.
|
||||
showNotification("info", "Printer properties dialog cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
// macOS: the backend returned a PrintPropertiesResult with the
|
||||
// effective printer and a PrintOptions snapshot containing captured
|
||||
// CUPS options from the native print panel.
|
||||
const effectivePrinter = result.selected_printer || printerName;
|
||||
|
||||
// If the user switched printer in the panel, update the dropdown.
|
||||
if (result.selected_printer && printerSelect && result.selected_printer !== printerName) {
|
||||
const exists = Array.from(printerSelect.options).some(
|
||||
o => o.value === result.selected_printer
|
||||
);
|
||||
if (exists) {
|
||||
printerSelect.value = result.selected_printer;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.options && result.options.cups_options) {
|
||||
capturedCupsOptions[effectivePrinter] = result.options.cups_options;
|
||||
} else {
|
||||
delete capturedCupsOptions[effectivePrinter];
|
||||
}
|
||||
|
||||
// If a media type was captured, update the dropdown to match.
|
||||
if (result.options && result.options.media_type && printerMediaTypeSelect) {
|
||||
const matchOpt = Array.from(printerMediaTypeSelect.options).find(
|
||||
o => o.value === result.options.media_type
|
||||
);
|
||||
if (matchOpt) {
|
||||
printerMediaTypeSelect.value = result.options.media_type;
|
||||
}
|
||||
}
|
||||
|
||||
showNotification("success", `Printer driver preferences configured for '${effectivePrinter}'.`);
|
||||
} catch (err) {
|
||||
console.error("[ICCery Print] Failed to open printer properties:", err);
|
||||
showNotification("error", `Could not open printer properties: ${err}`);
|
||||
@@ -510,6 +574,7 @@ export function initPrinttarg() {
|
||||
showNotification("error", "Please select a destination printer first.");
|
||||
return;
|
||||
}
|
||||
wizardState.printerName = printerName;
|
||||
|
||||
const options = getSelectedPrintOptions();
|
||||
const origBtnContent = triggeringButton ? triggeringButton.innerHTML : "";
|
||||
@@ -555,6 +620,7 @@ export function initPrinttarg() {
|
||||
showNotification("error", "Please select a destination printer first.");
|
||||
return;
|
||||
}
|
||||
wizardState.printerName = printerName;
|
||||
|
||||
const options = getSelectedPrintOptions();
|
||||
const cwd = stage1Cwd;
|
||||
|
||||
+533
-52
@@ -14,6 +14,423 @@ export function setStage4Result(basename, cwd) {
|
||||
profileBasename = basename || wizardState.basename;
|
||||
profileCwd = cwd || wizardState.cwd;
|
||||
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() {
|
||||
@@ -25,9 +442,68 @@ export function initProfcheck() {
|
||||
const maxDeEl = document.getElementById("profcheckMaxDe");
|
||||
const rmsDeEl = document.getElementById("profcheckRmsDe");
|
||||
const badgeEl = document.getElementById("profcheckBadge");
|
||||
const btnExport = document.getElementById("btnExportHistoryCsv");
|
||||
const btnClear = document.getElementById("btnClearHistory");
|
||||
const printerFilterSelect = document.getElementById("driftPrinterFilter");
|
||||
|
||||
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 () => {
|
||||
const basename = profileBasename || wizardState.basename;
|
||||
const cwd = profileCwd || wizardState.cwd;
|
||||
@@ -94,11 +570,66 @@ export function initProfcheck() {
|
||||
|
||||
if (event.payload.code === 0) {
|
||||
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
|
||||
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 {
|
||||
logPre.textContent += `\n[ERROR] profcheck exited with code ${event.payload.code}.\n`;
|
||||
}
|
||||
@@ -112,54 +643,4 @@ export function initProfcheck() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
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() {
|
||||
const dialog = document.getElementById('settingsDialog');
|
||||
const openBtn = document.getElementById('openSettingsBtn');
|
||||
@@ -11,6 +18,10 @@ export async function initSettings() {
|
||||
const btnCopyLogPath = document.getElementById('btnCopyLogPath');
|
||||
const btnCopyLogExcerpt = document.getElementById('btnCopyLogExcerpt');
|
||||
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;
|
||||
|
||||
@@ -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 () => {
|
||||
try {
|
||||
const settings = await invoke('load_settings');
|
||||
document.getElementById('argyll_binary_dir').value = settings.argyll_binary_dir || '';
|
||||
document.getElementById('default_instrument').value = settings.default_instrument || '';
|
||||
if (enableI1Pro2Leds) {
|
||||
enableI1Pro2Leds.checked = Boolean(settings.enable_i1pro2_leds);
|
||||
}
|
||||
if (logLevelSelect && 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();
|
||||
dialog.showModal();
|
||||
} catch (e) {
|
||||
@@ -99,18 +138,27 @@ export async function initSettings() {
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
if (!validateDeltaEThresholds()) {
|
||||
throw new Error('Invalid ΔE thresholds');
|
||||
}
|
||||
const currentSettings = await invoke('load_settings').catch(() => ({}));
|
||||
const settings = {
|
||||
...currentSettings,
|
||||
argyll_binary_dir: document.getElementById('argyll_binary_dir').value.trim() || null,
|
||||
default_instrument: document.getElementById('default_instrument').value.trim() || null,
|
||||
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 });
|
||||
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();
|
||||
} catch (e) {
|
||||
logger.error(`Failed to save settings: ${e}`, 'Settings');
|
||||
alert(`Failed to save settings: ${e}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export const wizardState = {
|
||||
currentStage: 1,
|
||||
basename: "",
|
||||
cwd: "",
|
||||
printerName: "",
|
||||
noticeTimer: null,
|
||||
|
||||
setTarget(basename, cwd) {
|
||||
@@ -75,6 +76,8 @@ export const wizardState = {
|
||||
s.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('stage-changed', { detail: { stage: stageNumber } }));
|
||||
},
|
||||
|
||||
async navigateToStage(stageNumber) {
|
||||
|
||||
+87
-14
@@ -1,10 +1,76 @@
|
||||
import { computeDeltaE00 } from './delta_e.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;
|
||||
|
||||
// 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.
|
||||
* @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 statsPanel = document.getElementById("readStats");
|
||||
|
||||
// Load thresholds at listener start
|
||||
await refreshDeltaEThresholds();
|
||||
|
||||
// Clear previous state
|
||||
grid.innerHTML = "";
|
||||
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}`;
|
||||
|
||||
// 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");
|
||||
rowEl.className = "swatch-row";
|
||||
|
||||
@@ -63,6 +134,10 @@ export async function startSwatchListener(processId, onRowComplete) {
|
||||
rowPatches.className = "swatch-row-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;
|
||||
|
||||
const patchEl = document.createElement("div");
|
||||
@@ -84,20 +159,21 @@ export async function startSwatchListener(processId, onRowComplete) {
|
||||
|
||||
const swatch = document.createElement("div");
|
||||
swatch.className = "swatch-color";
|
||||
|
||||
|
||||
if (measuredCss) {
|
||||
swatch.style.background = `linear-gradient(135deg, ${intendedCss} 50%, ${measuredCss} 50%)`;
|
||||
} else {
|
||||
swatch.style.backgroundColor = intendedCss;
|
||||
}
|
||||
|
||||
|
||||
patchEl.appendChild(swatch);
|
||||
|
||||
// Build a structured tooltip for the patch
|
||||
let titleStr = `${patch.loc} (ID: ${patch.id})`;
|
||||
if (patch.expected && patch.expected.Lab) {
|
||||
titleStr += `\nIntended Lab: ${patch.expected.Lab.map(v => v.toFixed(1)).join(', ')}`;
|
||||
} else if (patch.device) {
|
||||
titleStr += `\nDevice: ${patch.device.map(v => v.toFixed(1)).join(', ')}`;
|
||||
titleStr += `\nIntended Device: ${patch.device.map(v => v.toFixed(1)).join(', ')}`;
|
||||
}
|
||||
|
||||
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
|
||||
if (patch.expected && patch.expected.Lab && patch.measured && 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");
|
||||
deLabel.className = "swatch-de";
|
||||
deLabel.textContent = deltaE.toFixed(1);
|
||||
|
||||
titleStr += `\nΔE₀₀: ${deltaE.toFixed(2)}`;
|
||||
|
||||
// Traffic light classification
|
||||
if (deltaE < 2) {
|
||||
patchEl.classList.add("de-good"); // Green
|
||||
} else if (deltaE < 5) {
|
||||
patchEl.classList.add("de-warning"); // Amber
|
||||
} else {
|
||||
patchEl.classList.add("de-bad"); // Red
|
||||
}
|
||||
titleStr += `\nΔE₀₀: ${deltaE.toFixed(2)} (${label})`;
|
||||
|
||||
patchEl.classList.add(cssClass);
|
||||
patchEl.appendChild(deLabel);
|
||||
|
||||
// Accumulate stats
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
+578
-23
@@ -5,6 +5,11 @@
|
||||
--text-color: #d4d4d4;
|
||||
--accent-color: #007acc;
|
||||
--border-color: #333333;
|
||||
--btn-height-sm: 28px;
|
||||
--btn-height-md: 36px;
|
||||
--btn-height-lg: 40px;
|
||||
--btn-radius-sm: 4px;
|
||||
--btn-radius-md: 6px;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -127,25 +132,27 @@ button.primary {
|
||||
background: linear-gradient(135deg, var(--accent-color), #2d73a8);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--btn-radius-md);
|
||||
padding: 0 18px;
|
||||
height: 36px;
|
||||
height: var(--btn-height-md);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, transform 0.1s;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Button sizing system */
|
||||
.btn-sm { height: 28px; padding: 0 10px; font-size: 0.78rem; border-radius: 4px; }
|
||||
.btn-md { height: 36px; padding: 0 16px; font-size: 0.875rem; border-radius: 6px; }
|
||||
.btn-lg { height: 40px; padding: 0 20px; font-size: 0.95rem; font-weight: 600; border-radius: 6px; }
|
||||
.btn-icon-sq { width: 36px; height: 36px; min-width: 36px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||
/* Button sizing system — use CSS variables for consistency across the UI */
|
||||
.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: var(--btn-height-md); padding: 0 16px; font-size: 0.875rem; border-radius: var(--btn-radius-md); box-sizing: border-box; }
|
||||
.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; }
|
||||
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 */
|
||||
.stage-actions {
|
||||
@@ -163,10 +170,63 @@ button.primary:hover {
|
||||
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 {
|
||||
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 {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -254,6 +314,17 @@ select option:checked {
|
||||
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 {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -284,9 +355,10 @@ button.secondary {
|
||||
color: white;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0 16px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
height: var(--btn-height-md);
|
||||
border-radius: var(--btn-radius-sm);
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
@@ -489,6 +561,7 @@ button:disabled {
|
||||
.chartread-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -497,9 +570,10 @@ button.danger {
|
||||
background-color: #c62828;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-size: 1rem;
|
||||
border-radius: 4px;
|
||||
padding: 0 16px;
|
||||
height: var(--btn-height-md);
|
||||
font-size: 0.875rem;
|
||||
border-radius: var(--btn-radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -779,12 +853,12 @@ button.danger:hover {
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--btn-radius-sm);
|
||||
color: #d1d5db;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
width: var(--btn-height-sm);
|
||||
height: var(--btn-height-sm);
|
||||
min-width: var(--btn-height-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -973,8 +1047,9 @@ button.danger:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
padding: 0 12px;
|
||||
height: var(--btn-height-md);
|
||||
border-radius: var(--btn-radius-sm);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
@@ -984,6 +1059,7 @@ button.danger:hover {
|
||||
gap: 6px;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn-properties:hover {
|
||||
@@ -1042,20 +1118,24 @@ button.danger:hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Legacy alias: .btn-icon should not be used for new buttons; prefer .btn-icon-sq */
|
||||
.btn-icon {
|
||||
width: var(--btn-height-md);
|
||||
height: var(--btn-height-md);
|
||||
min-width: var(--btn-height-md);
|
||||
padding: 0;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
border-radius: var(--btn-radius-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
@@ -1078,7 +1158,7 @@ button.danger:hover {
|
||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.badge-printing {
|
||||
.badge-printing, .badge-primary {
|
||||
background: rgba(33, 150, 243, 0.15);
|
||||
color: #64b5f6;
|
||||
border: 1px solid rgba(33, 150, 243, 0.3);
|
||||
@@ -1349,3 +1429,478 @@ button.danger:hover {
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
3D Gamut Viewer — Stage 5
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.gamut-viewer-section {
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.gamut-viewer-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.gamut-viewer-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color, #d4d4d4);
|
||||
}
|
||||
|
||||
.gamut-viewer-subtitle {
|
||||
font-weight: 400;
|
||||
opacity: 0.55;
|
||||
font-size: 0.85rem;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.gamut-viewer-hint {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.45;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Wrapper gives the overlay panel a positioning context */
|
||||
.gamut-viewer-wrap {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
#gamutViewerContainer {
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
background: #0e0e14;
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Canvas fills the container */
|
||||
#gamutViewerContainer canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* ── Legend / controls panel ─────────────────────────────────────────────── */
|
||||
|
||||
.gamut-controls-panel {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 20;
|
||||
min-width: 154px;
|
||||
background: rgba(14, 14, 22, 0.82);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: 10px;
|
||||
padding: 10px 13px 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.gamut-legend-title {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0.45;
|
||||
font-weight: 600;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.gamut-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 7px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color, #d4d4d4);
|
||||
}
|
||||
|
||||
.gamut-legend-item:last-child {
|
||||
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 {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Colour swatches */
|
||||
.gamut-swatch {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.gamut-swatch-profile {
|
||||
/* Gradient hint at the three primary hue quadrants */
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #ef4444 50%, #22c55e 100%);
|
||||
}
|
||||
|
||||
.gamut-swatch-srgb {
|
||||
background: #6688aa;
|
||||
}
|
||||
|
||||
.gamut-swatch-axes {
|
||||
background: #3a3a5a;
|
||||
}
|
||||
|
||||
/* ── Mini iOS-style toggle switch ─────────────────────────────────────────── */
|
||||
|
||||
.gamut-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 30px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gamut-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.gamut-toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 16px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.gamut-toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
left: 2.5px;
|
||||
top: 2.5px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.gamut-toggle input:checked + .gamut-toggle-slider {
|
||||
background: var(--accent-color, #007acc);
|
||||
}
|
||||
|
||||
.gamut-toggle input:checked + .gamut-toggle-slider::before {
|
||||
transform: translateX(14px);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── CSS2D axis label styles (injected into overlay DOM by CSS2DRenderer) ── */
|
||||
|
||||
.gamut-axis-label {
|
||||
font-family: system-ui, -apple-system, monospace;
|
||||
font-size: 11px;
|
||||
color: rgba(200, 210, 230, 0.85);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9), 0 0 8px rgba(0, 0, 0, 0.7);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gamut-axis-primary {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(220, 230, 255, 0.95);
|
||||
}
|
||||
|
||||
.gamut-axis-a-pos { color: rgba(255, 160, 150, 0.85); }
|
||||
.gamut-axis-a-neg { color: rgba(140, 220, 160, 0.85); }
|
||||
.gamut-axis-b-pos { color: rgba(255, 230, 130, 0.85); }
|
||||
.gamut-axis-b-neg { color: rgba(140, 170, 255, 0.85); }
|
||||
|
||||
.gamut-tick-label {
|
||||
font-size: 10px;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user