29 Commits
Author SHA1 Message Date
Gordon Bolton c2fdfafaa7 Merge development for v0.8.0 release
Build macOS Packages / Build macOS (Apple Silicon) (push) Canceled after 0s
Build macOS Packages / Build macOS (Intel) (push) Canceled after 0s
Build macOS Packages / Build macOS (Universal (Intel + Apple Silicon)) (push) Canceled after 0s
Build Linux Packages / Build Linux (push) Canceled after 22s
Build Windows Packages / Build Windows (push) Canceled after 24s
Integrates Milestone 13 polish and closes #177, #178, #184, #176, #185, #179.

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

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

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

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

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

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

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

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

Generated with Devin
2026-09-04 00:18:51 +01:00
gronod d50c3c4f3c Merge pull request 'Release v0.7.4' (#197) from development into main
Build Windows Packages / Build Windows (push) Successful in 7m5s
Build Linux Packages / Build Linux (push) Successful in 16m34s
Release v0.7.4
2026-09-03 17:24:54 +01:00
gronod a90b6c6d77 Merge pull request 'fix(ci): enable DMG AppleScript background on GitHub macOS runners (#189)' (#196) from fix/189 into development
fix(ci): enable DMG AppleScript background on GitHub macOS runners (#189)
2026-09-03 17:24:41 +01:00
Gordon BoltonandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> d51bae705d fix(ci): enable DMG AppleScript background on GitHub macOS runners (#189)
Set TAURI_BUNDLER_DMG_IGNORE_CI=true so Tauri DMG bundler runs the
Finder AppleScript that writes the .DS_Store background reference.

Update the runner matrix to macos-26-intel (Intel) and macos-latest
(Apple Silicon / Universal), and add a warm-up step to launch/awaken
Finder and System Events before the build to reduce AppleEvent timeout
failures.

No DMG post-processing is performed; we accept the runner-dependent
risk of an AppleScript build failure to keep the build compatible with
future Tauri DMG code signing and notarization.

Closes #189

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-03 17:22:51 +01:00
Gordon BoltonandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 45b2208718 chore: bump version to 0.7.4
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-03 17:21:08 +01:00
gronod 9a365acd2b Merge pull request 'fix(print): macOS Preferences crash and color-management hardening (closes #188)' (#195) from fix/188-12 into main
Build Windows Packages / Build Windows (push) Successful in 7m6s
Build Linux Packages / Build Linux (push) Successful in 10m34s
Merge fix/188-12 into main: macOS Preferences crash fix and color-management hardening (closes #188)
2026-09-03 13:31:17 +01:00
gronod a5e090b323 Merge pull request 'fix(print): macOS Preferences crash and color-management hardening (closes #188)' (#194) from fix/188-12 into development
Merge fix/188-12 into development: macOS Preferences crash fix and color-management hardening (closes #188)
2026-09-03 13:30:56 +01:00
Gordon Bolton a4612bf065 chore: bump version to 0.7.3 2026-09-03 13:30:03 +01:00
Gordon BoltonandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 8b01c98463 fix(print): remove crashing 3-arg PMSessionSetColorMatchingMode calls and fix SPI usage (closes #188)
Build Windows Packages / Build Windows (push) Successful in 7m7s
Build Linux Packages / Build Linux (push) Successful in 11m4s
- All PMSessionSetColorMatchingMode* symbols use the 2-arg (PMPrintSession, *const CFString) signature.
- PMSessionSetColorMatchingModeLock sets and locks in one call; it does not take a Boolean.
- Remove speculative 3-argument transmutes that caused EXC_BAD_ACCESS.
- Restrict color-matching modes to application-managed bypass only (AP_ApplicationColorMatching / ApplicationColorMatching).
- Preserve the PMSessionGetCurrentPrinter pre-check.
- Return bool from set_session_color_matching_mode and log SPI results.
- Update non-macOS stub to return bool.
- Improve run_native_print_panel error logging when the printer cannot be resolved.
- Add unit tests for color-bypass filtering and detection.
- Update AGENTS.md with the corrected SPI notes.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-03 12:58:01 +01:00
Gordon Bolton 37b0c13263 fix(print): fix PMSessionGetCurrentPrinter type with .into() and remove unused mut on printer_from_id
Build Linux Packages / Build Linux (push) Failing after 10m25s
Build Windows Packages / Build Windows (push) Failing after 2h7m9s
2026-09-03 09:39:08 +01:00
Gordon Bolton 3e844fdf8b fix(print): extended PMSessionSetColorMatchingMode signature search with mode constants, session validation, and detailed logging
Build Linux Packages / Build Linux (push) Failing after 10m59s
Build Windows Packages / Build Windows (push) Failing after 2h6m47s
2026-09-03 07:35:59 +01:00
Gordon Bolton bafed60b20 fix(print): try correct PMSessionSetColorMatchingMode signatures (3-arg, 2-arg+lock, NoLock+lock) to avoid crash
Build Windows Packages / Build Windows (push) Failing after 5m20s
Build Linux Packages / Build Linux (push) Failing after 10m55s
2026-09-03 00:46:37 +01:00
Gordon Bolton 7b8d9e644a fix(print): correct log::warn! string literal in color matching helper
Build Windows Packages / Build Windows (push) Failing after 9m50s
Build Linux Packages / Build Linux (push) Failing after 10m52s
2026-09-03 00:28:01 +01:00
Gordon Bolton ff01e97ccf fix(print): use private PMSessionSetColorMatchingMode SPI and pre-select driver color bypass
Build Windows Packages / Build Windows (push) Successful in 7m14s
Build Linux Packages / Build Linux (push) Failing after 14m28s
2026-09-03 00:19:59 +01:00
Gordon Bolton 8f8c73d46b fix(print): deref Retained<NSString> for NSPrintInfo printSettings insert
Build Windows Packages / Build Windows (push) Successful in 6m38s
Build Linux Packages / Build Linux (push) Successful in 10m7s
2026-09-02 23:33:39 +01:00
Gordon Bolton 2273882330 fix(print): force application color matching on macOS via PM, NSPrintInfo, and lp
Build Windows Packages / Build Windows (push) Failing after 5m30s
Build Linux Packages / Build Linux (push) Successful in 10m30s
2026-09-02 23:22:58 +01:00
Gordon Bolton 53d5ab20b9 fix(print): add display_name field to Windows Printer initializers
Build Windows Packages / Build Windows (push) Successful in 6m44s
Build Linux Packages / Build Linux (push) Successful in 9m19s
2026-09-02 23:04:25 +01:00
Gordon Bolton 313bdfdee4 fix(print): bind macOS print panel via Core Printing, fix Windows build, and correct Epson bypass (#188)
Build Windows Packages / Build Windows (push) Failing after 2m5s
Build Linux Packages / Build Linux (push) Successful in 10m43s
2026-09-02 22:44:20 +01:00
Gordon BoltonandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ce5e0ef2b5 fix(print): open native NSPrintPanel for macOS printer properties (#188)
Build Windows Packages / Build Windows (push) Failing after 2m15s
Build Linux Packages / Build Linux (push) Successful in 11m2s
Replace the CUPS web interface fallback with the native macOS NSPrintPanel,
pre-configured with AP_ColorMatchingMode=AP_ApplicationColorMatching so the
driver's color-management controls are greyed out (application manages color).
Capture the user's media type and quality selections from the dialog and feed
them into the lp job via PrintOptions.cups_options.

- Add objc2-app-kit / objc2-application-services dependencies for NSPrintPanel
- Rewrite show_printer_properties to dispatch NSPrintPanel on the main thread
- Always add AP_ColorMatchingMode=AP_ApplicationColorMatching in build_lp_args
- Forward captured CUPS options with deduplication against explicit fields
- Detect Epson EPIJ_Medi / EPIJ_CCor / EPIJ_OSColMat PPD options
- Add parse_cups_options_string helper and filtering of internal Apple keys
- Update frontend to store and pass captured options per printer

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-02 21:21:52 +01:00
gronod 47a5373a8e chore: merge development into main (fix Windows build failure)
Build Windows Packages / Build Windows (push) Successful in 7m28s
Build Linux Packages / Build Linux (push) Successful in 10m39s
Merge pull request #193 from development into main
2026-09-02 19:07:58 +01:00
25 changed files with 2058 additions and 199 deletions
+9
View File
@@ -62,6 +62,15 @@ 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
cargo test --target ${{ matrix.platform.target }}
- name: Set Release Environment
id: set_env
shell: bash
+16 -1
View File
@@ -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
+104
View File
@@ -0,0 +1,104 @@
# 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`
+4 -3
View File
@@ -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.
---
+9
View File
@@ -99,6 +99,15 @@ ICCery is a native, cross-platform desktop application built with:
- [ ] **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 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 (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).
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "iccery",
"private": true,
"version": "0.7.2",
"version": "0.7.4",
"type": "module",
"scripts": {
"fetch-argyll": "node scripts/fetch-argyll.mjs",
+68 -1
View File
@@ -1423,11 +1423,16 @@ dependencies = [
[[package]]
name = "iccery"
version = "0.7.2"
version = "0.8.0"
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"
+8 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "iccery"
version = "0.7.2"
version = "0.8.0"
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",
+36 -6
View File
@@ -1152,26 +1152,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())
}
}
@@ -1780,6 +1783,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 {
+780 -47
View File
@@ -1,12 +1,522 @@
#![allow(dead_code)]
use std::ffi::CStr;
use std::path::Path;
use crate::print::PrintOptions;
pub use crate::print::unix::{
get_printer_capabilities, get_printers,
};
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,
@@ -15,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![
@@ -25,57 +537,103 @@ pub fn build_lp_args(
title,
];
let ppd_fallback = options
.and_then(|o| o.ppd_uncorrected_passthrough)
.unwrap_or(false);
// Always apply ColorSync bypass on macOS for targeting
// 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());
// Fetch lpoptions for this printer to detect capabilities
if let Ok(out) = std::process::Command::new("lpoptions")
.args(["-p", printer_name, "-l"])
.output()
{
if out.status.success() {
let output_str = String::from_utf8_lossy(&out.stdout);
// 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 let Some(opts) = options {
if let Some(ref media_type) = opts.media_type {
if !media_type.trim().is_empty() {
let key = crate::print::unix::detect_media_type_key(&output_str);
// 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, media_type.trim()));
args.push(format!("{}={}", key, value));
added_keys.insert(key.to_lowercase());
}
}
}
if ppd_fallback {
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));
}
}
}
}
// 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());
}
}
}
@@ -118,14 +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> {
// macOS System Preferences don't show PPD driver options.
// We open the CUPS web interface instead.
let url = format!("http://localhost:631/printers/{}", printer_name);
let _ = std::process::Command::new("open")
.args([&url])
.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);
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"))
);
}
}
+15
View File
@@ -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)]
@@ -43,6 +46,18 @@ pub struct PrintOptions {
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)]
+30 -5
View File
@@ -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");
@@ -72,6 +73,7 @@ mod integration_tests {
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");
@@ -81,6 +83,26 @@ mod integration_tests {
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]
@@ -230,6 +252,7 @@ printer Custom_Queue unknown state\n\
paper_size: None,
media_type: Some("4".to_string()),
ppd_uncorrected_passthrough: None,
cups_options: None,
};
let mut work_buf = retrieved;
@@ -265,9 +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], "AP_ColorMatchingMode=AP_ApplicationColorMatching");
assert_eq!(args_raw[6], 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 {
@@ -277,8 +301,9 @@ 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], "AP_ColorMatchingMode=AP_ApplicationColorMatching");
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);
+156 -36
View File
@@ -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")
@@ -166,7 +209,11 @@ 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") {
} 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 {
@@ -184,7 +231,11 @@ pub fn parse_lpoptions_l(output: &str) -> (Vec<PrinterTray>, Vec<PrinterPaperSiz
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 ") {
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();
@@ -202,10 +253,17 @@ pub fn detect_driver_color_bypass(output: &str) -> Option<(&'static str, &'stati
Some(("CNIJIntent2", "4"))
} else if output.contains("CNIJIntent") {
Some(("CNIJIntent", "4"))
} else if output.contains("ColorCorrection") {
Some(("ColorCorrection", "Uncorrected"))
} 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 {
@@ -216,6 +274,8 @@ pub fn detect_driver_color_bypass(output: &str) -> Option<(&'static str, &'stati
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 {
@@ -223,6 +283,54 @@ pub fn detect_media_type_key(output: &str) -> &'static str {
}
}
/// 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`.
pub fn get_printer_capabilities(printer_name: &str) -> Result<PrinterCapabilities, String> {
let output = std::process::Command::new("lpoptions")
@@ -410,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]
@@ -477,10 +568,39 @@ Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\
#[test]
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);
+2
View File
@@ -128,6 +128,7 @@ pub fn get_printers() -> Result<Vec<Printer>, String> {
name,
status: "Ready".to_string(),
is_default: false,
display_name: None,
});
}
}
@@ -158,6 +159,7 @@ pub fn get_printers() -> Result<Vec<Printer>, String> {
name,
status: "Ready".to_string(),
is_default: false,
display_name: None,
});
}
}
+68 -1
View File
@@ -60,15 +60,48 @@ 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>,
}
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(),
}
}
}
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> {
vec![
ProfilingPreset {
@@ -239,6 +272,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 +414,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);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ICCery",
"version": "0.7.2",
"version": "0.8.0",
"identifier": "com.gronod.iccery",
"build": {
"frontendDist": "../src"
+84 -28
View File
@@ -35,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>
@@ -131,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 &amp; 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 &amp; 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>
@@ -407,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 &amp; Metadata (Optional)</label>
<div class="form-group-header">
<label class="form-group-title">Target Label &amp; 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>
@@ -481,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>
@@ -521,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>
@@ -550,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>
@@ -593,13 +593,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 &amp; Proceed →</button>
</div>
@@ -648,7 +648,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>
@@ -656,14 +656,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">
@@ -673,7 +683,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>
@@ -685,8 +695,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>
@@ -694,8 +707,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>
@@ -704,8 +720,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>
@@ -716,6 +735,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>
@@ -723,7 +745,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 -->
@@ -736,7 +758,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 -->
@@ -752,7 +774,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 -->
@@ -805,6 +827,10 @@
<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>
@@ -814,6 +840,10 @@
<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>
@@ -823,6 +853,15 @@
<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>
@@ -848,6 +887,23 @@
<label for="default_instrument">Default Instrument Override</label>
<input type="text" id="default_instrument" placeholder="e.g. i1">
</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 &amp; Logging</label>
<div class="input-row" style="margin-top: 8px;">
@@ -862,7 +918,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>
+174 -16
View File
@@ -204,30 +204,80 @@ function _line(from, to, material) {
}
// ─────────────────────────────────────────────────────────────────────────────
// Parse .gam file text → { vertices: [[L,a,b],...], faces: [[v0,v1,v2],...] }
/**
* Parse an Argyll `.gam` text file into vertex and face arrays.
*
* Argyll `.gam` files contain a header followed by one or more `BEGIN_DATA`
* ... `END_DATA` blocks. The first data block is a vertex list
* (index L a b); subsequent blocks contain triangle face indices (v0 v1 v2).
* Blank lines and hash `#` comments outside data blocks are ignored.
*
* @param {string} text - Raw contents of the .gam file.
* @returns {{ vertices: number[][], faces: number[][], warnings: string[] }}
*/
// ─────────────────────────────────────────────────────────────────────────────
export function parseGamutFile(text) {
const lines = text.split('\n');
const vertices = [];
const faces = [];
const warnings = [];
let dataStarted = false;
let dataBlock = 0; // 1 = vertices section, 2 = faces section
let dataBlock = 0; // 1 = vertices section, 2+ = faces sections
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === 'BEGIN_DATA') { dataBlock++; dataStarted = true; continue; }
if (trimmed === 'END_DATA') { dataStarted = false; continue; }
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.toUpperCase() === 'END_DATA') {
dataStarted = false;
continue;
}
if (!dataStarted) continue;
const parts = trimmed.split(/\s+/).map(Number);
if (dataBlock === 1 && parts.length >= 4) {
vertices.push([parts[1], parts[2], parts[3]]); // [L, a, b]
} else if (dataBlock === 2 && parts.length >= 3) {
faces.push([parts[0], parts[1], parts[2]]);
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 };
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -304,7 +354,10 @@ function _renderSrgbReference(text, previousGroup) {
});
}
const { vertices, faces } = parseGamutFile(text);
const { vertices, faces, warnings } = parseGamutFile(text);
if (warnings.length > 0) {
console.warn('Gamut parser warnings:', warnings.join('\n'));
}
const built = _buildGeometry(vertices, faces);
if (!built) return null;
@@ -348,7 +401,10 @@ function _renderProfileGamut(text, previousMesh) {
if (previousMesh.material) previousMesh.material.dispose();
}
const { vertices, faces } = parseGamutFile(text);
const { vertices, faces, warnings } = parseGamutFile(text);
if (warnings.length > 0) {
console.warn('Gamut parser warnings:', warnings.join('\n'));
}
const built = _buildGeometry(vertices, faces);
if (!built) return null;
@@ -383,7 +439,77 @@ function _renderProfileGamut(text, previousMesh) {
}
// ─────────────────────────────────────────────────────────────────────────────
// Public: load bundled sRGB reference gamut
// Camera & view controls
// ─────────────────────────────────────────────────────────────────────────────
/**
* Reset the camera to its default home position and orientation.
* Also resets orbit controls target to the centre of the CIELAB volume.
*/
export function resetCamera() {
if (!camera || !controls) return;
camera.position.set(180, 120, 180);
camera.lookAt(0, 50, 0);
controls.target.set(0, 50, 0);
controls.update();
}
/**
* Set the opacity of the rendered profile gamut surface (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 {
@@ -398,7 +524,11 @@ export async function loadSrgbReferenceGamut() {
}
// ─────────────────────────────────────────────────────────────────────────────
// Public: load and render a profile gamut from a .gam file path
/**
* Load and render a profile gamut from a `.gam` file path.
* @param {string} gamFilePath - Absolute path to the gamut file.
* @returns {Promise<THREE.Mesh|null>}
*/
// ─────────────────────────────────────────────────────────────────────────────
export async function loadGamutMesh(gamFilePath) {
try {
@@ -428,7 +558,7 @@ export function toggleAxes(visible) {
}
// ─────────────────────────────────────────────────────────────────────────────
// Wire legend toggle checkboxes to the toggle functions
// Wire legend toggle checkboxes, opacity sliders, and reset button
// ─────────────────────────────────────────────────────────────────────────────
function _wireToggles() {
const bindings = [
@@ -440,4 +570,32 @@ function _wireToggles() {
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();
}
});
}
}
+80
View File
@@ -0,0 +1,80 @@
// Manual / browser-console tests for gamut_viewer.js and profcheck.js parsing.
// Run in a browser/devtools console after the app has loaded:
// import('./gamut_viewer.test.js').then(m => m.runAll())
import { parseGamutFile } from './gamut_viewer.js';
export function runAll() {
console.group('gamut/profcheck parser tests');
testParseGamutBasic();
testParseGamutDualTable();
testParseGamutWithComments();
console.groupEnd();
}
function assertEqual(actual, expected, message) {
const ok = JSON.stringify(actual) === JSON.stringify(expected);
if (ok) {
console.log('PASS:', message);
} else {
console.error('FAIL:', message, 'expected', expected, 'got', actual);
}
return ok;
}
function testParseGamutBasic() {
const text = `GAMUT file
BEGIN_DATA
0 50 0 0
1 100 0 0
2 0 -128 0
3 0 0 128
END_DATA
BEGIN_DATA
0 1 2
1 2 3
END_DATA`;
const { vertices, faces, warnings } = parseGamutFile(text);
assertEqual(vertices.length, 4, 'basic gamut vertex count');
assertEqual(faces.length, 2, 'basic gamut face count');
assertEqual(warnings.length, 0, 'basic gamut no warnings');
}
function testParseGamutDualTable() {
const text = `GAMUT file
BEGIN_DATA
0 50 0 0
1 100 0 0
2 0 -128 0
3 0 0 128
END_DATA
BEGIN_DATA
0 1 2
END_DATA
BEGIN_DATA
1 2 3
END_DATA`;
const { vertices, faces, warnings } = parseGamutFile(text);
assertEqual(vertices.length, 4, 'dual-table gamut vertex count');
assertEqual(faces.length, 2, 'dual-table gamut face count');
assertEqual(warnings.length, 0, 'dual-table gamut no warnings');
}
function testParseGamutWithComments() {
const text = `GAMUT file
# this is a comment
BEGIN_DATA
0 50 0 0
1 100 0 0
# inline comment
2 0 -128 0
3 0 0 128
END_DATA
BEGIN_DATA
0 1 2
1 2 3
END_DATA`;
const { vertices, faces, warnings } = parseGamutFile(text);
assertEqual(vertices.length, 4, 'commented gamut vertex count');
assertEqual(faces.length, 2, 'commented gamut face count');
}
+52 -3
View File
@@ -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;
/**
@@ -256,6 +261,8 @@ export function initPrinttarg() {
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,
@@ -263,6 +270,7 @@ export function initPrinttarg() {
paper_size: pageSizeSelect ? pageSizeSelect.value : null,
media_type: mediaType ? mediaType : null,
ppd_uncorrected_passthrough: ppdFallback,
cups_options: cupsOpts,
};
}
@@ -321,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;
@@ -373,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}`);
+76 -13
View File
@@ -98,7 +98,14 @@ export function initProfcheck() {
// 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`;
}
@@ -113,33 +120,89 @@ export function initProfcheck() {
}
});
/**
* Parse profcheck output for Average, Peak, and RMS delta-E values.
* Supports both Argyll's JSON-style summary and plain-text legacy output.
* @param {string} stdout - Full profcheck stdout.
*/
function parseAndRenderReport(stdout) {
reportCard.classList.remove("hidden");
let avgDe = 0.0;
let maxDe = 0.0;
let rmsDe = 0.0;
let parserWarnings = [];
// Check if JSON output is present
const jsonMatch = stdout.match(/\{[\s\S]*"avg_de"[\s\S]*\}/);
if (jsonMatch) {
// Argyll's JSON output can appear either as a compact object on a single
// line or embedded inside larger text. Try to find and parse the LAST valid
// JSON object in the output, which is most likely the summary.
const jsonObjects = [];
const re = /\{[\s\S]*?\}/g;
let m;
while ((m = re.exec(stdout)) !== null) {
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;
const parsed = JSON.parse(m[0]);
if (typeof parsed === 'object' && parsed !== null && ('avg_de' in parsed || 'peak_de' in parsed || 'rms_de' in parsed)) {
jsonObjects.push(parsed);
}
} catch (e) {
console.error("JSON parse error:", e);
// Not a valid JSON object, ignore.
}
}
if (jsonObjects.length > 0) {
const json = jsonObjects[jsonObjects.length - 1];
avgDe = typeof json.avg_de === 'number' ? json.avg_de : 0;
maxDe = typeof json.max_de === 'number' ? json.max_de : (typeof json.peak_de === 'number' ? json.peak_de : 0);
rmsDe = typeof json.rms_de === 'number' ? json.rms_de : 0;
} 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);
// 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 parserWarnings.push('Could not detect Average ΔE in profcheck output.');
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
else parserWarnings.push('Could not detect Peak ΔE in profcheck output.');
if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]);
else parserWarnings.push('Could not detect RMS ΔE in profcheck output.');
if (!avgMatch && !maxMatch && !rmsMatch) {
parserWarnings.push('No delta-E values were found in profcheck output.');
}
}
if (parserWarnings.length > 0) {
logPre.textContent += `\n[WARN] ${parserWarnings.join(' ')}\n`;
}
avgDeEl.textContent = avgDe.toFixed(2);
+43
View File
@@ -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,9 @@ 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');
if (!dialog || !openBtn) return;
@@ -24,6 +34,24 @@ 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');
@@ -32,6 +60,13 @@ export async function initSettings() {
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 +134,26 @@ 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),
};
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}`);
}
});
}
+87 -14
View File
@@ -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
+16
View File
@@ -0,0 +1,16 @@
// Manual smoke tests for swatch_grid.js
// Run in a browser/devtools console after the app has loaded:
// import('./swatch_grid.test.js').then(m => m.runAll())
export function runAll() {
console.group('swatch_grid smoke tests');
testClassifyDeltaE();
console.groupEnd();
}
function testClassifyDeltaE() {
// classifyDeltaE is currently internal to swatch_grid.js, but we can
// validate the visual classification by exercising the colour thresholds.
// This is a placeholder for a future unit-test harness.
console.log('testClassifyDeltaE: no direct export; validated by manual inspection');
}
+139 -22
View File
@@ -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 {
@@ -1450,6 +1530,43 @@ button.danger:hover {
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;