Compare commits
74
Commits
5b5c600ffc
...
v0.8.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18a6d9394c | ||
|
|
9016932547 | ||
|
|
2f7d8f3ca7 | ||
|
|
41efac68ed | ||
|
|
9aad7b083a | ||
|
|
5ee1b07263 | ||
|
|
53ce298bc1 | ||
|
|
45ed7dbfe5 | ||
|
|
f553f10399 | ||
|
|
a9112445bb | ||
|
|
a584b9746f | ||
|
|
c2fdfafaa7 | ||
|
|
b60d0c3bce | ||
|
|
8a0065274b | ||
|
|
ab2f6e562e | ||
|
|
3026e7081c | ||
|
|
84df0b7a7d | ||
|
|
9f8b2c8567 | ||
|
|
2c97e98dc3 | ||
|
|
0603faca59 | ||
|
|
956b5ff499 | ||
|
|
d50c3c4f3c | ||
|
|
a90b6c6d77 | ||
|
|
d51bae705d | ||
|
|
45b2208718 | ||
|
|
9a365acd2b | ||
|
|
a5e090b323 | ||
|
|
a4612bf065 | ||
|
|
8b01c98463 | ||
|
|
37b0c13263 | ||
|
|
3e844fdf8b | ||
|
|
bafed60b20 | ||
|
|
7b8d9e644a | ||
|
|
ff01e97ccf | ||
|
|
8f8c73d46b | ||
|
|
2273882330 | ||
|
|
53d5ab20b9 | ||
|
|
313bdfdee4 | ||
|
|
ce5e0ef2b5 | ||
|
|
47a5373a8e | ||
|
|
6a4551bab6 | ||
|
|
0b6974d060 | ||
|
|
75cba657cd | ||
|
|
a8764d90fd | ||
|
|
b78002b856 | ||
|
|
aad79681af | ||
|
|
be0f55aefc | ||
|
|
9a3f841f9f | ||
|
|
9768252f6b | ||
|
|
c37662daed | ||
|
|
2079556d61 | ||
|
|
fb438a7880 | ||
|
|
5c2c90f6e0 | ||
|
|
c917f059b1 | ||
|
|
a66b98fd9a | ||
|
|
76e7b6ee5f | ||
|
|
0cc6e9cb7a | ||
|
|
e8a703d844 | ||
|
|
f40fb410d6 | ||
|
|
16c6a15a90 | ||
|
|
61554aecc7 | ||
|
|
bdd44f9645 | ||
|
|
22e0650307 | ||
|
|
fdd9f75d10 | ||
|
|
4ba6242fd6 | ||
|
|
805922df6c | ||
|
|
89d156e400 | ||
|
|
b93ea5d933 | ||
|
|
05f7be8888 | ||
|
|
3f1b324a14 | ||
|
|
2e62e7660d | ||
|
|
7cebbf8b79 | ||
|
|
669d7a7f20 | ||
|
|
05544550ed |
@@ -0,0 +1,57 @@
|
||||
# Auto detect text files and perform LF normalization on commit
|
||||
* text=auto eol=lf
|
||||
|
||||
# Explicit text files normalized to LF
|
||||
*.rs text eol=lf
|
||||
*.js text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.json text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.svg text eol=lf
|
||||
*.md text eol=lf
|
||||
*.py text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.lock text eol=lf
|
||||
.gitignore text eol=lf
|
||||
.gitattributes text eol=lf
|
||||
.editorconfig text eol=lf
|
||||
|
||||
# ArgyllCMS datasets and test fixtures (CGATS text format)
|
||||
*.ti1 text eol=lf
|
||||
*.ti2 text eol=lf
|
||||
*.ti3 text eol=lf
|
||||
*.gam text eol=lf
|
||||
*.mock text eol=lf
|
||||
|
||||
# Windows scripts and installers
|
||||
*.cmd text eol=crlf
|
||||
*.bat text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
*.nsh text eol=crlf
|
||||
|
||||
# Binary file declarations (prevent EOL conversions)
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.bmp binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.icns binary
|
||||
*.tif binary
|
||||
*.tiff binary
|
||||
*.icc binary
|
||||
*.icm binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
*.so binary
|
||||
*.dylib binary
|
||||
*.zip binary
|
||||
*.tar binary
|
||||
*.tar.gz binary
|
||||
*.tgz binary
|
||||
*.7z binary
|
||||
@@ -13,17 +13,17 @@ jobs:
|
||||
matrix:
|
||||
platform:
|
||||
- name: Intel
|
||||
os: macos-15-intel
|
||||
os: macos
|
||||
target: x86_64-apple-darwin
|
||||
binary_dir: macos-x86_64
|
||||
arch: x86_64
|
||||
- name: Apple Silicon
|
||||
os: macos-15
|
||||
os: macos
|
||||
target: aarch64-apple-darwin
|
||||
binary_dir: macos-aarch64
|
||||
arch: arm64
|
||||
- name: Universal (Intel + Apple Silicon)
|
||||
os: macos-15
|
||||
os: macos
|
||||
target: universal-apple-darwin
|
||||
binary_dir: macos-universal
|
||||
arch: universal
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Rust stable
|
||||
@@ -44,9 +44,6 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.platform.target == 'universal-apple-darwin' && 'x86_64-apple-darwin, aarch64-apple-darwin' || matrix.platform.target }}
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -62,6 +59,21 @@ jobs:
|
||||
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/instlist"
|
||||
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/targen"
|
||||
|
||||
- name: Run Rust tests
|
||||
shell: bash
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_TARGET_DIR: "${{ runner.temp }}/cargo-target"
|
||||
run: |
|
||||
cd src-tauri
|
||||
if [ "${{ matrix.platform.target }}" = "universal-apple-darwin" ]; then
|
||||
cargo test
|
||||
elif [ "${{ matrix.platform.target }}" = "aarch64-apple-darwin" ] && [ "$(uname -m)" != "arm64" ]; then
|
||||
cargo test --no-run --target ${{ matrix.platform.target }}
|
||||
else
|
||||
cargo test --target ${{ matrix.platform.target }}
|
||||
fi
|
||||
|
||||
- name: Set Release Environment
|
||||
id: set_env
|
||||
shell: bash
|
||||
|
||||
@@ -74,7 +74,31 @@ jobs:
|
||||
"SHORT_SHA=$SHORT_SHA" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"PREFIX=ICCery_${TAG}-${SHORT_SHA}-windows-x86_64" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: Setup Code Signing Certificate
|
||||
shell: powershell
|
||||
env:
|
||||
WINDOWS_CODESIGN_CERT_BASE64: ${{ secrets.WINDOWS_CODESIGN_CERT_BASE64 }}
|
||||
WINDOWS_CODESIGN_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PASSWORD }}
|
||||
run: |
|
||||
$workspace = (Get-Location).Path
|
||||
"$workspace" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
"$workspace\scripts" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
"$workspace\src-tauri" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
if ($env:WINDOWS_CODESIGN_CERT_BASE64) {
|
||||
$certPath = "$env:TEMP\codesign.pfx"
|
||||
$bytes = [Convert]::FromBase64String($env:WINDOWS_CODESIGN_CERT_BASE64)
|
||||
[IO.File]::WriteAllBytes($certPath, $bytes)
|
||||
"WINDOWS_CODESIGN_CERT_PATH=$certPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"WINDOWS_CODESIGN_PASSWORD=$env:WINDOWS_CODESIGN_PASSWORD" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
Write-Host "Code signing certificate initialized at $certPath"
|
||||
} else {
|
||||
Write-Host "No code signing certificate secret provided. Skipping signing setup."
|
||||
}
|
||||
|
||||
- name: Build Tauri App
|
||||
env:
|
||||
WINDOWS_CODESIGN_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PASSWORD }}
|
||||
run: npm run tauri build -- -v
|
||||
|
||||
- name: Prepare Release Assets
|
||||
@@ -134,3 +158,12 @@ jobs:
|
||||
} catch {
|
||||
Write-Host "Skipping release asset upload: $_"
|
||||
}
|
||||
|
||||
- name: Cleanup Code Signing Certificate
|
||||
if: always()
|
||||
shell: powershell
|
||||
run: |
|
||||
if (Test-Path "$env:TEMP\codesign.pfx") {
|
||||
Remove-Item -Force "$env:TEMP\codesign.pfx"
|
||||
Write-Host "Temporary code signing certificate cleaned up."
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
matrix:
|
||||
platform:
|
||||
- name: Intel
|
||||
os: macos-latest
|
||||
os: macos-26-intel
|
||||
target: x86_64-apple-darwin
|
||||
binary_dir: macos-x86_64
|
||||
arch: x86_64
|
||||
@@ -74,7 +74,22 @@ jobs:
|
||||
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
|
||||
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Warm up macOS GUI for DMG layout
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
# Tauri's DMG bundler runs an AppleScript that asks Finder to set the
|
||||
# background picture and icon positions. On CI runners, Finder or the
|
||||
# Aqua session may be idle, which can cause AppleEvent timeouts. Try to
|
||||
# start/awaken Finder and System Events before the real build.
|
||||
open -g -a Finder || true
|
||||
open -g -a "System Events" || true
|
||||
# Send a harmless probe to force Finder to initialise a scripting session.
|
||||
osascript -e 'tell application "Finder" to get name' || true
|
||||
sleep 10
|
||||
|
||||
- name: Build Tauri App
|
||||
env:
|
||||
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
||||
run: npm run tauri build -- --target ${{ matrix.platform.target }}
|
||||
|
||||
- name: Prepare Release Assets
|
||||
|
||||
@@ -0,0 +1,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`
|
||||
+9
-36
@@ -1,40 +1,13 @@
|
||||
# End-User Licence Agreement (EULA) for ICCery
|
||||
# LICENCE
|
||||
|
||||
**IMPORTANT — READ CAREFULLY:** This End-User Licence Agreement ("EULA") is a legal agreement between you (either an individual or a single entity) and the developer of ICCery ("Licensor") for the software product identified above, which includes computer software and may include associated media, printed materials, and "online" or electronic documentation ("Software").
|
||||
**Copyright (c) 2026 Gordon Bolton**
|
||||
**All Rights Reserved.**
|
||||
|
||||
By installing, copying, or otherwise using the Software, you agree to be bound by the terms of this EULA. If you do not agree to the terms of this EULA, do not install or use the Software.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), strictly to view the source code and execute the Software for the sole purpose of personal testing, evaluation, and providing feedback.
|
||||
|
||||
## 1. GRANT OF LICENCE
|
||||
Licensor grants you a revocable, non-exclusive, non-transferable, limited commercial licence to download, install, and use the ICCery graphical user interface (GUI) software strictly in accordance with the terms of this Agreement.
|
||||
Under this licence, you may **not**:
|
||||
* Modify, alter, or create derivative works of the Software.
|
||||
* Distribute, publish, or sublicense the Software or any derivatives.
|
||||
* Use the Software for any commercial or production purpose.
|
||||
|
||||
## 2. RESTRICTIONS
|
||||
You agree not to, and you will not permit others to:
|
||||
* Licence, sell, rent, lease, assign, distribute, transmit, host, outsource, disclose, or otherwise commercially exploit the ICCery GUI.
|
||||
* Modify, make derivative works of, disassemble, decrypt, reverse compile, or reverse engineer any part of the proprietary ICCery GUI software.
|
||||
* Remove, alter, or obscure any proprietary notice (including any notice of copyright or trademark) of Licensor or its affiliates, partners, suppliers, or the licensors of the Software.
|
||||
|
||||
## 3. INTELLECTUAL PROPERTY
|
||||
The ICCery GUI (excluding third-party components described below) is proprietary software. All copyrights, patents, trademarks, trade secrets, and other intellectual property rights in the ICCery GUI are and shall remain the sole and exclusive property of the Licensor.
|
||||
|
||||
## 4. THIRD-PARTY COMPONENTS & AGPL COMPLIANCE
|
||||
ICCery relies on and interacts with a modified version of **ArgyllCMS**, an open-source colour management system.
|
||||
|
||||
### Arms-Length Integration
|
||||
The ICCery software architecture maintains a strict, loosely-coupled separation from ArgyllCMS. The proprietary ICCery GUI operates as a distinct, standalone process that interacts with the ArgyllCMS binaries strictly at arm's length via standard inter-process communication. Specifically, ICCery executes the modified ArgyllCMS binary and receives real-time, line-by-line patch measurement spectral data piped through standard output (stdout).
|
||||
|
||||
Because of this separate process architecture, the proprietary commercial licence of ICCery does not extend to ArgyllCMS, and the AGPLv3 licence of ArgyllCMS does not extend to or infect the proprietary ICCery GUI wrapper.
|
||||
|
||||
### ArgyllCMS Licence (AGPLv3)
|
||||
ArgyllCMS is licensed under the GNU Affero General Public Licence version 3.0 (AGPLv3).
|
||||
* The original ArgyllCMS software is copyright (c) Graeme W. Gill.
|
||||
* In compliance with the AGPLv3, the modifications made to the ArgyllCMS C codebase to implement the custom command-line switch for real-time data output remain fully subject to the AGPLv3.
|
||||
* **Source Code Availability:** You are entitled to the complete machine-readable source code of the modified ArgyllCMS binaries distributed with this software. The source code for the modified ArgyllCMS component can be obtained at: `https://git.i3omb.com/gronod/argyllcms`.
|
||||
|
||||
## 5. DISCLAIMER OF WARRANTY
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## 6. LIMITATION OF LIABILITY
|
||||
Notwithstanding any damages that you might incur, the entire liability of Licensor and any of its suppliers under any provision of this EULA and your exclusive remedy for all of the foregoing shall be limited to the amount actually paid by you for the Software.
|
||||
|
||||
---
|
||||
*Date of Last Revision: August 2026*
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS.
|
||||
|
||||
[](https://git.i3omb.com/gronod/ICCery)
|
||||
[](https://git.i3omb.com/gronod/ICCery)
|
||||
[](https://git.i3omb.com/gronod/ICCery)
|
||||
[](https://tauri.app)
|
||||
[](LICENCE.md)
|
||||
@@ -16,13 +16,14 @@
|
||||
- 🪄 **Linear 5-Stage Wizard Workflow**:
|
||||
1. **Stage 1 — Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, and neutral/grey axis boosting.
|
||||
2. **Stage 2 — Target Creation & Raw Printing (`printtarg`)**: Format patch targets for spectrophotometers (i1Pro, i1Pro2, ColorMunki, SpyderPrint). View high-resolution downscaled TIFF previews and print directly using native OS raw unmanaged pathways (Windows GDI uncorrected / Linux CUPS `raw`).
|
||||
3. **Stage 3 — Interactive Measurement (`chartread`) & Averaging (`average`)**: Instrument auto-detection (`instlist`), real-time calibration prompts, interactive strip reading state machine, live swatch grid with CIEDE2000 ($\Delta E_{00}$) quality indicators, and multi-pass sheet averaging for measurement noise reduction.
|
||||
4. **Stage 4 — Profile Calculation (`colprof`)**: Generate high-precision cLUT mathematical ICC/ICM profiles with configurable algorithm quality, descriptions, and copyright tagging.
|
||||
5. **Stage 5 — Verification & 3D Gamut (`profcheck` + `iccgamut`)**: Comprehensive mathematical validation report (Peak, Average, RMS $\Delta E$) paired with an interactive 3D CIELAB convex hull color volume viewer, touch controls, and bundled sRGB reference wireframe comparison.
|
||||
3. **Stage 3 — Interactive Measurement (`chartread`) & Averaging (`average`)**: Instrument auto-detection (`instlist`), real-time calibration prompts, interactive strip reading state machine, live swatch grid with CIEDE2000 ($\Delta E_{00}$) quality indicators and user-configurable traffic-light thresholds, diagonally split intended-vs-measured colour swatches, white reference patch preservation, and multi-pass sheet averaging for measurement noise reduction.
|
||||
4. **Stage 4 — Profile Calculation (`colprof`)**: Generate high-precision cLUT mathematical ICC/ICM profiles with configurable algorithm quality, OBA/FWA compensation, illuminant/observer selection, viewing-condition transforms, custom ambient spectrum support, descriptions, and copyright tagging.
|
||||
5. **Stage 5 — Verification & 3D Gamut (`profcheck` + `iccgamut`)**: Comprehensive mathematical validation report (Peak, Average, RMS $\Delta E$) paired with an interactive 3D CIELAB convex hull color volume viewer, per-vertex true-colour rendering, layer opacity controls, camera reset, keyboard shortcut, touch controls, bundled sRGB reference wireframe comparison, and robust parsing of both JSON and legacy profcheck output formats.
|
||||
- 📋 **Profiling Presets**: One-click configuration presets (Standard RGB Photo, High-Gamut CMYK Proofing, Fast RGB Draft) with custom preset export/import and security validation.
|
||||
- 🐧 **glibc Compatibility**: Pre-built Linux packages compiled with Ubuntu 22.04 LTS compatibility for Debian/Ubuntu environments.
|
||||
- 🛡️ **Disk Artefact Gating**: Stepper navigation strictly verifies generated artefacts on disk (`.ti1`, `.ti2`, `.ti3`, `.icc`/`.icm`), preventing out-of-order execution while preserving backward navigation.
|
||||
- 🌐 **Platform-Aware**: Automatic handling of platform profile conventions (`.icm` on Windows, `.icc` on Linux/macOS) and native OS printer subsystems.
|
||||
- 🎛️ **Consistent UI Controls**: Tiered button sizing (`btn-sm`/`btn-md`/`btn-lg`/`btn-icon-sq`) and standardised action row classes provide a uniform, polished interface across all wizard stages and dialogs.
|
||||
- ⚖️ **Clean AGPL Boundary**: Complete isolation of AGPLv3 binaries via asynchronous tokio IPC process pipelines.
|
||||
|
||||
---
|
||||
|
||||
+19
-2
@@ -79,8 +79,16 @@ ICCery is a native, cross-platform desktop application built with:
|
||||
- [x] **Stage 1 Layout Normalization & Stage 2 Deterministic Target Generation (#162, #163)** (`v0.5.5`): Reorganized Stage 1 Advanced Options into structured 2-column grids with normalized heights; enforced deterministic `-R 1` target generation with custom seed and raster order (`-r`) support in Stage 2.
|
||||
- [x] **macOS Universal Binary Target (#164)** (`v0.5.5`): Added macOS Universal Binary (`universal-apple-darwin`) build target combining Intel (`x86_64`) and Apple Silicon (`arm64`), ArgyllCMS universal sidecar packaging, runtime fallback resolution, and CI release asset automation.
|
||||
|
||||
### Milestone 11 — Enterprise Colour Workflow (`v0.6.0`)
|
||||
- [x] **CGATS Dataset Interoperability (#94)**: Native Rust CGATS and Argyll `.ti3` dataset parser, canonical normalizer (0-255 scaling, field aliasing, metadata synthesis), and direct-jump workflow to Stage 4 (Profile Generation) and Stage 5 (Verification) using imported external datasets.
|
||||
### Milestone 11 — Enterprise Colour Workflow (`v0.6.0` – `v0.7.2`)
|
||||
- [x] **macOS Driver Colour Management Bypass & Media Type Selection (#188)** (`v0.7.2`): Direct media type extraction via PPD/`lpoptions`, vendor uncorrected color bypass detection (Canon `CNIJIntent2`, Epson `ColorCorrection`), `AP_ColorMatchingMode=AP_ApplicationColorMatching` ColorSync suppression, CUPS printer management links, and Media Type selection UI.
|
||||
- [x] **3D Gamut Visualisation Rework (#185)** (`v0.7.1`): Purpose-built CIELAB axis scaffold with tick marks and crisp CSS2D HTML text labels, clean sRGB reference rendering using `THREE.EdgesGeometry` with faint transparent solid volume, per-vertex true-colour profile gamut shading via `labToSrgb()`, and glassmorphic legend overlay with independent layer visibility toggles.
|
||||
- [x] **Workflow & Visualizer Enhancements (#176, #177, #178, #179)** (`v0.7.0`): Stage 4 OBA/FWA compensation and viewing conditions UI (`-c`, `-d`), global button standardization, Stage 3 swatch grid diagonal split rendering for CIEDE2000 visual comparison, and robust gamut `.gam` dual-table face parsing with regex fixes for profcheck.
|
||||
- [x] **CGATS Dataset Interoperability (#94)** (`v0.6.0`): Native Rust CGATS and Argyll `.ti3` dataset parser, canonical normalizer (0-255 scaling, field aliasing, metadata synthesis), and direct-jump workflow to Stage 4 (Profile Generation) and Stage 5 (Verification) using imported external datasets.
|
||||
- [x] **Stage 1 Layout Normalization (#162)** (`v0.6.1`): Standardized control heights and structural flexbox auto-margin layout for multi-column Stage 1 advanced settings.
|
||||
- [x] **Overlay Tooltip Rendering (#171)** (`v0.6.2`): Rendered tooltips as absolute overlay popups on hover/focus to prevent layout jitter while preserving in-flow hints in global tooltip toggle mode.
|
||||
- [x] **Preconditioning Profile File Type Filter (#172)** (`v0.6.3`): Dedicated `select_profile_file` command allowing selection of `.icc`, `.icm`, and `.mpp` files for Stage 1 preconditioning profile input.
|
||||
- [x] **Windows Authenticode Code Signing in Gitea CI** (`v0.6.4` – `v0.6.6`): Integrated Tauri bundle signing hooks via `sign.cmd` batch wrapper with PATH resolution, Gitea Actions secret-based PFX materialization, and ephemeral signing pipeline.
|
||||
- [x] **Stage 3 Chartread Completion & Snapshot IPC Fix (#175)** (`v0.6.7` – `v0.6.8`): Added dedicated `Done & Save .ti3` action (`d\n`), `Undo Strip` action (`u\n`), automated completion state detection, and corrected Tauri IPC deserialization parameter (`passIndex`) in `snapshot_ti3`.
|
||||
|
||||
---
|
||||
|
||||
@@ -91,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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "iccery",
|
||||
"private": true,
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"fetch-argyll": "node scripts/fetch-argyll.mjs",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Signs Windows executables and installer packages with Authenticode and RFC 3161 timestamps.
|
||||
|
||||
.DESCRIPTION
|
||||
Used by Tauri bundle.windows.signCommand during Windows packaging.
|
||||
Resolves certificate from WINDOWS_CODESIGN_CERT_PATH or %TEMP%\codesign.pfx.
|
||||
Gracefully skips if no certificate or credentials are present.
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0)]
|
||||
[string]$TargetFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (!(Test-Path $TargetFile)) {
|
||||
Write-Warning "Target file does not exist: $TargetFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Resolve certificate path
|
||||
$certPath = $env:WINDOWS_CODESIGN_CERT_PATH
|
||||
if ([string]::IsNullOrWhiteSpace($certPath) -or !(Test-Path $certPath)) {
|
||||
$tempCert = Join-Path $env:TEMP "codesign.pfx"
|
||||
if (Test-Path $tempCert) {
|
||||
$certPath = $tempCert
|
||||
} elseif ((Test-Path "C:\Users\gordon\codesign.pfx") -and !([string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_PASSWORD))) {
|
||||
$certPath = "C:\Users\gordon\codesign.pfx"
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($certPath) -or !(Test-Path $certPath)) {
|
||||
Write-Host "Notice: No code signing certificate or credentials configured. Skipping signing for: $TargetFile"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "==> Code signing target: $TargetFile"
|
||||
Write-Host " Using certificate: $certPath"
|
||||
|
||||
# Find signtool.exe
|
||||
$signtool = $null
|
||||
$cmd = Get-Command signtool.exe -ErrorAction SilentlyContinue
|
||||
if ($cmd) {
|
||||
$signtool = $cmd.Source
|
||||
} else {
|
||||
$sdkPaths = @(
|
||||
"C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe",
|
||||
"C:\Program Files\Windows Kits\10\bin\*\x64\signtool.exe",
|
||||
"C:\Program Files (x86)\Windows Kits\10\App Certification Kit\signtool.exe"
|
||||
)
|
||||
foreach ($pattern in $sdkPaths) {
|
||||
$found = Get-Item -Path $pattern -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
if ($found) {
|
||||
$signtool = $found.FullName
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$signtool -or !(Test-Path $signtool)) {
|
||||
Write-Error "signtool.exe could not be found in PATH or Windows Kits directory."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host " Using Signtool: $signtool"
|
||||
|
||||
$timestampServers = @(
|
||||
"http://timestamp.digicert.com",
|
||||
"http://timestamp.sectigo.com",
|
||||
"http://tsa.starfieldtech.com"
|
||||
)
|
||||
|
||||
$signed = $false
|
||||
$lastError = $null
|
||||
|
||||
foreach ($tsUrl in $timestampServers) {
|
||||
Write-Host " Attempting signature with timestamp server: $tsUrl"
|
||||
|
||||
$argsList = @("sign", "/fd", "SHA256", "/d", "ICCery", "/f", $certPath)
|
||||
if (![string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_PASSWORD)) {
|
||||
$argsList += @("/p", $env:WINDOWS_CODESIGN_PASSWORD)
|
||||
}
|
||||
$argsList += @("/tr", $tsUrl, "/td", "SHA256", $TargetFile)
|
||||
|
||||
$proc = Start-Process -FilePath $signtool -ArgumentList $argsList -NoNewWindow -Wait -PassThru
|
||||
if ($proc.ExitCode -eq 0) {
|
||||
$signed = $true
|
||||
Write-Host "==> Successfully signed $TargetFile"
|
||||
break
|
||||
} else {
|
||||
$lastError = "Signtool exited with code $($proc.ExitCode) using $tsUrl"
|
||||
Write-Warning " Failed with $tsUrl (Exit code: $($proc.ExitCode)). Retrying with next server..."
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
|
||||
if (!$signed) {
|
||||
Write-Error "Code signing failed for $TargetFile. Last error: $lastError"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Verify signature
|
||||
$sigCheck = Get-AuthenticodeSignature -FilePath $TargetFile
|
||||
Write-Host "==> Signature Status: $($sigCheck.Status)"
|
||||
if ($sigCheck.Status -ne "Valid") {
|
||||
Write-Warning "Authenticode status is '$($sigCheck.Status)' (Note: Self-signed / private root CA may report UnknownError or NotTrusted on machines without root installed)."
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "TARGET=%~1"
|
||||
if "%TARGET%"=="" (
|
||||
echo [sign.cmd] Error: No target file specified.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Find sign-windows.ps1
|
||||
set "SCRIPT_PATH="
|
||||
if exist "%~dp0sign-windows.ps1" set "SCRIPT_PATH=%~dp0sign-windows.ps1"
|
||||
if not defined SCRIPT_PATH if exist "%~dp0scripts\sign-windows.ps1" set "SCRIPT_PATH=%~dp0scripts\sign-windows.ps1"
|
||||
if not defined SCRIPT_PATH if exist "%~dp0..\scripts\sign-windows.ps1" set "SCRIPT_PATH=%~dp0..\scripts\sign-windows.ps1"
|
||||
if not defined SCRIPT_PATH if exist "scripts\sign-windows.ps1" set "SCRIPT_PATH=scripts\sign-windows.ps1"
|
||||
if not defined SCRIPT_PATH if exist "..\scripts\sign-windows.ps1" set "SCRIPT_PATH=..\scripts\sign-windows.ps1"
|
||||
|
||||
if not defined SCRIPT_PATH (
|
||||
echo [sign.cmd] Error: sign-windows.ps1 could not be found.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_PATH%" "%TARGET%"
|
||||
exit /b %ERRORLEVEL%
|
||||
@@ -0,0 +1,23 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "TARGET=%~1"
|
||||
if "%TARGET%"=="" (
|
||||
echo [sign.cmd] Error: No target file specified.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Find sign-windows.ps1
|
||||
set "SCRIPT_PATH="
|
||||
if exist "%~dp0sign-windows.ps1" set "SCRIPT_PATH=%~dp0sign-windows.ps1"
|
||||
if not defined SCRIPT_PATH if exist "%~dp0scripts\sign-windows.ps1" set "SCRIPT_PATH=%~dp0scripts\sign-windows.ps1"
|
||||
if not defined SCRIPT_PATH if exist "%~dp0..\scripts\sign-windows.ps1" set "SCRIPT_PATH=%~dp0..\scripts\sign-windows.ps1"
|
||||
if not defined SCRIPT_PATH if exist "scripts\sign-windows.ps1" set "SCRIPT_PATH=scripts\sign-windows.ps1"
|
||||
if not defined SCRIPT_PATH if exist "..\scripts\sign-windows.ps1" set "SCRIPT_PATH=..\scripts\sign-windows.ps1"
|
||||
|
||||
if not defined SCRIPT_PATH (
|
||||
echo [sign.cmd] Error: sign-windows.ps1 could not be found.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_PATH%" "%TARGET%"
|
||||
exit /b %ERRORLEVEL%
|
||||
Generated
+68
-1
@@ -1423,11 +1423,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iccery"
|
||||
version = "0.5.5"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-application-services",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -2037,8 +2042,31 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-text",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-application-services"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69282c2b5bc58fba07cb9de2113619532eb551e98efe3d8d695509ef45fbd53b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-services",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
@@ -2059,6 +2087,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@@ -2070,7 +2099,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@@ -2107,6 +2138,18 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-services"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
@@ -2119,6 +2162,19 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-video"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -2170,6 +2226,17 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-security"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.3.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "iccery"
|
||||
version = "0.6.0"
|
||||
version = "0.8.1"
|
||||
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",
|
||||
@@ -38,6 +45,6 @@ windows = { version = "0.61", features = [
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
codegen-units = 16
|
||||
lto = "thin"
|
||||
strip = true
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "TARGET=%~1"
|
||||
if "%TARGET%"=="" (
|
||||
echo [sign.cmd] Error: No target file specified.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Find sign-windows.ps1
|
||||
set "SCRIPT_PATH="
|
||||
if exist "%~dp0sign-windows.ps1" set "SCRIPT_PATH=%~dp0sign-windows.ps1"
|
||||
if not defined SCRIPT_PATH if exist "%~dp0scripts\sign-windows.ps1" set "SCRIPT_PATH=%~dp0scripts\sign-windows.ps1"
|
||||
if not defined SCRIPT_PATH if exist "%~dp0..\scripts\sign-windows.ps1" set "SCRIPT_PATH=%~dp0..\scripts\sign-windows.ps1"
|
||||
if not defined SCRIPT_PATH if exist "scripts\sign-windows.ps1" set "SCRIPT_PATH=scripts\sign-windows.ps1"
|
||||
if not defined SCRIPT_PATH if exist "..\scripts\sign-windows.ps1" set "SCRIPT_PATH=..\scripts\sign-windows.ps1"
|
||||
|
||||
if not defined SCRIPT_PATH (
|
||||
echo [sign.cmd] Error: sign-windows.ps1 could not be found.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_PATH%" "%TARGET%"
|
||||
exit /b %ERRORLEVEL%
|
||||
+298
-29
@@ -365,6 +365,32 @@ pub async fn select_existing_target(
|
||||
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn select_profile_file(
|
||||
app: AppHandle,
|
||||
default_dir: Option<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let mut builder = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("ICC/ICM & MPP Profiles (*.icc, *.icm, *.mpp)", &["icc", "icm", "mpp"]);
|
||||
if let Some(ref dir) = default_dir {
|
||||
if !dir.trim().is_empty() {
|
||||
builder = builder.set_directory(std::path::PathBuf::from(dir));
|
||||
}
|
||||
}
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
builder.pick_file(move |file_path| {
|
||||
let res = file_path.map(|p| p.to_string());
|
||||
let _ = tx.send(res);
|
||||
});
|
||||
|
||||
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn select_target_file(
|
||||
app: AppHandle,
|
||||
@@ -803,11 +829,13 @@ pub async fn read_tiff_preview_png(path: String) -> Result<String, String> {
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(png_bytes.into_inner()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct ChartreadConfig {
|
||||
pub basename: String,
|
||||
pub cwd: String,
|
||||
pub port: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enable_i1pro2_leds: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
|
||||
@@ -823,6 +851,11 @@ pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
if config.enable_i1pro2_leds.unwrap_or(false) {
|
||||
args.push("-Y".to_string());
|
||||
args.push("l".to_string());
|
||||
}
|
||||
|
||||
args.push(config.basename.clone());
|
||||
args
|
||||
}
|
||||
@@ -831,8 +864,12 @@ pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
|
||||
pub async fn run_chartread(
|
||||
app: AppHandle,
|
||||
state: State<'_, ProcessManager>,
|
||||
config: ChartreadConfig,
|
||||
mut config: ChartreadConfig,
|
||||
) -> Result<(), String> {
|
||||
if config.enable_i1pro2_leds.is_none() {
|
||||
let settings = crate::settings::load_settings(app.clone()).unwrap_or_default();
|
||||
config.enable_i1pro2_leds = Some(settings.enable_i1pro2_leds);
|
||||
}
|
||||
let binary = resolve_binary(app.clone(), "chartread".to_string()).await?;
|
||||
let args = build_chartread_args(&config);
|
||||
let id = format!("chartread_{}", config.basename);
|
||||
@@ -942,38 +979,81 @@ pub struct ColprofConfig {
|
||||
pub description: Option<String>,
|
||||
pub basename: String,
|
||||
pub cwd: String,
|
||||
#[serde(default)]
|
||||
pub fwa: Option<String>,
|
||||
#[serde(default)]
|
||||
pub illuminant: Option<String>,
|
||||
#[serde(default)]
|
||||
pub observer: Option<String>,
|
||||
#[serde(default)]
|
||||
pub input_viewing_cond: Option<String>,
|
||||
#[serde(default)]
|
||||
pub output_viewing_cond: Option<String>,
|
||||
}
|
||||
|
||||
pub fn build_colprof_args(config: &ColprofConfig) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"-v".to_string(),
|
||||
"-a".to_string(),
|
||||
config.algorithm.clone(),
|
||||
"-q".to_string(),
|
||||
config.quality.clone(),
|
||||
];
|
||||
let mut args = vec!["-v".to_string(), "-a".to_string(), config.algorithm.clone(), "-q".to_string(), config.quality.clone()];
|
||||
|
||||
if let Some(ref intent) = config.intent {
|
||||
if !intent.trim().is_empty() {
|
||||
args.push("-p".to_string());
|
||||
if !intent.is_empty() {
|
||||
args.push("-t".to_string());
|
||||
args.push(intent.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref cr) = config.copyright {
|
||||
if !cr.trim().is_empty() {
|
||||
args.push("-C".to_string());
|
||||
args.push(cr.clone());
|
||||
if let Some(ref fwa) = config.fwa {
|
||||
if fwa.to_lowercase() != "none" {
|
||||
if fwa.is_empty() {
|
||||
args.push("-f".to_string());
|
||||
} else {
|
||||
args.push("-f".to_string());
|
||||
args.push(fwa.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref illuminant) = config.illuminant {
|
||||
if !illuminant.is_empty() {
|
||||
args.push("-i".to_string());
|
||||
args.push(illuminant.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref observer) = config.observer {
|
||||
if !observer.is_empty() {
|
||||
args.push("-o".to_string());
|
||||
args.push(observer.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref in_cond) = config.input_viewing_cond {
|
||||
if !in_cond.is_empty() && in_cond.to_lowercase() != "none" {
|
||||
args.push("-c".to_string());
|
||||
args.push(in_cond.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref out_cond) = config.output_viewing_cond {
|
||||
if !out_cond.is_empty() && out_cond.to_lowercase() != "none" {
|
||||
args.push("-d".to_string());
|
||||
args.push(out_cond.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref desc) = config.description {
|
||||
if !desc.trim().is_empty() {
|
||||
if !desc.is_empty() {
|
||||
args.push("-D".to_string());
|
||||
args.push(desc.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref copyright) = config.copyright {
|
||||
if !copyright.is_empty() {
|
||||
args.push("-C".to_string());
|
||||
args.push(copyright.clone());
|
||||
}
|
||||
}
|
||||
|
||||
args.push(config.basename.clone());
|
||||
args
|
||||
}
|
||||
@@ -1083,26 +1163,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())
|
||||
}
|
||||
}
|
||||
@@ -1486,6 +1569,7 @@ mod tests {
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/home/user".to_string(),
|
||||
port: None,
|
||||
enable_i1pro2_leds: None,
|
||||
};
|
||||
let args = build_chartread_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
|
||||
@@ -1497,6 +1581,7 @@ mod tests {
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/home/user".to_string(),
|
||||
port: Some("".to_string()),
|
||||
enable_i1pro2_leds: None,
|
||||
};
|
||||
let args = build_chartread_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
|
||||
@@ -1508,11 +1593,48 @@ mod tests {
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/home/user".to_string(),
|
||||
port: Some("1".to_string()),
|
||||
enable_i1pro2_leds: None,
|
||||
};
|
||||
let args = build_chartread_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-c", "1", "my_profile"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_chartread_args_leds_enabled() {
|
||||
let config = ChartreadConfig {
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/home/user".to_string(),
|
||||
port: None,
|
||||
enable_i1pro2_leds: Some(true),
|
||||
};
|
||||
let args = build_chartread_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-Y", "l", "my_profile"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_chartread_args_with_port_and_leds() {
|
||||
let config = ChartreadConfig {
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/home/user".to_string(),
|
||||
port: Some("1".to_string()),
|
||||
enable_i1pro2_leds: Some(true),
|
||||
};
|
||||
let args = build_chartread_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-c", "1", "-Y", "l", "my_profile"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_chartread_args_leds_disabled() {
|
||||
let config = ChartreadConfig {
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/home/user".to_string(),
|
||||
port: None,
|
||||
enable_i1pro2_leds: Some(false),
|
||||
};
|
||||
let args = build_chartread_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_average_args() {
|
||||
let config = AverageConfig {
|
||||
@@ -1576,18 +1698,165 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_colprof_args() {
|
||||
let config = ColprofConfig {
|
||||
quality: "h".to_string(),
|
||||
algorithm: "l".to_string(),
|
||||
intent: None,
|
||||
description: Some("My Profile".to_string()),
|
||||
copyright: Some("2026 ACME".to_string()),
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/home/user".to_string(),
|
||||
quality: "h".to_string(),
|
||||
intent: Some("p".to_string()),
|
||||
copyright: Some("Test".to_string()),
|
||||
description: Some("Test Desc".to_string()),
|
||||
basename: "basename".to_string(),
|
||||
cwd: "".to_string(),
|
||||
fwa: None,
|
||||
illuminant: None,
|
||||
observer: None,
|
||||
input_viewing_cond: None,
|
||||
output_viewing_cond: None,
|
||||
};
|
||||
let args = build_colprof_args(&config);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-v", "-a", "l", "-q", "h", "-C", "2026 ACME", "-D", "My Profile", "my_profile"]
|
||||
vec!["-v", "-a", "l", "-q", "h", "-t", "p", "-D", "Test Desc", "-C", "Test", "basename"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_colprof_args_with_fwa_d50() {
|
||||
let config = ColprofConfig {
|
||||
algorithm: "l".to_string(),
|
||||
quality: "h".to_string(),
|
||||
intent: None,
|
||||
copyright: None,
|
||||
description: None,
|
||||
basename: "basename".to_string(),
|
||||
cwd: "".to_string(),
|
||||
fwa: Some("D50".to_string()),
|
||||
illuminant: None,
|
||||
observer: None,
|
||||
input_viewing_cond: None,
|
||||
output_viewing_cond: None,
|
||||
};
|
||||
let args = build_colprof_args(&config);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-v", "-a", "l", "-q", "h", "-f", "D50", "basename"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_colprof_args_with_fwa_none() {
|
||||
let config = ColprofConfig {
|
||||
algorithm: "l".to_string(),
|
||||
quality: "h".to_string(),
|
||||
intent: None,
|
||||
copyright: None,
|
||||
description: None,
|
||||
basename: "basename".to_string(),
|
||||
cwd: "".to_string(),
|
||||
fwa: Some("none".to_string()),
|
||||
illuminant: None,
|
||||
observer: None,
|
||||
input_viewing_cond: None,
|
||||
output_viewing_cond: None,
|
||||
};
|
||||
let args = build_colprof_args(&config);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-v", "-a", "l", "-q", "h", "basename"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_colprof_args_with_custom_sp() {
|
||||
let config = ColprofConfig {
|
||||
algorithm: "l".to_string(),
|
||||
quality: "h".to_string(),
|
||||
intent: None,
|
||||
copyright: None,
|
||||
description: None,
|
||||
basename: "basename".to_string(),
|
||||
cwd: "".to_string(),
|
||||
fwa: Some("/path/to/viewing_booth.sp".to_string()),
|
||||
illuminant: None,
|
||||
observer: None,
|
||||
input_viewing_cond: None,
|
||||
output_viewing_cond: None,
|
||||
};
|
||||
let args = build_colprof_args(&config);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-v", "-a", "l", "-q", "h", "-f", "/path/to/viewing_booth.sp", "basename"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_colprof_args_with_illuminant_observer() {
|
||||
let config = ColprofConfig {
|
||||
algorithm: "l".to_string(),
|
||||
quality: "h".to_string(),
|
||||
intent: None,
|
||||
copyright: None,
|
||||
description: None,
|
||||
basename: "basename".to_string(),
|
||||
cwd: "".to_string(),
|
||||
fwa: None,
|
||||
illuminant: Some("D65".to_string()),
|
||||
observer: Some("1964_10".to_string()),
|
||||
input_viewing_cond: None,
|
||||
output_viewing_cond: None,
|
||||
};
|
||||
let args = build_colprof_args(&config);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-v", "-a", "l", "-q", "h", "-i", "D65", "-o", "1964_10", "basename"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_colprof_args_with_viewing_conditions() {
|
||||
let config = ColprofConfig {
|
||||
algorithm: "l".to_string(),
|
||||
quality: "h".to_string(),
|
||||
intent: None,
|
||||
copyright: None,
|
||||
description: None,
|
||||
basename: "basename".to_string(),
|
||||
cwd: "".to_string(),
|
||||
fwa: None,
|
||||
illuminant: None,
|
||||
observer: None,
|
||||
input_viewing_cond: Some("pp".to_string()),
|
||||
output_viewing_cond: Some("mt".to_string()),
|
||||
};
|
||||
let args = build_colprof_args(&config);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-v", "-a", "l", "-q", "h", "-c", "pp", "-d", "mt", "basename"]
|
||||
);
|
||||
}
|
||||
|
||||
#[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"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ pub fn run() {
|
||||
commands::export_measurement_dataset,
|
||||
commands::inspect_dataset_preview,
|
||||
commands::select_existing_target,
|
||||
commands::select_profile_file,
|
||||
commands::select_target_file,
|
||||
commands::select_directory,
|
||||
commands::send_stdin,
|
||||
|
||||
+788
-38
@@ -1,15 +1,522 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::ffi::CStr;
|
||||
use std::path::Path;
|
||||
use crate::print::{
|
||||
PrintOptions, Printer, PrinterCapabilities,
|
||||
};
|
||||
pub use crate::print::unix::{
|
||||
get_printer_capabilities, get_printers, merge_printer_info, parse_lpoptions_l,
|
||||
parse_lpstat_d, parse_lpstat_e, parse_lpstat_p,
|
||||
};
|
||||
use std::ptr::NonNull;
|
||||
|
||||
/// Construct `lp` command line arguments for target printing on macOS with ColorSync bypass flags.
|
||||
use objc2_application_services::PMPrintSession;
|
||||
|
||||
use crate::print::{PrintOptions, PrintPropertiesResult};
|
||||
pub use crate::print::unix::{get_printer_capabilities, get_printers};
|
||||
|
||||
extern "C" {
|
||||
fn free(ptr: *mut std::ffi::c_void);
|
||||
}
|
||||
|
||||
/// Run `lpoptions -p <printer> -l` and return the raw PPD option listing,
|
||||
/// or `None` if the command is not available or the printer is not found.
|
||||
fn get_lpoptions_output(printer_name: &str) -> Option<String> {
|
||||
std::process::Command::new("lpoptions")
|
||||
.args(["-p", printer_name, "-l"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
extern "C" {
|
||||
fn dlsym(handle: *mut std::ffi::c_void, symbol: *const std::ffi::c_char) -> *mut std::ffi::c_void;
|
||||
}
|
||||
|
||||
/// Set the Core Printing session's color matching mode to "application" and
|
||||
/// lock it. This is the private SPI used by Photoshop, Lightroom and X-Rite
|
||||
/// i1Profiler to gray-out the Color Matching controls in driver PDEs.
|
||||
///
|
||||
/// The symbols are resolved at runtime with `dlsym` so the binary does not
|
||||
/// hard-depend on an undocumented symbol. All three exported variants
|
||||
/// (PMSessionSetColorMatchingMode, PMSessionSetColorMatchingModeLock,
|
||||
/// PMSessionSetColorMatchingModeNoLock) use the same 2-argument C signature:
|
||||
/// `(PMPrintSession, *const CFString) -> i32`. The "Lock" variant sets the
|
||||
/// mode and locks the UI in a single call; "NoLock" sets it without locking.
|
||||
/// We only attempt application-managed color-matching modes.
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe fn set_session_color_matching_mode(pm_session: PMPrintSession) -> bool {
|
||||
use std::ffi::{c_char, c_void};
|
||||
use objc2_core_foundation::CFString;
|
||||
use objc2_application_services::{PMSessionGetCurrentPrinter, PMPrinter};
|
||||
|
||||
if pm_session.is_null() {
|
||||
log::warn!("pm_session is null; skipping private color-matching SPI");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify session has a printer attached
|
||||
let mut current_printer: PMPrinter = std::ptr::null_mut();
|
||||
let printer_status = PMSessionGetCurrentPrinter(pm_session, (&mut current_printer).into());
|
||||
if printer_status != 0 || current_printer.is_null() {
|
||||
log::warn!(
|
||||
"PMSessionGetCurrentPrinter failed ({}) or returned null printer; skipping SPI",
|
||||
printer_status
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const RTLD_DEFAULT: *mut c_void = (-2isize) as *mut c_void;
|
||||
|
||||
// Load all relevant symbols once. Order matches the priority below:
|
||||
// Lock first (sets and locks in one call), then main, then NoLock.
|
||||
let sym_lock = c"PMSessionSetColorMatchingModeLock";
|
||||
let sym_main = c"PMSessionSetColorMatchingMode";
|
||||
let sym_nolock = c"PMSessionSetColorMatchingModeNoLock";
|
||||
|
||||
let lock_ptr = dlsym(RTLD_DEFAULT, sym_lock.as_ptr() as *const c_char);
|
||||
let main_ptr = dlsym(RTLD_DEFAULT, sym_main.as_ptr() as *const c_char);
|
||||
let nolock_ptr = dlsym(RTLD_DEFAULT, sym_nolock.as_ptr() as *const c_char);
|
||||
|
||||
log::info!(
|
||||
"PMSessionSetColorMatchingMode SPI symbols: lock={:p} main={:p} nolock={:p}",
|
||||
lock_ptr, main_ptr, nolock_ptr
|
||||
);
|
||||
|
||||
// Only application-managed color-matching modes are appropriate for
|
||||
// profiling targets. ColorSync or vendor modes would apply color
|
||||
// management and corrupt the target patches.
|
||||
const MODES: &[&str] = &[
|
||||
"AP_ApplicationColorMatching", // kPMApplicationColorMatching
|
||||
"ApplicationColorMatching", // without AP_ prefix
|
||||
];
|
||||
|
||||
// All three symbols have the same (PMPrintSession, *const CFString) signature.
|
||||
// PMSessionSetColorMatchingModeLock sets and locks in one call, so try it first.
|
||||
type ModeFn = unsafe extern "C" fn(PMPrintSession, *const CFString) -> i32;
|
||||
|
||||
let candidates = [
|
||||
("PMSessionSetColorMatchingModeLock", lock_ptr),
|
||||
("PMSessionSetColorMatchingMode", main_ptr),
|
||||
("PMSessionSetColorMatchingModeNoLock", nolock_ptr),
|
||||
];
|
||||
|
||||
let mut set_success = false;
|
||||
|
||||
for &mode_str in MODES {
|
||||
let mode = CFString::from_str(mode_str);
|
||||
let mode_ptr = &*mode as *const CFString;
|
||||
|
||||
for (name, fn_ptr) in candidates {
|
||||
if !fn_ptr.is_null() {
|
||||
let set_fn: ModeFn = std::mem::transmute(fn_ptr);
|
||||
let status = set_fn(pm_session, mode_ptr);
|
||||
log::info!("{}({}) returned {}", name, mode_str, status);
|
||||
if status == 0 {
|
||||
set_success = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if set_success {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !set_success {
|
||||
log::warn!(
|
||||
"All PMSessionSetColorMatchingMode calls failed; color controls may not be grayed"
|
||||
);
|
||||
}
|
||||
|
||||
set_success
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
unsafe fn set_session_color_matching_mode(_pm_session: PMPrintSession) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// PPD/driver-relevant CUPS option key names that we forward to `lp` when
|
||||
/// captured from the native print panel. Keys not in this set (internal Apple
|
||||
/// ticket keys, generic CUPS bookkeeping, etc.) are discarded.
|
||||
const RELEVANT_CUPS_OPTION_KEYS: &[&str] = &[
|
||||
// Media type
|
||||
"MediaType",
|
||||
"CNIJMediaType",
|
||||
"EPIJ_Medi",
|
||||
"StpMediaType",
|
||||
// Input slot / tray
|
||||
"InputSlot",
|
||||
"AP_D_InputSlot",
|
||||
// Paper size
|
||||
"PageSize",
|
||||
// Driver color management bypass
|
||||
"CNIJIntent2",
|
||||
"CNIJIntent",
|
||||
"EPIJ_CMat",
|
||||
"EPIJ_CCor",
|
||||
"EPIJ_OSColMat",
|
||||
"ColorCorrection",
|
||||
"StpColorCorrection",
|
||||
"EpsonColorMode",
|
||||
"ColorModel",
|
||||
// Quality / resolution
|
||||
"Resolution",
|
||||
"cupsPrintQuality",
|
||||
"Quality",
|
||||
"EPIJ_Quality",
|
||||
"CNIJQuality",
|
||||
"StpQuality",
|
||||
"OutputMode",
|
||||
// Duplex
|
||||
"Duplex",
|
||||
"sides",
|
||||
];
|
||||
|
||||
/// Return true if a CUPS option key/value pair captured from the native print
|
||||
/// panel should be forwarded to the `lp` command.
|
||||
fn is_relevant_cups_option(key: &str, value: &str) -> bool {
|
||||
if key.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Never forward internal Apple ticket keys.
|
||||
if key.starts_with("com.apple.") {
|
||||
return false;
|
||||
}
|
||||
// We always set AP_ColorMatchingMode (and dot-notation) ourselves in build_lp_args.
|
||||
if key == "AP_ColorMatchingMode" || key == "AP.ColorMatchingMode" {
|
||||
return false;
|
||||
}
|
||||
// Skip empty values (e.g. "AP_D_InputSlot=").
|
||||
if value.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Skip generic CUPS bookkeeping keys that don't affect color or quality.
|
||||
if matches!(
|
||||
key,
|
||||
"collate" | "copies" | "pserrorhandler-requested" | "job-sheets"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Keep known PPD option keys, or any other non-Apple key that looks
|
||||
// driver-specific (permissive: unknown driver keys are kept).
|
||||
RELEVANT_CUPS_OPTION_KEYS.contains(&key) || !key.starts_with("com.")
|
||||
}
|
||||
|
||||
/// Filter a raw CUPS options string (as produced by PMPrintSettingsToOptions)
|
||||
/// down to only the PPD-relevant pairs, returning them as a space-separated
|
||||
/// `"key=value key=value"` string.
|
||||
fn filter_cups_options_string(raw: &str) -> Option<String> {
|
||||
let pairs = crate::print::unix::parse_cups_options_string(raw);
|
||||
let filtered: Vec<(String, String)> = pairs
|
||||
.into_iter()
|
||||
.filter(|(k, v)| is_relevant_cups_option(k, v))
|
||||
.collect();
|
||||
if filtered.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
filtered
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the media type value from a filtered CUPS options string, looking
|
||||
/// for the first known media-type key.
|
||||
fn extract_media_type_from_options(options: &str) -> Option<String> {
|
||||
let pairs = crate::print::unix::parse_cups_options_string(options);
|
||||
for (key, value) in &pairs {
|
||||
if matches!(
|
||||
key.as_str(),
|
||||
"MediaType" | "CNIJMediaType" | "EPIJ_Medi" | "StpMediaType"
|
||||
) {
|
||||
if !value.is_empty() {
|
||||
return Some(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Show the native macOS `NSPrintPanel` for the selected printer, pre-configured
|
||||
/// with application-managed color so the driver's color-management controls are
|
||||
/// greyed out. Returns the user-selected printer and captured print settings as
|
||||
/// a `PrintPropertiesResult` snapshot, or `None` if the user cancelled the
|
||||
/// dialog.
|
||||
///
|
||||
/// This function must be called on the Cocoa main thread. The caller is
|
||||
/// responsible for dispatching via `AppHandle::run_on_main_thread`.
|
||||
fn run_native_print_panel(
|
||||
printer_name: &str,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<Option<PrintPropertiesResult>, String> {
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSPrintInfo, NSPrintPanel, NSPrintPanelOptions, NSPrinter};
|
||||
use objc2_application_services::{
|
||||
PMPageFormat, PMPrinter, PMPrinterCreateFromPrinterID,
|
||||
PMPrinterGetID, PMPrintSettings, PMPrintSettingsSetValue,
|
||||
PMPrintSettingsToOptions, PMRelease, PMSessionDefaultPageFormat,
|
||||
PMSessionDefaultPrintSettings, PMSessionGetCurrentPrinter,
|
||||
PMSessionSetCurrentPMPrinter,
|
||||
};
|
||||
use objc2_core_foundation::CFString;
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
let mtm = MainThreadMarker::new()
|
||||
.ok_or("Print panel must be invoked on the main thread")?;
|
||||
|
||||
// Create a fresh NSPrintInfo and initialize it.
|
||||
let print_info = NSPrintInfo::new();
|
||||
print_info.setUpPrintOperationDefaultValues();
|
||||
|
||||
// Bind the CUPS destination (Printer ID) to the session. CUPS destination
|
||||
// IDs are what macOS Core Printing calls "Printer IDs"; they are not the
|
||||
// human-readable display names used by NSPrinter::printerWithName.
|
||||
let printer_id_cf = CFString::from_str(printer_name);
|
||||
let pm_printer: PMPrinter = unsafe { PMPrinterCreateFromPrinterID(&*printer_id_cf) };
|
||||
let printer_from_id = !pm_printer.is_null();
|
||||
|
||||
if pm_printer.is_null() {
|
||||
if let Some(dn) = display_name {
|
||||
// Fallback: try the human-readable display name via NSPrinter. This is
|
||||
// less robust than a direct PMPrinter lookup but allows the panel to
|
||||
// open when the queue is not currently registered in the Core Printing
|
||||
// session under its CUPS ID.
|
||||
let name_ns = NSString::from_str(dn);
|
||||
if let Some(ns_printer) = NSPrinter::printerWithName(&name_ns) {
|
||||
print_info.setPrinter(&ns_printer);
|
||||
print_info.setUpPrintOperationDefaultValues();
|
||||
} else {
|
||||
log::warn!(
|
||||
"PMPrinterCreateFromPrinterID('{}') and NSPrinter::printerWithName('{}') both failed",
|
||||
printer_name, dn
|
||||
);
|
||||
return Err(format!(
|
||||
"No printer found for '{}' (display name: {:?}). The native print panel cannot be opened.",
|
||||
printer_name, display_name
|
||||
));
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
"PMPrinterCreateFromPrinterID('{}') failed and no display name was available",
|
||||
printer_name
|
||||
);
|
||||
return Err(format!(
|
||||
"No printer found for '{}' (display name: {:?}). The native print panel cannot be opened.",
|
||||
printer_name, display_name
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Obtain the underlying Core Printing session and settings now that
|
||||
// NSPrintInfo has been configured. These are valid as long as `print_info`
|
||||
// is retained.
|
||||
let pm_session: PMPrintSession = print_info.PMPrintSession().as_ptr() as PMPrintSession;
|
||||
let pm_settings: PMPrintSettings = print_info.PMPrintSettings().as_ptr() as PMPrintSettings;
|
||||
let pm_page_format = print_info.PMPageFormat().as_ptr() as PMPageFormat;
|
||||
|
||||
if !pm_printer.is_null() {
|
||||
let set_status = unsafe { PMSessionSetCurrentPMPrinter(pm_session, pm_printer) };
|
||||
if set_status != 0 {
|
||||
unsafe { PMRelease(pm_printer as _) };
|
||||
return Err(format!(
|
||||
"PMSessionSetCurrentPMPrinter failed with status {}",
|
||||
set_status
|
||||
));
|
||||
}
|
||||
|
||||
let default_status = unsafe { PMSessionDefaultPrintSettings(pm_session, pm_settings) };
|
||||
if default_status != 0 {
|
||||
log::warn!(
|
||||
"PMSessionDefaultPrintSettings returned status {}",
|
||||
default_status
|
||||
);
|
||||
}
|
||||
|
||||
let default_page_status = unsafe { PMSessionDefaultPageFormat(pm_session, pm_page_format) };
|
||||
if default_page_status != 0 {
|
||||
log::warn!(
|
||||
"PMSessionDefaultPageFormat returned status {}",
|
||||
default_page_status
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the private PMSessionSetColorMatchingMode SPI to gray out and lock
|
||||
// the Color Matching controls in the driver PDE (ColorSync vs. vendor
|
||||
// color). This is separate from the AP_ColorMatchingMode spool setting.
|
||||
let spi_ok = unsafe { set_session_color_matching_mode(pm_session) };
|
||||
if !spi_ok {
|
||||
log::warn!("Native color-matching controls may not be locked; PMPrintSettingsSetValue fallback will be used");
|
||||
}
|
||||
|
||||
// Set AP_ColorMatchingMode = AP_ApplicationColorMatching (and the
|
||||
// dot-notation variant) so the CUPS backend/cgpdftoraster knows the
|
||||
// application already handled color matching.
|
||||
let cm_key = CFString::from_str("AP_ColorMatchingMode");
|
||||
let cm_dot_key = CFString::from_str("AP.ColorMatchingMode");
|
||||
let cm_val = CFString::from_str("AP_ApplicationColorMatching");
|
||||
let cm_val_ref: &objc2_core_foundation::CFType = &*cm_val;
|
||||
for cm_key_ref in [&cm_key, &cm_dot_key] {
|
||||
let set_status = unsafe {
|
||||
PMPrintSettingsSetValue(pm_settings, cm_key_ref, Some(cm_val_ref), true)
|
||||
};
|
||||
if set_status != 0 {
|
||||
log::warn!(
|
||||
"PMPrintSettingsSetValue({}) returned status {}",
|
||||
cm_key_ref,
|
||||
set_status
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-select the driver-specific "no color adjustment" PPD option in the
|
||||
// print settings so the panel shows it as the current choice.
|
||||
let lpoptions = get_lpoptions_output(printer_name);
|
||||
if let Some(ref output) = lpoptions {
|
||||
if let Some((bypass_key, bypass_val)) =
|
||||
crate::print::unix::detect_driver_color_bypass(output)
|
||||
{
|
||||
let bypass_key_cf = CFString::from_str(bypass_key);
|
||||
let bypass_val_cf = CFString::from_str(bypass_val);
|
||||
let bypass_val_ref: &objc2_core_foundation::CFType = &*bypass_val_cf;
|
||||
let set_status = unsafe {
|
||||
PMPrintSettingsSetValue(pm_settings, &bypass_key_cf, Some(bypass_val_ref), false)
|
||||
};
|
||||
if set_status != 0 {
|
||||
log::warn!(
|
||||
"PMPrintSettingsSetValue({}={}) returned status {}",
|
||||
bypass_key, bypass_val, set_status
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print_info.updateFromPMPageFormat();
|
||||
|
||||
// Sync the PMPrintSettings changes back into the NSPrintInfo object.
|
||||
print_info.updateFromPMPrintSettings();
|
||||
|
||||
// Also write the color-matching keys directly into the NSPrintInfo
|
||||
// printSettings dictionary. This is the dictionary that AppKit print
|
||||
// dialog extensions (PDEs) and some raster drivers inspect, so the
|
||||
// value must appear here as well as in the PMPrintSettings object.
|
||||
let cm_key_ns = NSString::from_str("AP_ColorMatchingMode");
|
||||
let cm_dot_key_ns = NSString::from_str("AP.ColorMatchingMode");
|
||||
let cm_val_ns = NSString::from_str("AP_ApplicationColorMatching");
|
||||
|
||||
// Safety: `NSString` has the same memory layout as its root `AnyObject`
|
||||
// (`isa`), so we can borrow it as `&AnyObject` for the dictionary.
|
||||
let as_any = |s: &NSString| -> &objc2::runtime::AnyObject {
|
||||
unsafe { &*(s as *const _ as *const objc2::runtime::AnyObject) }
|
||||
};
|
||||
|
||||
let print_settings = unsafe { print_info.printSettings() };
|
||||
print_settings.insert(&*cm_key_ns, as_any(&*cm_val_ns));
|
||||
print_settings.insert(&*cm_dot_key_ns, as_any(&*cm_val_ns));
|
||||
|
||||
// Also surface the driver-specific color bypass in printSettings so the
|
||||
// panel PDE sees it even if PMPrintSettings didn't sync it.
|
||||
if let Some(ref output) = lpoptions {
|
||||
if let Some((bypass_key, bypass_val)) =
|
||||
crate::print::unix::detect_driver_color_bypass(output)
|
||||
{
|
||||
let bypass_key_ns = NSString::from_str(bypass_key);
|
||||
let bypass_val_ns = NSString::from_str(bypass_val);
|
||||
print_settings.insert(&*bypass_key_ns, as_any(&*bypass_val_ns));
|
||||
}
|
||||
}
|
||||
|
||||
// Create and configure the print panel.
|
||||
let panel = NSPrintPanel::printPanel(mtm);
|
||||
let mut opts = NSPrintPanelOptions::all();
|
||||
opts.insert(NSPrintPanelOptions::ShowsPageSetupAccessory);
|
||||
panel.setOptions(opts);
|
||||
panel.setDefaultButtonTitle(Some(&NSString::from_str("Use Settings")));
|
||||
|
||||
// Run the modal dialog.
|
||||
let response = panel.runModalWithPrintInfo(&print_info);
|
||||
|
||||
// NSModalResponseOK == NSOKButton == 1.
|
||||
if response != 1 {
|
||||
if !pm_printer.is_null() {
|
||||
unsafe { PMRelease(pm_printer as _) };
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Capture the effective printer from the session.
|
||||
let mut current_printer: PMPrinter = std::ptr::null_mut();
|
||||
let get_status = unsafe {
|
||||
PMSessionGetCurrentPrinter(
|
||||
pm_session,
|
||||
NonNull::new(&mut current_printer).unwrap(),
|
||||
)
|
||||
};
|
||||
let selected_printer = if get_status == 0 && !current_printer.is_null() {
|
||||
unsafe {
|
||||
PMPrinterGetID(current_printer).map(|id| format!("{}", id))
|
||||
}
|
||||
} else {
|
||||
if printer_from_id {
|
||||
Some(printer_name.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Extract the updated print settings from the panel.
|
||||
let updated_info = panel.printInfo();
|
||||
let updated_settings: PMPrintSettings =
|
||||
updated_info.PMPrintSettings().as_ptr() as PMPrintSettings;
|
||||
|
||||
let mut opts_ptr: *mut std::ffi::c_char = std::ptr::null_mut();
|
||||
let to_opts_status = unsafe {
|
||||
PMPrintSettingsToOptions(updated_settings, NonNull::new(&mut opts_ptr).unwrap())
|
||||
};
|
||||
|
||||
if to_opts_status != 0 || opts_ptr.is_null() {
|
||||
if !pm_printer.is_null() {
|
||||
unsafe { PMRelease(pm_printer as _) };
|
||||
}
|
||||
return Err(format!(
|
||||
"PMPrintSettingsToOptions failed with status {}",
|
||||
to_opts_status
|
||||
));
|
||||
}
|
||||
|
||||
let opts_cstr = unsafe { CStr::from_ptr(opts_ptr) };
|
||||
let opts_str = opts_cstr.to_string_lossy().into_owned();
|
||||
unsafe { free(opts_ptr as *mut _) };
|
||||
if !pm_printer.is_null() {
|
||||
unsafe { PMRelease(pm_printer as _) };
|
||||
}
|
||||
|
||||
// Filter to PPD-relevant options.
|
||||
let cups_options = filter_cups_options_string(&opts_str);
|
||||
|
||||
// Try to extract a media type from the captured options.
|
||||
let media_type = cups_options
|
||||
.as_ref()
|
||||
.and_then(|s| extract_media_type_from_options(s));
|
||||
|
||||
Ok(Some(PrintPropertiesResult {
|
||||
selected_printer,
|
||||
options: PrintOptions {
|
||||
media_type,
|
||||
cups_options,
|
||||
ppd_uncorrected_passthrough: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// Construct `lp` command line arguments for target printing on macOS with
|
||||
/// ColorSync bypass flags and any user-captured PPD options.
|
||||
pub fn build_lp_args(
|
||||
printer_name: &str,
|
||||
tiff_path: &str,
|
||||
@@ -18,7 +525,9 @@ pub fn build_lp_args(
|
||||
let path = Path::new(tiff_path);
|
||||
let title = format!(
|
||||
"ICCery Target - {}",
|
||||
path.file_name().and_then(|n| n.to_str()).unwrap_or("Profiling Target")
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("Profiling Target")
|
||||
);
|
||||
|
||||
let mut args = vec![
|
||||
@@ -28,38 +537,103 @@ pub fn build_lp_args(
|
||||
title,
|
||||
];
|
||||
|
||||
let ppd_fallback = options
|
||||
.and_then(|o| o.ppd_uncorrected_passthrough)
|
||||
.unwrap_or(false);
|
||||
|
||||
if ppd_fallback {
|
||||
args.push("-o".to_string());
|
||||
args.push("ColorModel=Gray".to_string());
|
||||
args.push("-o".to_string());
|
||||
args.push("cm-calibration".to_string());
|
||||
} else {
|
||||
args.push("-o".to_string());
|
||||
args.push("raw".to_string());
|
||||
}
|
||||
|
||||
// macOS Apple ColorSync suppression flags
|
||||
// Always apply ColorSync bypass on macOS for targeting. The dot-notation
|
||||
// variant is a legacy form used by some raster drivers and PDEs.
|
||||
args.push("-o".to_string());
|
||||
args.push("AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string());
|
||||
args.push("-o".to_string());
|
||||
args.push("AP.ColorMatchingMode=AP_ApplicationColorMatching".to_string());
|
||||
|
||||
// Track which option keys have already been added from cups_options so we
|
||||
// don't duplicate them from the explicit PrintOptions fields.
|
||||
let mut added_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
// If we have captured CUPS options from the native print panel, add them
|
||||
// first. These take precedence over auto-detected defaults.
|
||||
if let Some(opts) = options {
|
||||
if let Some(ref cups_opts) = opts.cups_options {
|
||||
if !cups_opts.trim().is_empty() {
|
||||
for (key, value) in crate::print::unix::parse_cups_options_string(cups_opts) {
|
||||
if !key.is_empty() {
|
||||
args.push("-o".to_string());
|
||||
args.push(format!("{}={}", key, value));
|
||||
added_keys.insert(key.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch lpoptions for this printer to detect capabilities.
|
||||
let lpoptions_output: Option<String> = get_lpoptions_output(printer_name);
|
||||
|
||||
// Add media type from explicit options if not already in cups_options.
|
||||
if let Some(opts) = options {
|
||||
if let Some(ref media_type) = opts.media_type {
|
||||
if !media_type.trim().is_empty()
|
||||
&& !added_keys.contains("mediatype")
|
||||
&& !added_keys.contains("cnijmediatype")
|
||||
&& !added_keys.contains("epij_medi")
|
||||
&& !added_keys.contains("stpmediatype")
|
||||
{
|
||||
if let Some(ref output_str) = lpoptions_output {
|
||||
let key = crate::print::unix::detect_media_type_key(output_str);
|
||||
args.push("-o".to_string());
|
||||
args.push(format!("{}={}", key, media_type.trim()));
|
||||
added_keys.insert(key.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always detect and add the driver color bypass (not gated on
|
||||
// ppd_uncorrected_passthrough). If cups_options already contains a color
|
||||
// bypass key, skip this to avoid duplicates.
|
||||
let color_bypass_keys = [
|
||||
"cnijintent2",
|
||||
"cnijintent",
|
||||
"epij_cmat",
|
||||
"epij_ccor",
|
||||
"epij_oscolmat",
|
||||
"colorcorrection",
|
||||
"stpcolorcorrection",
|
||||
"epsoncolormode",
|
||||
];
|
||||
let has_color_bypass = added_keys
|
||||
.iter()
|
||||
.any(|k| color_bypass_keys.contains(&k.as_str()));
|
||||
if !has_color_bypass {
|
||||
if let Some(ref output_str) = lpoptions_output {
|
||||
if let Some((bypass_key, bypass_val)) =
|
||||
crate::print::unix::detect_driver_color_bypass(output_str)
|
||||
{
|
||||
args.push("-o".to_string());
|
||||
args.push(format!("{}={}", bypass_key, bypass_val));
|
||||
added_keys.insert(bypass_key.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add orientation and paper size from explicit options if not already
|
||||
// present in cups_options.
|
||||
if let Some(opts) = options {
|
||||
if let Some(ref orient) = opts.orientation {
|
||||
args.push("-o".to_string());
|
||||
if orient.eq_ignore_ascii_case("landscape") {
|
||||
args.push("orientation-requested=4".to_string());
|
||||
} else {
|
||||
args.push("orientation-requested=3".to_string());
|
||||
if !added_keys.contains("orientation-requested") {
|
||||
args.push("-o".to_string());
|
||||
if orient.eq_ignore_ascii_case("landscape") {
|
||||
args.push("orientation-requested=4".to_string());
|
||||
} else {
|
||||
args.push("orientation-requested=3".to_string());
|
||||
}
|
||||
added_keys.insert("orientation-requested".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref page_size) = opts.paper_size {
|
||||
if !page_size.trim().is_empty() {
|
||||
if !page_size.trim().is_empty() && !added_keys.contains("pagesize") {
|
||||
args.push("-o".to_string());
|
||||
args.push(format!("PageSize={}", page_size.trim()));
|
||||
added_keys.insert("pagesize".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,13 +676,189 @@ pub fn print_target(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open macOS printer queue / properties management or inspect queue options.
|
||||
pub fn show_printer_properties(printer_name: &str) -> Result<(), String> {
|
||||
// On macOS, open Print & Scan preference pane or query printer status via lpstat
|
||||
let _ = std::process::Command::new("open")
|
||||
.args(["x-apple.systempreferences:com.apple.preference.printfax"])
|
||||
.spawn();
|
||||
/// Open the native macOS `NSPrintPanel` for the selected printer.
|
||||
///
|
||||
/// The panel is pre-configured with `AP_ColorMatchingMode=AP_ApplicationColorMatching`
|
||||
/// so the driver's color-management controls are greyed out (application manages
|
||||
/// color). On OK, the user's media type / quality choices are captured and
|
||||
/// returned as a `PrintPropertiesResult` snapshot for the frontend to feed back
|
||||
/// into `print_target_native`.
|
||||
///
|
||||
/// This function dispatches the dialog to the Cocoa main thread via
|
||||
/// `AppHandle::run_on_main_thread` and awaits the result.
|
||||
pub async fn show_printer_properties(
|
||||
printer_name: &str,
|
||||
app: &tauri::AppHandle,
|
||||
) -> Result<Option<PrintPropertiesResult>, String> {
|
||||
// Resolve the human-readable display name before entering the main thread
|
||||
// so we have a fallback if PMPrinterCreateFromPrinterID fails.
|
||||
let display_name = crate::print::unix::get_printer_display_name(printer_name);
|
||||
|
||||
let _ = printer_name;
|
||||
Ok(())
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<Result<Option<PrintPropertiesResult>, String>>();
|
||||
let printer_name_owned = printer_name.to_string();
|
||||
let display_name_owned = display_name;
|
||||
|
||||
app.run_on_main_thread(move || {
|
||||
let display_name_ref = display_name_owned.as_deref();
|
||||
let result = run_native_print_panel(&printer_name_owned, display_name_ref);
|
||||
let _ = tx.send(result);
|
||||
})
|
||||
.map_err(|e| format!("Failed to dispatch print panel to main thread: {}", e))?;
|
||||
|
||||
rx.await
|
||||
.map_err(|e| format!("Print panel channel closed unexpectedly: {}", e))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_filter_cups_options_string_basic() {
|
||||
let raw = "MediaType=92 EPIJ_CMat=3 com.apple.print.PrintSettings.PMCopies..n.=1 collate=False PageSize=A4 AP_ColorMatchingMode=AP_ApplicationColorMatching AP_D_InputSlot= copies=1";
|
||||
let filtered = filter_cups_options_string(raw).unwrap();
|
||||
// Should contain MediaType, EPIJ_CMat, PageSize but not com.apple.*,
|
||||
// collate, copies, AP_ColorMatchingMode, or empty AP_D_InputSlot.
|
||||
assert!(filtered.contains("MediaType=92"));
|
||||
assert!(filtered.contains("EPIJ_CMat=3"));
|
||||
assert!(filtered.contains("PageSize=A4"));
|
||||
assert!(!filtered.contains("com.apple."));
|
||||
assert!(!filtered.contains("collate"));
|
||||
assert!(!filtered.contains("copies"));
|
||||
assert!(!filtered.contains("AP_ColorMatchingMode"));
|
||||
assert!(!filtered.contains("AP_D_InputSlot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_cups_options_string_empty() {
|
||||
let raw = "com.apple.print.PrintSettings.PMCopies..n.=1 collate=False";
|
||||
assert!(filter_cups_options_string(raw).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_media_type_from_options() {
|
||||
let opts = "MediaType=92 EPIJ_CMat=3 PageSize=A4";
|
||||
assert_eq!(
|
||||
extract_media_type_from_options(opts),
|
||||
Some("92".to_string())
|
||||
);
|
||||
|
||||
let opts2 = "EPIJ_Medi=13 PageSize=A4";
|
||||
assert_eq!(
|
||||
extract_media_type_from_options(opts2),
|
||||
Some("13".to_string())
|
||||
);
|
||||
|
||||
let opts3 = "PageSize=A4";
|
||||
assert_eq!(extract_media_type_from_options(opts3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_with_cups_options() {
|
||||
let opts = PrintOptions {
|
||||
cups_options: Some("MediaType=92 EPIJ_CMat=3 PageSize=A4".to_string()),
|
||||
ppd_uncorrected_passthrough: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts));
|
||||
assert!(args.contains(&"AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string()));
|
||||
assert!(args.contains(&"MediaType=92".to_string()));
|
||||
assert!(args.contains(&"EPIJ_CMat=3".to_string()));
|
||||
assert!(args.contains(&"PageSize=A4".to_string()));
|
||||
assert_eq!(args.last().unwrap(), "/tmp/target.tif");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_no_duplicate_media_type() {
|
||||
// When cups_options contains a MediaType, the explicit media_type
|
||||
// field should NOT also be added.
|
||||
let opts = PrintOptions {
|
||||
cups_options: Some("MediaType=92".to_string()),
|
||||
media_type: Some("13".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts));
|
||||
// Should contain MediaType=92 (from cups_options) but NOT MediaType=13
|
||||
assert!(args.contains(&"MediaType=92".to_string()));
|
||||
assert!(!args.contains(&"MediaType=13".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_no_duplicate_color_bypass() {
|
||||
// When cups_options contains a color bypass key, the auto-detected
|
||||
// bypass should NOT also be added.
|
||||
let opts = PrintOptions {
|
||||
cups_options: Some("EPIJ_CMat=3".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts));
|
||||
// Should contain EPIJ_CMat=3 exactly once (from cups_options).
|
||||
let count = args.iter().filter(|a| *a == "EPIJ_CMat=3").count();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
// Same for Canon's CNIJIntent2 path.
|
||||
let opts2 = PrintOptions {
|
||||
cups_options: Some("CNIJIntent2=4".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let args2 = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts2));
|
||||
let count2 = args2.iter().filter(|a| *a == "CNIJIntent2=4").count();
|
||||
assert_eq!(count2, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_colorsync_always_present() {
|
||||
// Even with no options, AP_ColorMatchingMode and the dot-notation
|
||||
// variant should be present.
|
||||
let args = build_lp_args("Test_Printer", "/tmp/target.tif", None);
|
||||
assert!(args.contains(&"AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string()));
|
||||
assert!(args.contains(&"AP.ColorMatchingMode=AP_ApplicationColorMatching".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_orientation_not_duplicated() {
|
||||
let opts = PrintOptions {
|
||||
cups_options: Some("orientation-requested=4".to_string()),
|
||||
orientation: Some("portrait".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts));
|
||||
// Should contain orientation-requested=4 from cups_options, but NOT
|
||||
// orientation-requested=3 from the explicit portrait setting.
|
||||
assert!(args.contains(&"orientation-requested=4".to_string()));
|
||||
assert!(!args.contains(&"orientation-requested=3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_cups_options_string_keeps_color_bypass_keys() {
|
||||
let raw = "com.apple.print.PrintSettings.PMCopies..n.=1 collate=False CNIJIntent2=4 EPIJ_CMat=3 ColorCorrection=Uncorrected PageSize=A4";
|
||||
let filtered = filter_cups_options_string(raw).unwrap();
|
||||
assert!(filtered.contains("CNIJIntent2=4"));
|
||||
assert!(filtered.contains("EPIJ_CMat=3"));
|
||||
assert!(filtered.contains("ColorCorrection=Uncorrected"));
|
||||
assert!(filtered.contains("PageSize=A4"));
|
||||
assert!(!filtered.contains("com.apple."));
|
||||
assert!(!filtered.contains("collate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_driver_color_bypass_canon_and_epson() {
|
||||
let canon = "CNIJIntent2/Color Mode: 1 4 3 *StpColorCorrection/Color Correction: None Correct Uncorrected";
|
||||
assert_eq!(
|
||||
crate::print::unix::detect_driver_color_bypass(canon),
|
||||
Some(("CNIJIntent2", "4"))
|
||||
);
|
||||
|
||||
let epson = "EPIJ_CMat/Color Settings: 1 2 *3 StpColorCorrection/Color Correction: None Correct Uncorrected";
|
||||
assert_eq!(
|
||||
crate::print::unix::detect_driver_color_bypass(epson),
|
||||
Some(("EPIJ_CMat", "3"))
|
||||
);
|
||||
|
||||
let gutenprint = "StpColorCorrection/Color Correction: None Correct *Uncorrected";
|
||||
assert_eq!(
|
||||
crate::print::unix::detect_driver_color_bypass(gutenprint),
|
||||
Some(("StpColorCorrection", "Uncorrected"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ pub struct Printer {
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub is_default: bool,
|
||||
/// Human-readable name from CUPS `printer-info` (macOS display name).
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -21,10 +24,18 @@ pub struct PrinterPaperSize {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PrinterMediaType {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PrinterCapabilities {
|
||||
pub trays: Vec<PrinterTray>,
|
||||
pub paper_sizes: Vec<PrinterPaperSize>,
|
||||
#[serde(default)]
|
||||
pub media_types: Vec<PrinterMediaType>,
|
||||
pub supports_orientation: bool,
|
||||
}
|
||||
|
||||
@@ -33,7 +44,20 @@ pub struct PrintOptions {
|
||||
pub paper_source: Option<u16>,
|
||||
pub orientation: Option<String>,
|
||||
pub paper_size: Option<String>,
|
||||
pub media_type: Option<String>,
|
||||
pub ppd_uncorrected_passthrough: Option<bool>,
|
||||
/// Optional CUPS option string ("name=value name=value ...") captured from
|
||||
/// the native macOS print panel. On macOS these are appended to the `lp`
|
||||
/// invocation as additional `-o name=value` arguments, taking precedence
|
||||
/// over auto-detected defaults. On other platforms this field is ignored.
|
||||
#[serde(default)]
|
||||
pub cups_options: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PrintPropertiesResult {
|
||||
pub selected_printer: Option<String>,
|
||||
pub options: PrintOptions,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
use crate::print::{
|
||||
PrintOptions, Printer, PrinterCapabilities, PrinterDevModeStore, PrinterPaperSize,
|
||||
PrinterTray,
|
||||
PrintOptions, Printer, PrinterCapabilities, PrinterDevModeStore, PrinterMediaType,
|
||||
PrinterPaperSize, PrinterTray,
|
||||
};
|
||||
use crate::print::unix::{build_lp_args, parse_lpstat_d, parse_lpstat_e, parse_lpstat_p, print_target};
|
||||
|
||||
@@ -12,6 +12,7 @@ mod integration_tests {
|
||||
name: "Epson-Stylus-Pro-4900".to_string(),
|
||||
status: "Idle".to_string(),
|
||||
is_default: true,
|
||||
display_name: Some("Epson Stylus Pro 4900".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&printer).expect("Failed to serialize printer");
|
||||
@@ -46,6 +47,10 @@ mod integration_tests {
|
||||
name: "Letter (8.5 x 11 in)".to_string(),
|
||||
},
|
||||
],
|
||||
media_types: vec![PrinterMediaType {
|
||||
id: "1".to_string(),
|
||||
name: "Plain Paper".to_string(),
|
||||
}],
|
||||
supports_orientation: true,
|
||||
};
|
||||
|
||||
@@ -53,6 +58,7 @@ mod integration_tests {
|
||||
assert!(json.contains("Auto Sheet Feeder"));
|
||||
assert!(json.contains("Rear Manual Feed"));
|
||||
assert!(json.contains("A4 (210 x 297 mm)"));
|
||||
assert!(json.contains("Plain Paper"));
|
||||
|
||||
let deserialized: PrinterCapabilities =
|
||||
serde_json::from_str(&json).expect("Failed to deserialize capabilities");
|
||||
@@ -65,7 +71,9 @@ mod integration_tests {
|
||||
paper_source: Some(2),
|
||||
orientation: Some("landscape".to_string()),
|
||||
paper_size: Some("A4".to_string()),
|
||||
media_type: Some("1".to_string()),
|
||||
ppd_uncorrected_passthrough: Some(false),
|
||||
cups_options: Some("MediaType=1".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&opts).expect("Failed to serialize PrintOptions");
|
||||
@@ -74,6 +82,27 @@ mod integration_tests {
|
||||
assert_eq!(deserialized.paper_source, Some(2));
|
||||
assert_eq!(deserialized.orientation.as_deref(), Some("landscape"));
|
||||
assert_eq!(deserialized.paper_size.as_deref(), Some("A4"));
|
||||
assert_eq!(deserialized.media_type.as_deref(), Some("1"));
|
||||
assert_eq!(deserialized.cups_options.as_deref(), Some("MediaType=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_print_options_cups_options_default_none() {
|
||||
// When cups_options is not set, it should default to None and
|
||||
// serialize/deserialize correctly.
|
||||
let opts = PrintOptions {
|
||||
paper_source: None,
|
||||
orientation: None,
|
||||
paper_size: None,
|
||||
media_type: None,
|
||||
ppd_uncorrected_passthrough: None,
|
||||
cups_options: None,
|
||||
};
|
||||
let json = serde_json::to_string(&opts).expect("Failed to serialize");
|
||||
assert!(!json.contains("cups_options") || json.contains("\"cups_options\":null"));
|
||||
let deserialized: PrintOptions =
|
||||
serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
assert_eq!(deserialized.cups_options, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -221,7 +250,9 @@ printer Custom_Queue unknown state\n\
|
||||
paper_source: Some(3),
|
||||
orientation: Some("landscape".to_string()),
|
||||
paper_size: None,
|
||||
media_type: Some("4".to_string()),
|
||||
ppd_uncorrected_passthrough: None,
|
||||
cups_options: None,
|
||||
};
|
||||
|
||||
let mut work_buf = retrieved;
|
||||
@@ -235,6 +266,8 @@ printer Custom_Queue unknown state\n\
|
||||
assert_eq!((*p_work_devmode).Anonymous1.Anonymous1.dmDefaultSource, 3);
|
||||
// Verify orientation override
|
||||
assert_eq!((*p_work_devmode).Anonymous1.Anonymous1.dmOrientation, 2); // DMORIENT_LANDSCAPE = 2
|
||||
// Verify media type override
|
||||
assert_eq!((*p_work_devmode).dmMediaType, 4);
|
||||
}
|
||||
|
||||
// Verify private OEM payload was NOT corrupted or overwritten
|
||||
@@ -255,11 +288,10 @@ printer Custom_Queue unknown state\n\
|
||||
assert_eq!(args_raw[1], printer);
|
||||
assert_eq!(args_raw[2], "-t");
|
||||
assert_eq!(args_raw[3], "ICCery Target - target_page_1.tif");
|
||||
assert_eq!(args_raw[4], "-o");
|
||||
assert_eq!(args_raw[5], "raw");
|
||||
assert_eq!(args_raw[6], "-o");
|
||||
assert_eq!(args_raw[7], "AP_ColorMatchingMode=AP_ApplicationColorMatching");
|
||||
assert_eq!(args_raw[8], path);
|
||||
assert!(args_raw.contains(&"-o".to_string()));
|
||||
assert!(args_raw.contains(&"AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string()));
|
||||
assert!(args_raw.contains(&"AP.ColorMatchingMode=AP_ApplicationColorMatching".to_string()));
|
||||
assert_eq!(args_raw.last().unwrap(), path);
|
||||
|
||||
// PPD uncorrected passthrough mode with options
|
||||
let opts = PrintOptions {
|
||||
@@ -269,17 +301,12 @@ printer Custom_Queue unknown state\n\
|
||||
..Default::default()
|
||||
};
|
||||
let args_ppd = macos_build_lp_args(printer, path, Some(&opts));
|
||||
assert_eq!(args_ppd[4], "-o");
|
||||
assert_eq!(args_ppd[5], "ColorModel=Gray");
|
||||
assert_eq!(args_ppd[6], "-o");
|
||||
assert_eq!(args_ppd[7], "cm-calibration");
|
||||
assert_eq!(args_ppd[8], "-o");
|
||||
assert_eq!(args_ppd[9], "AP_ColorMatchingMode=AP_ApplicationColorMatching");
|
||||
assert_eq!(args_ppd[10], "-o");
|
||||
assert_eq!(args_ppd[11], "orientation-requested=4");
|
||||
assert_eq!(args_ppd[12], "-o");
|
||||
assert_eq!(args_ppd[13], "PageSize=A4");
|
||||
assert_eq!(args_ppd[14], path);
|
||||
assert!(args_ppd.contains(&"-o".to_string()));
|
||||
assert!(args_ppd.contains(&"AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string()));
|
||||
assert!(args_ppd.contains(&"AP.ColorMatchingMode=AP_ApplicationColorMatching".to_string()));
|
||||
assert!(args_ppd.contains(&"orientation-requested=4".to_string()));
|
||||
assert!(args_ppd.contains(&"PageSize=A4".to_string()));
|
||||
assert_eq!(args_ppd.last().unwrap(), path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+254
-89
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::path::Path;
|
||||
use crate::print::{
|
||||
PrintOptions, Printer, PrinterCapabilities, PrinterPaperSize, PrinterTray,
|
||||
PrintOptions, Printer, PrinterCapabilities, PrinterPaperSize, PrinterTray, PrinterMediaType,
|
||||
};
|
||||
|
||||
/// Parse `lpstat -e` output into a list of printer destination names.
|
||||
@@ -55,6 +55,8 @@ pub fn parse_lpstat_p(output: &str) -> Vec<(String, String)> {
|
||||
}
|
||||
|
||||
/// Merge printer lists from `lpstat -e`, `lpstat -p`, and `lpstat -d`.
|
||||
/// Optionally enriches each printer with a display name fetched from
|
||||
/// `lpoptions -p <name>`.
|
||||
pub fn merge_printer_info(
|
||||
destinations: &[String],
|
||||
statuses: &[(String, String)],
|
||||
@@ -67,6 +69,18 @@ pub fn merge_printer_info(
|
||||
.map(|(name, status)| (name.as_str(), status.as_str()))
|
||||
.collect();
|
||||
|
||||
fn get_display_name(name: &str) -> Option<String> {
|
||||
let out = std::process::Command::new("lpoptions")
|
||||
.args(["-p", name])
|
||||
.output()
|
||||
.ok()?;
|
||||
if out.status.success() {
|
||||
extract_printer_display_name(&String::from_utf8_lossy(&out.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
|
||||
@@ -78,6 +92,7 @@ pub fn merge_printer_info(
|
||||
name: name.clone(),
|
||||
status: status.to_string(),
|
||||
is_default,
|
||||
display_name: get_display_name(name),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -89,6 +104,7 @@ pub fn merge_printer_info(
|
||||
name: name.clone(),
|
||||
status: status.clone(),
|
||||
is_default,
|
||||
display_name: get_display_name(name),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -96,6 +112,33 @@ pub fn merge_printer_info(
|
||||
result
|
||||
}
|
||||
|
||||
/// Extract the CUPS `printer-info` display name from `lpoptions -p <name>` output.
|
||||
/// This is the human-readable queue name that macOS uses in the print panel.
|
||||
pub fn extract_printer_display_name(output: &str) -> Option<String> {
|
||||
for line in output.lines() {
|
||||
if let Some(info) = line.strip_prefix("printer-info=") {
|
||||
let trimmed = info.trim().trim_matches('\'');
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Fetch the CUPS `printer-info` display name for a single destination.
|
||||
pub fn get_printer_display_name(name: &str) -> Option<String> {
|
||||
let out = std::process::Command::new("lpoptions")
|
||||
.args(["-p", name])
|
||||
.output()
|
||||
.ok()?;
|
||||
if out.status.success() {
|
||||
extract_printer_display_name(&String::from_utf8_lossy(&out.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Query available CUPS printers, connection statuses, and default destination.
|
||||
pub fn get_printers() -> Result<Vec<Printer>, String> {
|
||||
let e_output = std::process::Command::new("lpstat")
|
||||
@@ -134,10 +177,11 @@ pub fn get_printers() -> Result<Vec<Printer>, String> {
|
||||
Ok(merge_printer_info(&destinations, &statuses, default_dest.as_deref()))
|
||||
}
|
||||
|
||||
/// Parse `lpoptions -p <printer> -l` output into trays and paper sizes.
|
||||
pub fn parse_lpoptions_l(output: &str) -> (Vec<PrinterTray>, Vec<PrinterPaperSize>) {
|
||||
/// Parse `lpoptions -p <printer> -l` output into trays and paper sizes and media types.
|
||||
pub fn parse_lpoptions_l(output: &str) -> (Vec<PrinterTray>, Vec<PrinterPaperSize>, Vec<PrinterMediaType>) {
|
||||
let mut trays = Vec::new();
|
||||
let mut paper_sizes = Vec::new();
|
||||
let mut media_types = Vec::new();
|
||||
|
||||
for line in output.lines() {
|
||||
let trimmed = line.trim();
|
||||
@@ -165,11 +209,126 @@ pub fn parse_lpoptions_l(output: &str) -> (Vec<PrinterTray>, Vec<PrinterPaperSiz
|
||||
name: clean_val.to_string(),
|
||||
});
|
||||
}
|
||||
} else if key_name.eq_ignore_ascii_case("CNIJMediaType")
|
||||
|| key_name.eq_ignore_ascii_case("MediaType")
|
||||
|| key_name.eq_ignore_ascii_case("StpMediaType")
|
||||
|| key_name.eq_ignore_ascii_case("EPIJ_Medi")
|
||||
{
|
||||
for val in values {
|
||||
let clean_val = val.trim_start_matches('*');
|
||||
media_types.push(PrinterMediaType {
|
||||
id: clean_val.to_string(),
|
||||
name: clean_val.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(trays, paper_sizes)
|
||||
(trays, paper_sizes, media_types)
|
||||
}
|
||||
|
||||
pub fn parse_ppd_media_types(ppd_content: &str) -> Vec<PrinterMediaType> {
|
||||
let mut media = Vec::new();
|
||||
for line in ppd_content.lines() {
|
||||
if line.starts_with("*CNIJMediaType ")
|
||||
|| line.starts_with("*MediaType ")
|
||||
|| line.starts_with("*StpMediaType ")
|
||||
|| line.starts_with("*EPIJ_Medi ")
|
||||
{
|
||||
if let Some((id_part, name_part)) = line.split_once('/') {
|
||||
let id = id_part.split_whitespace().last().unwrap_or("").trim().to_string();
|
||||
let name = name_part.split(':').next().unwrap_or("").trim().to_string();
|
||||
if !id.is_empty() {
|
||||
media.push(PrinterMediaType { id, name });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
media
|
||||
}
|
||||
|
||||
pub fn detect_driver_color_bypass(output: &str) -> Option<(&'static str, &'static str)> {
|
||||
if output.contains("CNIJIntent2") {
|
||||
Some(("CNIJIntent2", "4"))
|
||||
} else if output.contains("CNIJIntent") {
|
||||
Some(("CNIJIntent", "4"))
|
||||
} else if output.contains("EPIJ_CCor") {
|
||||
// Epson "Color Settings" option. The value 0 disables driver-side color correction.
|
||||
Some(("EPIJ_CCor", "0"))
|
||||
} else if output.contains("EPIJ_CMat") {
|
||||
// Epson "Color Settings" option. The value 3 is "Off (No Color
|
||||
// Adjustment)" which disables driver-side color management.
|
||||
Some(("EPIJ_CMat", "3"))
|
||||
} else if output.contains("StpColorCorrection") {
|
||||
Some(("StpColorCorrection", "Uncorrected"))
|
||||
} else if output.contains("ColorCorrection") {
|
||||
Some(("ColorCorrection", "Uncorrected"))
|
||||
} else if output.contains("EpsonColorMode") {
|
||||
Some(("EpsonColorMode", "Off"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect_media_type_key(output: &str) -> &'static str {
|
||||
if output.contains("CNIJMediaType") {
|
||||
"CNIJMediaType"
|
||||
} else if output.contains("EPIJ_Medi") {
|
||||
"EPIJ_Medi"
|
||||
} else if output.contains("StpMediaType") {
|
||||
"StpMediaType"
|
||||
} else {
|
||||
"MediaType"
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a CUPS options string of the form `"name=value name=value ..."` into
|
||||
/// an ordered vector of `(name, value)` pairs. Tokens are separated by
|
||||
/// whitespace. Values may be quoted with double quotes; the quotes are
|
||||
/// preserved in the value so the caller can decide whether to strip them.
|
||||
/// Malformed tokens without `=` are skipped.
|
||||
pub fn parse_cups_options_string(options: &str) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = options.chars().peekable();
|
||||
|
||||
while chars.peek().is_some() {
|
||||
// Skip leading whitespace.
|
||||
while chars.peek().map_or(false, |c| c.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
|
||||
// Collect the token, respecting double-quoted substrings.
|
||||
let mut token = String::new();
|
||||
let mut in_quote = false;
|
||||
while let Some(c) = chars.peek() {
|
||||
match c {
|
||||
'"' => {
|
||||
in_quote = !in_quote;
|
||||
token.push(*c);
|
||||
chars.next();
|
||||
}
|
||||
c if c.is_whitespace() && !in_quote => break,
|
||||
_ => {
|
||||
token.push(*c);
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if token.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((name, value)) = token.split_once('=') {
|
||||
let name = name.trim().trim_matches('"').to_string();
|
||||
let value = value.trim().trim_matches('"').to_string();
|
||||
if !name.is_empty() {
|
||||
out.push((name, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Query CUPS printer capabilities via `lpoptions -p <printer> -l`.
|
||||
@@ -178,16 +337,24 @@ pub fn get_printer_capabilities(printer_name: &str) -> Result<PrinterCapabilitie
|
||||
.args(["-p", printer_name, "-l"])
|
||||
.output();
|
||||
|
||||
let (trays, paper_sizes) = match output {
|
||||
let (trays, paper_sizes, mut media_types) = match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
parse_lpoptions_l(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
_ => (Vec::new(), Vec::new(), Vec::new()),
|
||||
};
|
||||
|
||||
if let Ok(ppd_content) = std::fs::read_to_string(format!("/etc/cups/ppd/{}.ppd", printer_name)) {
|
||||
let ppd_media = parse_ppd_media_types(&ppd_content);
|
||||
if !ppd_media.is_empty() {
|
||||
media_types = ppd_media;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PrinterCapabilities {
|
||||
trays,
|
||||
paper_sizes,
|
||||
media_types,
|
||||
supports_orientation: true,
|
||||
})
|
||||
}
|
||||
@@ -241,6 +408,14 @@ pub fn build_lp_args(
|
||||
args.push(format!("PageSize={}", page_size.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref media_type) = opts.media_type {
|
||||
if !media_type.trim().is_empty() {
|
||||
// Here we just use a generic MediaType, but macos.rs will use the detected key
|
||||
args.push("-o".to_string());
|
||||
args.push(format!("MediaType={}", media_type.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
args.push(tiff_path.to_string());
|
||||
@@ -343,38 +518,21 @@ printer Zebra_Label disabled since Wed 10 Jun 2026 - reason: out of ribbon\n\
|
||||
let merged = merge_printer_info(&destinations, &statuses, default_dest);
|
||||
assert_eq!(merged.len(), 4);
|
||||
|
||||
assert_eq!(
|
||||
merged[0],
|
||||
Printer {
|
||||
name: "Epson-Stylus-SX420W".to_string(),
|
||||
status: "Idle".to_string(),
|
||||
is_default: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
merged[1],
|
||||
Printer {
|
||||
name: "HP_LaserJet".to_string(),
|
||||
status: "Printing".to_string(),
|
||||
is_default: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
merged[2],
|
||||
Printer {
|
||||
name: "Virtual_PDF".to_string(),
|
||||
status: "Idle".to_string(),
|
||||
is_default: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
merged[3],
|
||||
Printer {
|
||||
name: "Zebra_Label".to_string(),
|
||||
status: "Stopped".to_string(),
|
||||
is_default: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(merged[0].name, "Epson-Stylus-SX420W".to_string());
|
||||
assert_eq!(merged[0].status, "Idle".to_string());
|
||||
assert!(merged[0].is_default);
|
||||
|
||||
assert_eq!(merged[1].name, "HP_LaserJet".to_string());
|
||||
assert_eq!(merged[1].status, "Printing".to_string());
|
||||
assert!(!merged[1].is_default);
|
||||
|
||||
assert_eq!(merged[2].name, "Virtual_PDF".to_string());
|
||||
assert_eq!(merged[2].status, "Idle".to_string());
|
||||
assert!(!merged[2].is_default);
|
||||
|
||||
assert_eq!(merged[3].name, "Zebra_Label".to_string());
|
||||
assert_eq!(merged[3].status, "Stopped".to_string());
|
||||
assert!(!merged[3].is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -384,7 +542,7 @@ PageSize/Media Size: *A4 Letter Legal A3\n\
|
||||
InputSlot/Media Source: *Auto Upper Lower Rear Manual\n\
|
||||
Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\
|
||||
";
|
||||
let (trays, paper_sizes) = parse_lpoptions_l(sample);
|
||||
let (trays, paper_sizes, _media_types) = parse_lpoptions_l(sample);
|
||||
assert_eq!(trays.len(), 5);
|
||||
assert_eq!(trays[0].name, "Auto");
|
||||
assert_eq!(trays[1].name, "Upper");
|
||||
@@ -396,43 +554,64 @@ Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_raw() {
|
||||
let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", None);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-d",
|
||||
"Epson-Stylus-SX420W",
|
||||
"-t",
|
||||
"ICCery Target - target.tif",
|
||||
"-o",
|
||||
"raw",
|
||||
"/tmp/target.tif"
|
||||
]
|
||||
);
|
||||
fn test_parse_ppd_media_types() {
|
||||
let sample = "\
|
||||
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: \"\"
|
||||
*CNIJMediaType 43/Photo Paper Pro Platinum: \"\"
|
||||
";
|
||||
let media = parse_ppd_media_types(sample);
|
||||
assert_eq!(media.len(), 2);
|
||||
assert_eq!(media[0].id, "42");
|
||||
assert_eq!(media[0].name, "Photo Paper Plus Semi-gloss");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_ppd_fallback() {
|
||||
let opts = PrintOptions {
|
||||
ppd_uncorrected_passthrough: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", Some(&opts));
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-d",
|
||||
"Epson-Stylus-SX420W",
|
||||
"-t",
|
||||
"ICCery Target - target.tif",
|
||||
"-o",
|
||||
"ColorModel=Gray",
|
||||
"-o",
|
||||
"cm-calibration",
|
||||
"/tmp/target.tif"
|
||||
]
|
||||
);
|
||||
fn test_detect_driver_color_bypass() {
|
||||
assert_eq!(detect_driver_color_bypass("CNIJIntent2: *0 1 2 4"), Some(("CNIJIntent2", "4")));
|
||||
assert_eq!(detect_driver_color_bypass("EPIJ_CCor: *0 3 4 6 12"), Some(("EPIJ_CCor", "0")));
|
||||
assert_eq!(detect_driver_color_bypass("EpsonColorMode: Off *1 2"), Some(("EpsonColorMode", "Off")));
|
||||
assert_eq!(detect_driver_color_bypass("SomethingElse: 1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_media_type_key_epson() {
|
||||
assert_eq!(detect_media_type_key("EPIJ_Medi: *0 92 13"), "EPIJ_Medi");
|
||||
assert_eq!(detect_media_type_key("CNIJMediaType: *1 2"), "CNIJMediaType");
|
||||
assert_eq!(detect_media_type_key("StpMediaType: *1"), "StpMediaType");
|
||||
assert_eq!(detect_media_type_key("MediaType: *Plain"), "MediaType");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cups_options_string() {
|
||||
let opts = parse_cups_options_string("MediaType=92 EPIJ_CCor=0 PageSize=A4");
|
||||
assert_eq!(opts, vec![
|
||||
("MediaType".to_string(), "92".to_string()),
|
||||
("EPIJ_CCor".to_string(), "0".to_string()),
|
||||
("PageSize".to_string(), "A4".to_string()),
|
||||
]);
|
||||
|
||||
// Empty / malformed tokens are skipped
|
||||
let empty = parse_cups_options_string("");
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let no_eq = parse_cups_options_string("foo bar baz");
|
||||
assert!(no_eq.is_empty());
|
||||
|
||||
let empty_val = parse_cups_options_string("AP_D_InputSlot=");
|
||||
assert_eq!(empty_val, vec![("AP_D_InputSlot".to_string(), "".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lp_args_raw() {
|
||||
let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", None);
|
||||
assert_eq!(args.len(), 7);
|
||||
assert_eq!(args[0], "-d");
|
||||
assert_eq!(args[1], "Epson-Stylus-SX420W");
|
||||
assert_eq!(args[2], "-t");
|
||||
assert_eq!(args[3], "ICCery Target - target.tif");
|
||||
assert_eq!(args[4], "-o");
|
||||
assert_eq!(args[5], "raw");
|
||||
assert_eq!(args[6], "/tmp/target.tif");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -444,22 +623,8 @@ Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", Some(&opts));
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-d",
|
||||
"Epson-Stylus-SX420W",
|
||||
"-t",
|
||||
"ICCery Target - target.tif",
|
||||
"-o",
|
||||
"raw",
|
||||
"-o",
|
||||
"orientation-requested=4",
|
||||
"-o",
|
||||
"PageSize=A4",
|
||||
"/tmp/target.tif"
|
||||
]
|
||||
);
|
||||
assert!(args.contains(&"orientation-requested=4".to_string()));
|
||||
assert!(args.contains(&"PageSize=A4".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -17,7 +17,8 @@ use windows::Win32::Graphics::Printing::{
|
||||
use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
|
||||
|
||||
use crate::print::{
|
||||
PrintOptions, Printer, PrinterCapabilities, PrinterDevModeStore, PrinterPaperSize, PrinterTray,
|
||||
PrintOptions, Printer, PrinterCapabilities, PrinterDevModeStore, PrinterMediaType,
|
||||
PrinterPaperSize, PrinterTray,
|
||||
};
|
||||
|
||||
#[repr(C)]
|
||||
@@ -54,11 +55,14 @@ const DM_ICMMETHOD: u32 = 0x00800000;
|
||||
const DMICMMETHOD_NONE: u32 = 1;
|
||||
const DMORIENT_PORTRAIT: i16 = 1;
|
||||
const DMORIENT_LANDSCAPE: i16 = 2;
|
||||
const DM_MEDIATYPE: u32 = 0x02000000;
|
||||
|
||||
const DC_PAPERS: u16 = 2;
|
||||
const DC_BINS: u16 = 6;
|
||||
const DC_BINNAMES: u16 = 12;
|
||||
const DC_PAPERNAMES: u16 = 16;
|
||||
const DC_MEDIATYPENAMES: u16 = 34;
|
||||
const DC_MEDIATYPES: u16 = 35;
|
||||
const IDOK: i32 = 1;
|
||||
|
||||
fn to_wide(s: &str) -> Vec<u16> {
|
||||
@@ -124,6 +128,7 @@ pub fn get_printers() -> Result<Vec<Printer>, String> {
|
||||
name,
|
||||
status: "Ready".to_string(),
|
||||
is_default: false,
|
||||
display_name: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -154,6 +159,7 @@ pub fn get_printers() -> Result<Vec<Printer>, String> {
|
||||
name,
|
||||
status: "Ready".to_string(),
|
||||
is_default: false,
|
||||
display_name: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -262,9 +268,58 @@ pub fn get_printer_capabilities(printer_name: &str) -> Result<PrinterCapabilitie
|
||||
}
|
||||
}
|
||||
|
||||
// Query media types
|
||||
let num_media = DeviceCapabilitiesW(
|
||||
PCWSTR(printer_wide.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
DC_MEDIATYPES,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null(),
|
||||
);
|
||||
|
||||
let mut media_types = Vec::new();
|
||||
if num_media > 0 {
|
||||
let mut media_ids = vec![0u32; num_media as usize];
|
||||
let mut media_names_raw = vec![0u16; num_media as usize * 64];
|
||||
|
||||
let res_ids = DeviceCapabilitiesW(
|
||||
PCWSTR(printer_wide.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
DC_MEDIATYPES,
|
||||
media_ids.as_mut_ptr() as *mut u16,
|
||||
std::ptr::null(),
|
||||
);
|
||||
|
||||
let res_names = DeviceCapabilitiesW(
|
||||
PCWSTR(printer_wide.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
DC_MEDIATYPENAMES,
|
||||
media_names_raw.as_mut_ptr(),
|
||||
std::ptr::null(),
|
||||
);
|
||||
|
||||
if res_ids > 0 && res_names > 0 {
|
||||
for i in 0..num_media as usize {
|
||||
let id = media_ids[i];
|
||||
let name_slice = &media_names_raw[i * 64..(i + 1) * 64];
|
||||
let name = extract_null_terminated_string(name_slice);
|
||||
let display_name = if name.is_empty() {
|
||||
format!("Media Type {}", id)
|
||||
} else {
|
||||
name
|
||||
};
|
||||
media_types.push(PrinterMediaType {
|
||||
id: id.to_string(),
|
||||
name: display_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PrinterCapabilities {
|
||||
trays,
|
||||
paper_sizes,
|
||||
media_types,
|
||||
supports_orientation: true,
|
||||
})
|
||||
}
|
||||
@@ -370,6 +425,13 @@ pub fn apply_print_options_to_devmode(
|
||||
(*p_devmode).Anonymous1.Anonymous1.dmOrientation = DMORIENT_PORTRAIT;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref media_type) = opts.media_type {
|
||||
if let Ok(media_id) = media_type.parse::<u32>() {
|
||||
(*p_devmode).dmFields |= DEVMODE_FIELD_FLAGS(DM_MEDIATYPE);
|
||||
(*p_devmode).dmMediaType = media_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ mod tests {
|
||||
let pm = ProcessManager::new();
|
||||
// Test helper using dummy/mock or direct map operations
|
||||
{
|
||||
let mut stdins = pm.stdins.lock().await;
|
||||
let stdins = pm.stdins.lock().await;
|
||||
assert!(!stdins.contains_key("test_proc"));
|
||||
}
|
||||
}
|
||||
|
||||
+130
-1
@@ -18,6 +18,16 @@ pub struct ProfilingPreset {
|
||||
pub colprof_algorithm: String,
|
||||
pub colprof_quality: String,
|
||||
pub colprof_intent: Option<String>,
|
||||
#[serde(default)]
|
||||
pub colprof_fwa: Option<String>,
|
||||
#[serde(default)]
|
||||
pub colprof_illuminant: Option<String>,
|
||||
#[serde(default)]
|
||||
pub colprof_observer: Option<String>,
|
||||
#[serde(default)]
|
||||
pub colprof_input_viewing_cond: Option<String>,
|
||||
#[serde(default)]
|
||||
pub colprof_output_viewing_cond: Option<String>,
|
||||
|
||||
// Advanced Stage 1 fields
|
||||
#[serde(default)]
|
||||
@@ -50,13 +60,49 @@ pub struct ProfilingPreset {
|
||||
pub no_randomize: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
|
||||
fn default_delta_e_good_max() -> f64 { 2.0 }
|
||||
fn default_delta_e_warning_max() -> f64 { 5.0 }
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct AppSettings {
|
||||
pub argyll_binary_dir: Option<String>,
|
||||
pub default_instrument: Option<String>,
|
||||
pub log_level: Option<String>,
|
||||
#[serde(default = "default_delta_e_good_max")]
|
||||
pub delta_e_good_max: f64,
|
||||
#[serde(default = "default_delta_e_warning_max")]
|
||||
pub delta_e_warning_max: f64,
|
||||
#[serde(default)]
|
||||
pub custom_presets: Vec<ProfilingPreset>,
|
||||
#[serde(default)]
|
||||
pub enable_i1pro2_leds: bool,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
argyll_binary_dir: None,
|
||||
default_instrument: None,
|
||||
log_level: None,
|
||||
delta_e_good_max: default_delta_e_good_max(),
|
||||
delta_e_warning_max: default_delta_e_warning_max(),
|
||||
custom_presets: Vec::new(),
|
||||
enable_i1pro2_leds: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
/// Validate thresholds and other cross-field invariants.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.delta_e_good_max < 0.0 || self.delta_e_warning_max < 0.0 {
|
||||
return Err("ΔE thresholds cannot be negative.".to_string());
|
||||
}
|
||||
if self.delta_e_good_max >= self.delta_e_warning_max {
|
||||
return Err("Good ΔE threshold must be strictly less than the warning threshold.".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
||||
@@ -89,6 +135,11 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
||||
device_power: None,
|
||||
random_seed: Some(1),
|
||||
no_randomize: Some(false),
|
||||
colprof_fwa: Some("D50".to_string()),
|
||||
colprof_illuminant: None,
|
||||
colprof_observer: None,
|
||||
colprof_input_viewing_cond: None,
|
||||
colprof_output_viewing_cond: None,
|
||||
},
|
||||
ProfilingPreset {
|
||||
id: "preset-hq-cmyk".to_string(),
|
||||
@@ -118,6 +169,11 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
||||
device_power: None,
|
||||
random_seed: Some(1),
|
||||
no_randomize: Some(false),
|
||||
colprof_fwa: Some("D50".to_string()),
|
||||
colprof_illuminant: None,
|
||||
colprof_observer: None,
|
||||
colprof_input_viewing_cond: None,
|
||||
colprof_output_viewing_cond: None,
|
||||
},
|
||||
ProfilingPreset {
|
||||
id: "preset-draft-rgb".to_string(),
|
||||
@@ -147,6 +203,11 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
||||
device_power: None,
|
||||
random_seed: Some(1),
|
||||
no_randomize: Some(false),
|
||||
colprof_fwa: Some("D50".to_string()),
|
||||
colprof_illuminant: None,
|
||||
colprof_observer: None,
|
||||
colprof_input_viewing_cond: None,
|
||||
colprof_output_viewing_cond: None,
|
||||
},
|
||||
ProfilingPreset {
|
||||
id: "preset-ultra-rgb".to_string(),
|
||||
@@ -176,6 +237,11 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
||||
device_power: None,
|
||||
random_seed: Some(1),
|
||||
no_randomize: Some(false),
|
||||
colprof_fwa: Some("D50".to_string()),
|
||||
colprof_illuminant: None,
|
||||
colprof_observer: None,
|
||||
colprof_input_viewing_cond: None,
|
||||
colprof_output_viewing_cond: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -209,6 +275,7 @@ pub fn load_settings(app: AppHandle) -> Result<AppSettings, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
|
||||
settings.validate()?;
|
||||
let path = app.path().app_data_dir().unwrap();
|
||||
fs::create_dir_all(&path).map_err(|e| e.to_string())?;
|
||||
let json = serde_json::to_string_pretty(&settings).unwrap();
|
||||
@@ -338,6 +405,11 @@ mod tests {
|
||||
device_power: Some(1.2),
|
||||
random_seed: Some(42),
|
||||
no_randomize: Some(false),
|
||||
colprof_fwa: Some("D50".to_string()),
|
||||
colprof_illuminant: None,
|
||||
colprof_observer: None,
|
||||
colprof_input_viewing_cond: None,
|
||||
colprof_output_viewing_cond: None,
|
||||
};
|
||||
|
||||
let json = export_preset_json(preset.clone()).expect("Export failed");
|
||||
@@ -345,6 +417,39 @@ mod tests {
|
||||
assert_eq!(preset, imported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_delta_e_thresholds() {
|
||||
let settings = AppSettings::default();
|
||||
assert_eq!(settings.delta_e_good_max, 2.0);
|
||||
assert_eq!(settings.delta_e_warning_max, 5.0);
|
||||
assert!(settings.delta_e_good_max < settings.delta_e_warning_max);
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delta_e_threshold_serialization_defaults() {
|
||||
let json = "{}";
|
||||
let settings: AppSettings = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(settings.delta_e_good_max, 2.0);
|
||||
assert_eq!(settings.delta_e_warning_max, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delta_e_threshold_validation() {
|
||||
let mut settings = AppSettings::default();
|
||||
settings.delta_e_good_max = 5.0;
|
||||
settings.delta_e_warning_max = 2.0;
|
||||
assert!(settings.validate().is_err());
|
||||
|
||||
settings.delta_e_good_max = -1.0;
|
||||
settings.delta_e_warning_max = 2.0;
|
||||
assert!(settings.validate().is_err());
|
||||
|
||||
settings.delta_e_good_max = 1.0;
|
||||
settings.delta_e_warning_max = 3.0;
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_level_filter() {
|
||||
assert_eq!(parse_log_level_filter(Some("error")), log::LevelFilter::Error);
|
||||
@@ -364,4 +469,28 @@ mod tests {
|
||||
assert_eq!(parse_log_level_filter(Some("unknown")), log::LevelFilter::Info);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_enable_i1pro2_leds() {
|
||||
let settings = AppSettings::default();
|
||||
assert_eq!(settings.enable_i1pro2_leds, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enable_i1pro2_leds_serialization() {
|
||||
// Missing field defaults to false
|
||||
let json_empty = "{}";
|
||||
let settings_empty: AppSettings = serde_json::from_str(json_empty).unwrap();
|
||||
assert_eq!(settings_empty.enable_i1pro2_leds, false);
|
||||
|
||||
// Explicit true
|
||||
let json_true = r#"{"enable_i1pro2_leds": true}"#;
|
||||
let settings_true: AppSettings = serde_json::from_str(json_true).unwrap();
|
||||
assert_eq!(settings_true.enable_i1pro2_leds, true);
|
||||
|
||||
// Explicit false
|
||||
let json_false = r#"{"enable_i1pro2_leds": false}"#;
|
||||
let settings_false: AppSettings = serde_json::from_str(json_false).unwrap();
|
||||
assert_eq!(settings_false.enable_i1pro2_leds, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ICCery",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.1",
|
||||
"identifier": "com.gronod.iccery",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
@@ -54,6 +54,7 @@
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"signCommand": "sign.cmd %1",
|
||||
"webviewInstallMode": {
|
||||
"type": "skip"
|
||||
},
|
||||
|
||||
+238
-41
@@ -8,6 +8,7 @@
|
||||
<link rel="stylesheet" href="./styles/main.css" />
|
||||
<script src="./js/three.min.js"></script>
|
||||
<script src="./js/OrbitControls.js"></script>
|
||||
<script src="./js/CSS2DRenderer.js"></script>
|
||||
<script type="module" src="./js/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -16,13 +17,13 @@
|
||||
<div class="brand">
|
||||
<img src="./assets/ICCery-logo.svg" alt="ICCery" class="main-logo" />
|
||||
<div class="brand-actions">
|
||||
<button id="openSettingsBtn" class="icon-btn" title="Settings" style="display: flex; align-items: center; justify-content: center;">
|
||||
<button id="openSettingsBtn" class="icon-btn" title="Settings">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="openAboutBtn" class="icon-btn" title="About" style="display: flex; align-items: center; justify-content: center;">
|
||||
<button id="openAboutBtn" class="icon-btn" title="About">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
@@ -34,11 +35,15 @@
|
||||
|
||||
<!-- 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: 4px;">
|
||||
<button type="button" id="btnSavePresetModal" class="icon-btn" title="Save current settings as Preset" style="font-size: 0.85rem; padding: 2px 4px; border: 1px solid var(--border-color, #333); border-radius: 4px; background: transparent; cursor: pointer;">💾</button>
|
||||
<button type="button" id="btnOpenPresetsDialog" class="icon-btn" title="Manage Presets (Import/Export/Delete)" style="font-size: 0.85rem; padding: 2px 4px; border: 1px solid var(--border-color, #333); border-radius: 4px; background: transparent; cursor: pointer;">⚙️</button>
|
||||
<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>
|
||||
<button type="button" id="btnOpenPresetsDialog" class="icon-btn btn-sm" title="Manage Presets (Import/Export/Delete)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<select id="presetSelect" style="width: 100%; font-size: 0.85rem; padding: 6px 8px; border-radius: 6px; background: var(--bg-card, #1a1a22); color: var(--text-color, #eee); border: 1px solid var(--border-color, #333); cursor: pointer;">
|
||||
@@ -65,13 +70,10 @@
|
||||
|
||||
<!-- Stage 1: targen -->
|
||||
<section id="stage-1" class="stage active">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<h2>Generate Target</h2>
|
||||
<p>Configure the colour patches for your profiling target.</p>
|
||||
</div>
|
||||
<button type="button" id="btnToggleAllHelp" class="secondary" style="font-size: 0.75rem; padding: 4px 8px;" title="Toggle tooltip hints for all options">💡 Show Tooltip Hints</button>
|
||||
</div>
|
||||
<h2 style="display:flex; justify-content:space-between; align-items:center;">
|
||||
Define Target
|
||||
<button type="button" id="btnToggleAllHelp" class="secondary btn-sm">Toggle Help Mode</button>
|
||||
</h2>
|
||||
|
||||
<div class="form-container" id="stage1FormContainer">
|
||||
<!-- Basic Settings -->
|
||||
@@ -129,11 +131,11 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group has-tooltip">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<label style="margin: 0;">Project Directory & Filename</label>
|
||||
<div>
|
||||
<button type="button" id="btn-import-dataset" class="secondary" style="font-size: 0.75rem; padding: 2px 8px; margin-right: 4px;" title="Import external dataset (.ti3, .csv) and jump to Profiling">📥 Import Dataset...</button>
|
||||
<button type="button" id="btnOpenExisting" class="secondary" style="font-size: 0.75rem; padding: 2px 8px;" title="Open existing .ti1 or .ti2 target file">📂 Open Target...</button>
|
||||
<div class="form-group-header">
|
||||
<label>Project Directory & Filename</label>
|
||||
<div class="btn-row-sm">
|
||||
<button type="button" id="btn-import-dataset" class="secondary btn-sm">Import Dataset...</button>
|
||||
<button type="button" id="btnOpenExisting" class="secondary btn-sm">Open Target (.ti2/.ti3)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-row has-btn">
|
||||
@@ -304,7 +306,9 @@
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<button class="primary" id="btnGenerate" disabled style="margin-top: 14px;">Generate (.ti1)</button>
|
||||
<div class="stage-actions">
|
||||
<button class="primary" id="btnGenerate" disabled>Generate (.ti1)</button>
|
||||
</div>
|
||||
|
||||
<details class="log-container hidden" id="targenLogContainer">
|
||||
<summary>Process Output</summary>
|
||||
@@ -403,9 +407,9 @@
|
||||
|
||||
<!-- Target Label & Metadata (printtarg -d) -->
|
||||
<div class="form-group" style="margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<label style="margin: 0; font-weight: 600;">Target Label & Metadata (Optional)</label>
|
||||
<button type="button" class="secondary" id="btnToggleLabelEdit" style="font-size: 0.75rem; padding: 2px 8px;">✏️ Edit Label</button>
|
||||
<div class="form-group-header">
|
||||
<label class="form-group-title">Target Label & Metadata (Optional)</label>
|
||||
<button type="button" class="secondary btn-sm" id="btnToggleLabelEdit">Edit Custom Field</button>
|
||||
</div>
|
||||
<p class="sub-label" style="margin-bottom: 10px;">Printed below test patches to identify hardware, ink, and media used for this profiling run.</p>
|
||||
|
||||
@@ -439,7 +443,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="primary" id="btnCreateLayout">Create Layout (.ti2 + .tif)</button>
|
||||
<div class="stage-actions">
|
||||
<button class="primary" id="btnCreateLayout">Create Layout (.ti2 + .tif)</button>
|
||||
</div>
|
||||
|
||||
<details class="log-container hidden" id="printtargLogContainer">
|
||||
<summary>Process Output</summary>
|
||||
@@ -475,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>
|
||||
@@ -498,6 +504,13 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group media-group" id="mediaTypeGroup">
|
||||
<label for="printerMediaTypeSelect">Media Type</label>
|
||||
<select id="printerMediaTypeSelect">
|
||||
<option value="" selected>Default / Auto Select</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group orientation-group">
|
||||
<label>Orientation</label>
|
||||
<div class="orientation-toggle-group">
|
||||
@@ -508,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>
|
||||
@@ -537,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>
|
||||
@@ -558,8 +571,10 @@
|
||||
<div class="chartread-actions">
|
||||
<button class="primary" id="btnStartRead">Start Measurement</button>
|
||||
<button class="primary hidden" id="btnCalibrate">✓ Calibrate</button>
|
||||
<button class="primary hidden" id="btnDoneRead">💾 Done & Save .ti3</button>
|
||||
<button class="primary hidden" id="btnAccept">✓ Accept Strip</button>
|
||||
<button class="secondary hidden" id="btnRetry">↻ Retry Strip</button>
|
||||
<button class="secondary hidden" id="btnUndo">↶ Undo Strip</button>
|
||||
<button class="secondary hidden" id="btnSkip">⏭ Skip Strip</button>
|
||||
<button class="danger hidden" id="btnCancel">✕ Cancel</button>
|
||||
</div>
|
||||
@@ -578,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 & Proceed →</button>
|
||||
</div>
|
||||
@@ -632,9 +647,106 @@
|
||||
<option value="m">Matrix Only (Simple)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<option value="none">None — Native M0 (No Compensation)</option>
|
||||
<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 has-tooltip" id="colprofCustomSpRow">
|
||||
<label>Custom Spectrum File (.sp)</label>
|
||||
<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">
|
||||
<summary>
|
||||
<span>⚙️ Advanced Spectral & Viewing Options</span>
|
||||
<span style="opacity:0.5; font-size:0.8rem;">▼</span>
|
||||
</summary>
|
||||
<div class="advanced-content">
|
||||
<div class="advanced-grid-2col">
|
||||
<div class="form-group has-tooltip">
|
||||
<label>Standard Illuminant</label>
|
||||
<select id="colprofIlluminant">
|
||||
<option value="" selected>Default (D50)</option>
|
||||
<option value="A">A</option>
|
||||
<option value="C">C</option>
|
||||
<option value="D50M2">D50M2</option>
|
||||
<option value="D65">D65</option>
|
||||
<option value="F5">F5</option>
|
||||
<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 has-tooltip">
|
||||
<label>Standard Observer</label>
|
||||
<select id="colprofObserver">
|
||||
<option value="" selected>Default (1931 2°)</option>
|
||||
<option value="1964_10">1964 10°</option>
|
||||
<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 has-tooltip">
|
||||
<label>Input Viewing Condition</label>
|
||||
<select id="colprofInputViewCond">
|
||||
<option value="none" selected>None</option>
|
||||
<option value="pc">Critical print evaluation (ISO-3664 P1)</option>
|
||||
<option value="pp">Practical Reflection Print (ISO-3664 P2)</option>
|
||||
<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 has-tooltip">
|
||||
<label>Output Viewing Condition</label>
|
||||
<select id="colprofOutputViewCond">
|
||||
<option value="none" selected>None</option>
|
||||
<option value="mt">Monitor in typical work environment</option>
|
||||
<option value="mb">Bright monitor in bright environment</option>
|
||||
<option value="md">Monitor in darkened environment</option>
|
||||
<option value="jm">Projector in dim environment</option>
|
||||
<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>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<button class="primary" id="btnCreateProfile">Calculate Profile (.icc)</button>
|
||||
<div class="stage-actions">
|
||||
<button class="primary btn-lg" id="btnCreateProfile">Calculate Profile (.icc)</button>
|
||||
</div>
|
||||
|
||||
<!-- Spinner and Progress Stage -->
|
||||
<div class="spinner-container hidden" id="colprofSpinnerContainer">
|
||||
@@ -646,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 -->
|
||||
@@ -661,7 +773,9 @@
|
||||
<h2>Verify Profile</h2>
|
||||
<p>Check the numerical accuracy of your profile against the original measurement data.</p>
|
||||
|
||||
<button class="primary" id="btnVerify">Run Profile Verification</button>
|
||||
<div class="stage-actions">
|
||||
<button class="primary btn-lg" id="btnVerify">Verify Profile Accuracy</button>
|
||||
</div>
|
||||
|
||||
<!-- Report Card -->
|
||||
<div class="report-card hidden" id="profcheckReportCard">
|
||||
@@ -692,8 +806,64 @@
|
||||
|
||||
<!-- 3D Gamut Viewer -->
|
||||
<div class="gamut-viewer-section">
|
||||
<h3>3D Gamut Visualization (CIELAB)</h3>
|
||||
<div id="gamutViewerContainer"></div>
|
||||
<div class="gamut-viewer-header">
|
||||
<h3>3D Gamut Visualisation <span class="gamut-viewer-subtitle">(CIELAB L* a* b*)</span></h3>
|
||||
<p class="gamut-viewer-hint">Drag to rotate • Scroll to zoom • Right-drag to pan</p>
|
||||
</div>
|
||||
|
||||
<div class="gamut-viewer-wrap" id="gamutViewerWrap">
|
||||
<!-- Three.js canvas is appended here by gamut_viewer.js -->
|
||||
<div id="gamutViewerContainer"></div>
|
||||
|
||||
<!-- Legend & layer controls overlay -->
|
||||
<div class="gamut-controls-panel" id="gamutControlsPanel">
|
||||
<div class="gamut-legend-title">Layers</div>
|
||||
|
||||
<div class="gamut-legend-item">
|
||||
<span class="gamut-swatch gamut-swatch-profile"></span>
|
||||
<span class="gamut-legend-label">Profile Gamut</span>
|
||||
<label class="gamut-toggle" title="Show / hide profile gamut">
|
||||
<input type="checkbox" id="chkProfileGamut" checked>
|
||||
<span class="gamut-toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="gamut-legend-item gamut-slider-item">
|
||||
<label for="rngProfileOpacity" class="gamut-slider-label">Opacity</label>
|
||||
<input type="range" id="rngProfileOpacity" min="0.1" max="1" step="0.05" value="0.88">
|
||||
</div>
|
||||
|
||||
<div class="gamut-legend-item">
|
||||
<span class="gamut-swatch gamut-swatch-srgb"></span>
|
||||
<span class="gamut-legend-label">sRGB Reference</span>
|
||||
<label class="gamut-toggle" title="Show / hide sRGB reference">
|
||||
<input type="checkbox" id="chkSrgbReference" checked>
|
||||
<span class="gamut-toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="gamut-legend-item gamut-slider-item">
|
||||
<label for="rngSrgbOpacity" class="gamut-slider-label">Opacity</label>
|
||||
<input type="range" id="rngSrgbOpacity" min="0.1" max="1" step="0.05" value="0.55">
|
||||
</div>
|
||||
|
||||
<div class="gamut-legend-item">
|
||||
<span class="gamut-swatch gamut-swatch-axes"></span>
|
||||
<span class="gamut-legend-label">CIELAB Axes</span>
|
||||
<label class="gamut-toggle" title="Show / hide CIELAB axis scaffold">
|
||||
<input type="checkbox" id="chkLabAxes" checked>
|
||||
<span class="gamut-toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="gamut-legend-item gamut-slider-item">
|
||||
<label for="rngAxisOpacity" class="gamut-slider-label">Opacity</label>
|
||||
<input type="range" id="rngAxisOpacity" min="0.1" max="1" step="0.05" value="0.8">
|
||||
</div>
|
||||
|
||||
<div class="gamut-controls-actions">
|
||||
<button type="button" id="btnGamutResetCamera" class="btn-sm" title="Reset camera to default view (R)">⟲ Reset View</button>
|
||||
<span class="gamut-keyboard-hint">Press R to reset</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Process Log -->
|
||||
@@ -713,10 +883,37 @@
|
||||
<label for="argyll_binary_dir">ArgyllCMS Binary Directory Override</label>
|
||||
<input type="text" id="argyll_binary_dir" placeholder="Leave empty for bundled sidecars (e.g. /usr/bin)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="default_instrument">Default Instrument Override</label>
|
||||
<input type="text" id="default_instrument" placeholder="e.g. i1">
|
||||
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
||||
<label style="font-weight: 600;">Instrument & Measurement Preferences</label>
|
||||
<div style="margin-top: 8px;">
|
||||
<label for="default_instrument" class="sub-label">Default Instrument Override</label>
|
||||
<input type="text" id="default_instrument" placeholder="e.g. i1">
|
||||
</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<label class="checkbox-label" for="enable_i1pro2_leds" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" id="enable_i1pro2_leds">
|
||||
<span>Enable i1Pro 2 status LEDs (<code>-Y l</code>)</span>
|
||||
</label>
|
||||
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">Provides visual status feedback via the instrument ring LEDs during patch reading. Requires patched ArgyllCMS build.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
||||
<label style="font-weight: 600;">Stage 3 ΔE₀₀ Traffic-Light Thresholds</label>
|
||||
<div class="input-row" style="margin-top: 8px; align-items: flex-start;">
|
||||
<div>
|
||||
<label for="deltaEGoodMax" class="sub-label">Good / Warning boundary</label>
|
||||
<input type="number" id="deltaEGoodMax" min="0" step="0.1" value="2.0">
|
||||
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">ΔE below this value is shown as green (Good).</small>
|
||||
</div>
|
||||
<div>
|
||||
<label for="deltaEWarningMax" class="sub-label">Warning / Bad boundary</label>
|
||||
<input type="number" id="deltaEWarningMax" min="0" step="0.1" value="5.0">
|
||||
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">ΔE above this value is shown as red (Bad).</small>
|
||||
</div>
|
||||
</div>
|
||||
<small id="deltaEThresholdError" class="help-hint" style="display:none; color: var(--error-color, #ff5f5f); margin-top: 6px;">Good threshold must be less than warning threshold.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
||||
<label style="font-weight: 600;">Diagnostics & Logging</label>
|
||||
<div class="input-row" style="margin-top: 8px;">
|
||||
@@ -731,7 +928,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>
|
||||
@@ -753,7 +950,7 @@
|
||||
<img src="./assets/ICCery-logo.svg" alt="ICCery Logo" class="about-logo" />
|
||||
</div>
|
||||
<h2>About ICCery</h2>
|
||||
<p><strong>Version:</strong> <span id="aboutVersion">v0.3.2</span> • <strong>Build Date:</strong> <span id="aboutBuildDate">August 2026</span></p>
|
||||
<p><strong>Version:</strong> <span id="aboutVersion">v0.8.1</span> • <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
|
||||
<p><strong>Copyright © 2026 Gordon Bolton. All rights reserved.</strong></p>
|
||||
<h3>Licences & EULA</h3>
|
||||
<div class="license-text-container">
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* CSS2DRenderer - Three.js addon
|
||||
* Adapted from three.js examples/jsm/renderers/CSS2DRenderer.js
|
||||
* Modified to use global THREE object (IIFE pattern) for compatibility
|
||||
* with projects using three.min.js as a script tag (not ES module).
|
||||
*
|
||||
* Source: https://github.com/mrdoob/three.js
|
||||
* License: MIT
|
||||
*/
|
||||
( function () {
|
||||
|
||||
const _vector = new THREE.Vector3();
|
||||
const _viewMatrix = new THREE.Matrix4();
|
||||
const _viewProjectionMatrix = new THREE.Matrix4();
|
||||
const _a = new THREE.Vector3();
|
||||
const _b = new THREE.Vector3();
|
||||
|
||||
class CSS2DObject extends THREE.Object3D {
|
||||
|
||||
constructor( element = document.createElement( 'div' ) ) {
|
||||
super();
|
||||
this.isCSS2DObject = true;
|
||||
this.element = element;
|
||||
this.element.style.position = 'absolute';
|
||||
this.element.style.userSelect = 'none';
|
||||
this.element.setAttribute( 'draggable', false );
|
||||
this.center = new THREE.Vector2( 0.5, 0.5 );
|
||||
this.rotation2D = 0;
|
||||
|
||||
this.addEventListener( 'removed', function () {
|
||||
this.traverse( function ( object ) {
|
||||
if (
|
||||
object.element &&
|
||||
object.element instanceof object.element.ownerDocument.defaultView.Element &&
|
||||
object.element.parentNode !== null
|
||||
) {
|
||||
object.element.remove();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
copy( source, recursive ) {
|
||||
super.copy( source, recursive );
|
||||
this.element = source.element.cloneNode( true );
|
||||
this.center = source.center;
|
||||
this.rotation2D = source.rotation2D;
|
||||
return this;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class CSS2DRenderer {
|
||||
|
||||
constructor( parameters = {} ) {
|
||||
const domElement = parameters.element !== undefined
|
||||
? parameters.element
|
||||
: document.createElement( 'div' );
|
||||
|
||||
domElement.style.overflow = 'hidden';
|
||||
this.domElement = domElement;
|
||||
|
||||
this._width = 0;
|
||||
this._height = 0;
|
||||
this._widthHalf = 0;
|
||||
this._heightHalf = 0;
|
||||
|
||||
const cache = { objects: new WeakMap() };
|
||||
|
||||
this.getSize = function () {
|
||||
return { width: this._width, height: this._height };
|
||||
};
|
||||
|
||||
this.render = function ( scene, camera ) {
|
||||
if ( scene.matrixWorldAutoUpdate === true ) scene.updateMatrixWorld();
|
||||
if ( camera.parent === null && camera.matrixWorldAutoUpdate === true ) camera.updateMatrixWorld();
|
||||
_viewMatrix.copy( camera.matrixWorldInverse );
|
||||
_viewProjectionMatrix.multiplyMatrices( camera.projectionMatrix, _viewMatrix );
|
||||
this.renderObject( scene, scene, camera );
|
||||
this.zOrder( scene );
|
||||
};
|
||||
|
||||
this.renderObject = function ( object, scene, camera ) {
|
||||
if ( object.isCSS2DObject ) {
|
||||
_vector.setFromMatrixPosition( object.matrixWorld );
|
||||
_vector.applyMatrix4( _viewProjectionMatrix );
|
||||
const visible = ( object.visible === true ) &&
|
||||
( _vector.z >= -1 && _vector.z <= 1 ) &&
|
||||
( object.layers.test( camera.layers ) === true );
|
||||
const element = object.element;
|
||||
element.style.display = ( visible === true ) ? '' : 'none';
|
||||
if ( visible === true ) {
|
||||
object.onBeforeRender( this, scene, camera );
|
||||
const style = 'translate(' +
|
||||
( -100 * object.center.x ) + '%,' +
|
||||
( -100 * object.center.y ) + '%) ' +
|
||||
'translate(' +
|
||||
( _vector.x * this._widthHalf + this._widthHalf ) + 'px,' +
|
||||
( -_vector.y * this._heightHalf + this._heightHalf ) + 'px)';
|
||||
element.style.webkitTransform = style;
|
||||
element.style.MozTransform = style;
|
||||
element.style.oTransform = style;
|
||||
element.style.transform = style;
|
||||
if ( element.parentNode !== this.domElement ) {
|
||||
this.domElement.appendChild( element );
|
||||
}
|
||||
object.onAfterRender( this, scene, camera );
|
||||
}
|
||||
cache.objects.set( object, {
|
||||
distanceToCameraSquared: this.getDistanceToSquared( camera, object )
|
||||
} );
|
||||
}
|
||||
for ( let i = 0, l = object.children.length; i < l; i++ ) {
|
||||
this.renderObject( object.children[ i ], scene, camera );
|
||||
}
|
||||
};
|
||||
|
||||
this.getDistanceToSquared = function ( object1, object2 ) {
|
||||
_a.setFromMatrixPosition( object1.matrixWorld );
|
||||
_b.setFromMatrixPosition( object2.matrixWorld );
|
||||
return _a.distanceToSquared( _b );
|
||||
};
|
||||
|
||||
this.filterAndFlatten = function ( scene ) {
|
||||
const result = [];
|
||||
scene.traverse( function ( object ) {
|
||||
if ( object.isCSS2DObject ) result.push( object );
|
||||
} );
|
||||
return result;
|
||||
};
|
||||
|
||||
this.zOrder = function ( scene ) {
|
||||
const sorted = this.filterAndFlatten( scene ).sort( ( a, b ) => {
|
||||
if ( a.renderOrder !== b.renderOrder ) return b.renderOrder - a.renderOrder;
|
||||
const dA = cache.objects.get( a ).distanceToCameraSquared;
|
||||
const dB = cache.objects.get( b ).distanceToCameraSquared;
|
||||
return dA - dB;
|
||||
} );
|
||||
const zMax = sorted.length;
|
||||
for ( let i = 0, l = sorted.length; i < l; i++ ) {
|
||||
sorted[ i ].element.style.zIndex = ( zMax - i ).toString();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setSize( width, height ) {
|
||||
this._width = width;
|
||||
this._height = height;
|
||||
this._widthHalf = this._width / 2;
|
||||
this._heightHalf = this._height / 2;
|
||||
this.domElement.style.width = width + 'px';
|
||||
this.domElement.style.height = height + 'px';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Expose on global THREE object
|
||||
THREE.CSS2DObject = CSS2DObject;
|
||||
THREE.CSS2DRenderer = CSS2DRenderer;
|
||||
|
||||
} )();
|
||||
+94
-5
@@ -50,6 +50,7 @@ const STATE = {
|
||||
CALIBRATING: "CALIBRATING",
|
||||
AWAITING_STRIP: "AWAITING_STRIP",
|
||||
READING: "READING",
|
||||
ALL_STRIPS_READ: "ALL_STRIPS_READ",
|
||||
WARNING: "WARNING",
|
||||
PROMPT_CONTINUE: "PROMPT_CONTINUE",
|
||||
ERROR: "ERROR",
|
||||
@@ -65,8 +66,10 @@ const recordedPasses = [];
|
||||
export function initChartread() {
|
||||
const btnStartRead = document.getElementById("btnStartRead");
|
||||
const btnCalibrate = document.getElementById("btnCalibrate");
|
||||
const btnDoneRead = document.getElementById("btnDoneRead");
|
||||
const btnAccept = document.getElementById("btnAccept");
|
||||
const btnRetry = document.getElementById("btnRetry");
|
||||
const btnUndo = document.getElementById("btnUndo");
|
||||
const btnSkip = document.getElementById("btnSkip");
|
||||
const btnCancel = document.getElementById("btnCancel");
|
||||
const btnDetectInstruments = document.getElementById("btnDetectInstruments");
|
||||
@@ -205,8 +208,10 @@ export function initChartread() {
|
||||
|
||||
// Show/hide buttons based on state
|
||||
if (btnCalibrate) btnCalibrate.classList.add("hidden");
|
||||
if (btnDoneRead) btnDoneRead.classList.add("hidden");
|
||||
if (btnAccept) btnAccept.classList.add("hidden");
|
||||
if (btnRetry) btnRetry.classList.add("hidden");
|
||||
if (btnUndo) btnUndo.classList.add("hidden");
|
||||
if (btnSkip) btnSkip.classList.add("hidden");
|
||||
if (btnCancel) btnCancel.classList.add("hidden");
|
||||
if (btnStartRead) btnStartRead.classList.add("hidden");
|
||||
@@ -229,12 +234,38 @@ export function initChartread() {
|
||||
btnRetry.textContent = "↻ Retry Strip";
|
||||
btnRetry.classList.remove("hidden");
|
||||
}
|
||||
if (btnUndo) {
|
||||
btnUndo.disabled = false;
|
||||
btnUndo.classList.remove("hidden");
|
||||
}
|
||||
if (btnSkip) btnSkip.classList.remove("hidden");
|
||||
if (btnDoneRead) {
|
||||
btnDoneRead.disabled = false;
|
||||
btnDoneRead.textContent = "💾 Done & Save .ti3";
|
||||
btnDoneRead.classList.remove("hidden");
|
||||
}
|
||||
if (btnCancel) btnCancel.classList.remove("hidden");
|
||||
break;
|
||||
case STATE.READING:
|
||||
if (btnCancel) btnCancel.classList.remove("hidden");
|
||||
break;
|
||||
case STATE.ALL_STRIPS_READ:
|
||||
if (btnDoneRead) {
|
||||
btnDoneRead.disabled = false;
|
||||
btnDoneRead.textContent = "💾 Done & Save .ti3";
|
||||
btnDoneRead.classList.remove("hidden");
|
||||
}
|
||||
if (btnRetry) {
|
||||
btnRetry.disabled = false;
|
||||
btnRetry.textContent = "↻ Re-read Last Strip";
|
||||
btnRetry.classList.remove("hidden");
|
||||
}
|
||||
if (btnUndo) {
|
||||
btnUndo.disabled = false;
|
||||
btnUndo.classList.remove("hidden");
|
||||
}
|
||||
if (btnCancel) btnCancel.classList.remove("hidden");
|
||||
break;
|
||||
case STATE.WARNING:
|
||||
if (btnAccept) {
|
||||
btnAccept.disabled = false;
|
||||
@@ -262,6 +293,10 @@ export function initChartread() {
|
||||
btnRetry.textContent = "↻ Retry Strip";
|
||||
btnRetry.classList.remove("hidden");
|
||||
}
|
||||
if (btnUndo) {
|
||||
btnUndo.disabled = false;
|
||||
btnUndo.classList.remove("hidden");
|
||||
}
|
||||
if (btnSkip) btnSkip.classList.remove("hidden");
|
||||
if (btnCancel) btnCancel.classList.remove("hidden");
|
||||
break;
|
||||
@@ -314,8 +349,13 @@ export function initChartread() {
|
||||
|
||||
currentProcessId = `chartread_${basename}`;
|
||||
|
||||
// Start swatch grid listener
|
||||
await startSwatchListener(currentProcessId);
|
||||
// Start swatch grid listener with completion callback
|
||||
await startSwatchListener(currentProcessId, ({ rowIndex, totalRows, rowId, isAllComplete }) => {
|
||||
if (isAllComplete && currentState !== STATE.FINISHED) {
|
||||
setState(STATE.ALL_STRIPS_READ);
|
||||
setPrompt(`🎉 All ${totalRows} strips measured! Click 'Done & Save .ti3' to write measurements and finish.`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const unlistenStdout = await listen("process:stdout", (event) => {
|
||||
@@ -329,6 +369,19 @@ export function initChartread() {
|
||||
const lineLower = line.toLowerCase();
|
||||
|
||||
if (
|
||||
lineLower.includes("'d' if done") ||
|
||||
lineLower.includes("'d' when done") ||
|
||||
lineLower.includes("d if done") ||
|
||||
lineLower.includes("d when done") ||
|
||||
lineLower.includes("d to finish") ||
|
||||
lineLower.includes("d to save") ||
|
||||
lineLower.includes("all strips read") ||
|
||||
lineLower.includes("all patches read") ||
|
||||
lineLower.includes("done reading")
|
||||
) {
|
||||
setState(STATE.ALL_STRIPS_READ);
|
||||
setPrompt(`🎉 ${line.trim()} — Click 'Done & Save .ti3' to save.`);
|
||||
} else if (
|
||||
lineLower.includes("(warning)") ||
|
||||
lineLower.includes("use it anyway") ||
|
||||
lineLower.includes("seem to have read strip pass") ||
|
||||
@@ -373,8 +426,11 @@ export function initChartread() {
|
||||
}
|
||||
});
|
||||
|
||||
let lastStderrLine = "";
|
||||
|
||||
const unlistenStderr = await listen("process:stderr", (event) => {
|
||||
if (event.payload.id === currentProcessId && event.payload.line) {
|
||||
lastStderrLine = event.payload.line;
|
||||
logPre.textContent += "ERR: " + event.payload.line + "\n";
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
}
|
||||
@@ -393,7 +449,7 @@ export function initChartread() {
|
||||
const filename = await invoke("snapshot_ti3", {
|
||||
cwd: cwd,
|
||||
basename: basename,
|
||||
pass_index: passIndex,
|
||||
passIndex: passIndex,
|
||||
});
|
||||
|
||||
currentPassIndex = passIndex;
|
||||
@@ -418,8 +474,10 @@ export function initChartread() {
|
||||
}
|
||||
} else {
|
||||
setState(STATE.FINISHED);
|
||||
setPrompt(`❌ chartread exited with code ${event.payload.code}.`);
|
||||
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`;
|
||||
if (logContainer) logContainer.open = true;
|
||||
const errDetail = lastStderrLine ? ` (${lastStderrLine})` : "";
|
||||
setPrompt(`❌ chartread exited with code ${event.payload.code}.${errDetail}`);
|
||||
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.${errDetail}\n`;
|
||||
}
|
||||
|
||||
setMeasurementBusy(false);
|
||||
@@ -578,6 +636,37 @@ export function initChartread() {
|
||||
});
|
||||
}
|
||||
|
||||
// Done & Save button — sends "d\n"
|
||||
if (btnDoneRead) {
|
||||
btnDoneRead.addEventListener("click", async () => {
|
||||
try {
|
||||
btnDoneRead.disabled = true;
|
||||
btnDoneRead.textContent = "⏳ Saving .ti3...";
|
||||
setPrompt("Saving measurement data to .ti3 (sending done signal to chartread)...");
|
||||
await invoke("send_stdin", { id: currentProcessId, input: "d\n" });
|
||||
} catch (e) {
|
||||
console.error("send_stdin error for done:", e);
|
||||
btnDoneRead.disabled = false;
|
||||
btnDoneRead.textContent = "💾 Done & Save .ti3";
|
||||
setPrompt(`Failed to send done signal: ${e}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Undo button — sends "u\n"
|
||||
if (btnUndo) {
|
||||
btnUndo.addEventListener("click", async () => {
|
||||
try {
|
||||
await invoke("send_stdin", { id: currentProcessId, input: "u\n" });
|
||||
setState(STATE.READING);
|
||||
setPrompt("Undoing previous strip measurement. Preparing to re-read...");
|
||||
} catch (e) {
|
||||
console.error("send_stdin error for undo:", e);
|
||||
setPrompt(`Failed to send undo signal: ${e}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Skip button — sends "s\n"
|
||||
if (btnSkip) {
|
||||
btnSkip.addEventListener("click", async () => {
|
||||
|
||||
@@ -65,3 +65,11 @@ export function labToCss(lab) {
|
||||
const [r, g, b] = labToSrgb(lab[0], lab[1], lab[2]);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
export function deviceCmykToCss(device) {
|
||||
const c = device[0] / 100, m = device[1] / 100, y = device[2] / 100, k = device[3] / 100;
|
||||
const r = Math.round(255 * (1 - c) * (1 - k));
|
||||
const g = Math.round(255 * (1 - m) * (1 - k));
|
||||
const b = Math.round(255 * (1 - y) * (1 - k));
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
@@ -30,9 +30,42 @@ export function initColprof() {
|
||||
const successCard = document.getElementById("colprofSuccessCard");
|
||||
const successInfo = document.getElementById("colprofSuccessInfo");
|
||||
const btnGoToVerify = document.getElementById("btnGoToVerify");
|
||||
const colprofFwa = document.getElementById("colprofFwa");
|
||||
const colprofCustomSpRow = document.getElementById("colprofCustomSpRow");
|
||||
const colprofCustomSpPath = document.getElementById("colprofCustomSpPath");
|
||||
const btnBrowseCustomSp = document.getElementById("btnBrowseCustomSp");
|
||||
const colprofIlluminant = document.getElementById("colprofIlluminant");
|
||||
const colprofObserver = document.getElementById("colprofObserver");
|
||||
const colprofInputViewCond = document.getElementById("colprofInputViewCond");
|
||||
const colprofOutputViewCond = document.getElementById("colprofOutputViewCond");
|
||||
|
||||
if (!btnCreateProfile) return;
|
||||
|
||||
if (colprofFwa && colprofCustomSpRow) {
|
||||
colprofFwa.addEventListener("change", () => {
|
||||
if (colprofFwa.value === "custom") {
|
||||
colprofCustomSpRow.classList.remove("hidden");
|
||||
} else {
|
||||
colprofCustomSpRow.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (btnBrowseCustomSp && colprofCustomSpPath) {
|
||||
btnBrowseCustomSp.addEventListener("click", async () => {
|
||||
try {
|
||||
const selected = await window.__TAURI__.dialog.open({
|
||||
filters: [{ name: 'Spectrum', extensions: ['sp'] }]
|
||||
});
|
||||
if (selected) {
|
||||
colprofCustomSpPath.value = selected;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to open dialog:", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
btnCreateProfile.addEventListener("click", async () => {
|
||||
const basename = chartreadBasename || wizardState.basename;
|
||||
const cwd = chartreadCwd || wizardState.cwd;
|
||||
@@ -65,6 +98,11 @@ export function initColprof() {
|
||||
copyright: copyrightInput.value.trim() || null,
|
||||
basename: basename,
|
||||
cwd: cwd,
|
||||
fwa: colprofFwa.value === "custom" ? (colprofCustomSpPath.value || "none") : colprofFwa.value,
|
||||
illuminant: colprofIlluminant.value || null,
|
||||
observer: colprofObserver.value || null,
|
||||
input_viewing_cond: colprofInputViewCond.value !== "none" ? colprofInputViewCond.value : null,
|
||||
output_viewing_cond: colprofOutputViewCond.value !== "none" ? colprofOutputViewCond.value : null,
|
||||
};
|
||||
|
||||
const processId = `colprof_${basename}`;
|
||||
|
||||
+481
-82
@@ -1,72 +1,89 @@
|
||||
import { computeQuickHull } from "./vendor/quickhull.js";
|
||||
import { computeQuickHull } from "./vendor/quickhull.js";
|
||||
import { labToSrgb } from "./color_convert.js";
|
||||
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
|
||||
let scene, camera, renderer, controls;
|
||||
let scene, camera, renderer, labelRenderer, controls;
|
||||
let currentProfileMesh = null;
|
||||
let sRgbMesh = null;
|
||||
let sRgbGroup = null;
|
||||
let axisScaffoldGroup = null;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public: initialise the gamut viewer
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function initGamutViewer() {
|
||||
try {
|
||||
const container = document.getElementById('gamutViewerContainer');
|
||||
if (!container || typeof THREE === 'undefined') return;
|
||||
|
||||
// Clear any existing contents if re-initialized
|
||||
// Clear any existing contents if re-initialised
|
||||
container.innerHTML = "";
|
||||
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x111116);
|
||||
scene.background = new THREE.Color(0x0e0e14);
|
||||
|
||||
const width = container.clientWidth > 0 ? container.clientWidth : 500;
|
||||
const height = container.clientHeight > 0 ? container.clientHeight : 360;
|
||||
const width = container.clientWidth > 0 ? container.clientWidth : 500;
|
||||
const height = container.clientHeight > 0 ? container.clientHeight : 400;
|
||||
|
||||
camera = new THREE.PerspectiveCamera(45, width / height, 1, 1000);
|
||||
camera.position.set(150, 110, 150);
|
||||
// ── WebGL renderer ────────────────────────────────────────────────────
|
||||
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000);
|
||||
camera.position.set(180, 120, 180);
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(window.devicePixelRatio || 1);
|
||||
renderer.domElement.style.touchAction = 'none';
|
||||
renderer.domElement.style.display = 'block';
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// ── CSS2D label renderer ──────────────────────────────────────────────
|
||||
if (typeof THREE.CSS2DRenderer !== 'undefined') {
|
||||
labelRenderer = new THREE.CSS2DRenderer();
|
||||
labelRenderer.setSize(width, height);
|
||||
labelRenderer.domElement.style.position = 'absolute';
|
||||
labelRenderer.domElement.style.top = '0';
|
||||
labelRenderer.domElement.style.left = '0';
|
||||
labelRenderer.domElement.style.pointerEvents = 'none';
|
||||
container.appendChild(labelRenderer.domElement);
|
||||
}
|
||||
|
||||
// ── Orbit controls ────────────────────────────────────────────────────
|
||||
if (typeof THREE.OrbitControls !== 'undefined') {
|
||||
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.target.set(0, 50, 0); // orbit around the centre of L*=50
|
||||
controls.update();
|
||||
controls.touches = {
|
||||
ONE: THREE.TOUCH.ROTATE,
|
||||
TWO: THREE.TOUCH.DOLLY_PAN
|
||||
};
|
||||
}
|
||||
|
||||
// Add CIELAB orientation helpers (Axes: X=a*, Y=L*, Z=b*)
|
||||
const axesHelper = new THREE.AxesHelper(100);
|
||||
scene.add(axesHelper);
|
||||
|
||||
// Add grid helper at L*=0 plane
|
||||
const gridHelper = new THREE.GridHelper(200, 20, 0x444455, 0x222233);
|
||||
gridHelper.position.y = 0;
|
||||
scene.add(gridHelper);
|
||||
|
||||
// Lights
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
|
||||
// ── Lighting ──────────────────────────────────────────────────────────
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
|
||||
scene.add(ambientLight);
|
||||
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
dirLight1.position.set(100, 200, 100);
|
||||
dirLight1.position.set(150, 250, 150);
|
||||
scene.add(dirLight1);
|
||||
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||
dirLight2.position.set(-100, -100, -100);
|
||||
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.35);
|
||||
dirLight2.position.set(-120, -80, -120);
|
||||
scene.add(dirLight2);
|
||||
|
||||
// Handle viewport resize dynamically (e.g., when switching to Stage 5 tab)
|
||||
// ── CIELAB axis scaffold + labels ─────────────────────────────────────
|
||||
buildAxisScaffold();
|
||||
|
||||
// ── Resize handling ───────────────────────────────────────────────────
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (let entry of entries) {
|
||||
for (const entry of entries) {
|
||||
const w = entry.contentRect.width;
|
||||
const h = entry.contentRect.height;
|
||||
if (w > 0 && h > 0 && renderer && camera) {
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
if (labelRenderer) labelRenderer.setSize(w, h);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -74,77 +91,308 @@ export async function initGamutViewer() {
|
||||
|
||||
animate();
|
||||
|
||||
// Load bundled sRGB reference gamut wireframe on startup
|
||||
// ── Wire toggle controls ──────────────────────────────────────────────
|
||||
_wireToggles();
|
||||
|
||||
// ── Load bundled sRGB reference gamut ─────────────────────────────────
|
||||
loadSrgbReferenceGamut();
|
||||
|
||||
} catch (err) {
|
||||
console.warn("Gamut viewer initialization notice:", err);
|
||||
console.warn("Gamut viewer initialisation notice:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Animation loop
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
if (controls) controls.update();
|
||||
if (renderer && scene && camera) renderer.render(scene, camera);
|
||||
if (renderer && scene && camera) {
|
||||
renderer.render(scene, camera);
|
||||
if (labelRenderer) labelRenderer.render(scene, camera);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCGATS(text) {
|
||||
const lines = text.split('\n');
|
||||
let dataStarted = false;
|
||||
const points = [];
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Build custom CIELAB axis scaffold (replaces THREE.AxesHelper)
|
||||
// CIELAB mapping: X = a* (−128 → +128), Y = L* (0 → 100), Z = b* (−128 → +128)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function buildAxisScaffold() {
|
||||
axisScaffoldGroup = new THREE.Group();
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('BEGIN_DATA')) {
|
||||
const AXIS_MAT_L = new THREE.LineBasicMaterial({ color: 0xcccccc, transparent: true, opacity: 0.7 });
|
||||
const AXIS_MAT_A = new THREE.LineBasicMaterial({ color: 0x99bbcc, transparent: true, opacity: 0.7 });
|
||||
const AXIS_MAT_B = new THREE.LineBasicMaterial({ color: 0x99bbcc, transparent: true, opacity: 0.7 });
|
||||
const TICK_MAT = new THREE.LineBasicMaterial({ color: 0x556677, transparent: true, opacity: 0.8 });
|
||||
const BOX_MAT = new THREE.LineBasicMaterial({ color: 0x232336, transparent: true, opacity: 0.9 });
|
||||
const GRID_MAT = new THREE.LineBasicMaterial({ color: 0x1e1e2e });
|
||||
|
||||
// ── Bounding box (full CIELAB domain: a*/b* ±128, L* 0→100) ──────────────
|
||||
const boxGeo = new THREE.BoxGeometry(256, 100, 256);
|
||||
const edgesGeo = new THREE.EdgesGeometry(boxGeo);
|
||||
const boxLines = new THREE.LineSegments(edgesGeo, BOX_MAT);
|
||||
boxLines.position.set(0, 50, 0); // centre at a*=0, L*=50, b*=0
|
||||
axisScaffoldGroup.add(boxLines);
|
||||
|
||||
// ── Ground plane grid at L*=0 (a*/b* plane) ──────────────────────────────
|
||||
const gridHelper = new THREE.GridHelper(256, 16, 0x1e1e2e, 0x1a1a28);
|
||||
gridHelper.position.y = 0;
|
||||
axisScaffoldGroup.add(gridHelper);
|
||||
|
||||
// ── L* axis line (vertical, Y): 0 → 100 ──────────────────────────────────
|
||||
axisScaffoldGroup.add(_line([0, 0, 0], [0, 100, 0], AXIS_MAT_L));
|
||||
|
||||
// ── a* axis line (X): −128 → +128 ────────────────────────────────────────
|
||||
axisScaffoldGroup.add(_line([-128, 0, 0], [128, 0, 0], AXIS_MAT_A));
|
||||
|
||||
// ── b* axis line (Z): −128 → +128 ────────────────────────────────────────
|
||||
axisScaffoldGroup.add(_line([0, 0, -128], [0, 0, 128], AXIS_MAT_B));
|
||||
|
||||
// ── Tick marks — L* axis (y direction) ───────────────────────────────────
|
||||
for (const v of [25, 50, 75, 100]) {
|
||||
axisScaffoldGroup.add(_line([-4, v, 0], [4, v, 0], TICK_MAT));
|
||||
axisScaffoldGroup.add(_line([0, v, -4], [0, v, 4], TICK_MAT));
|
||||
}
|
||||
|
||||
// ── Tick marks — a* axis (x direction) ───────────────────────────────────
|
||||
for (const v of [-128, -64, 64, 128]) {
|
||||
axisScaffoldGroup.add(_line([v, -3, 0], [v, 3, 0], TICK_MAT));
|
||||
}
|
||||
|
||||
// ── Tick marks — b* axis (z direction) ───────────────────────────────────
|
||||
for (const v of [-128, -64, 64, 128]) {
|
||||
axisScaffoldGroup.add(_line([0, -3, v], [0, 3, v], TICK_MAT));
|
||||
}
|
||||
|
||||
scene.add(axisScaffoldGroup);
|
||||
|
||||
// ── CSS2D axis labels ─────────────────────────────────────────────────────
|
||||
if (typeof THREE.CSS2DObject !== 'undefined') {
|
||||
const labels = [
|
||||
// Primary axis name labels
|
||||
{ text: 'L*', pos: [0, 110, 0 ], cls: 'gamut-axis-label gamut-axis-primary' },
|
||||
{ text: '+a* (Red →)', pos: [140, 0, 0 ], cls: 'gamut-axis-label gamut-axis-a-pos' },
|
||||
{ text: '← −a* (Green)', pos: [-140, 0, 0 ], cls: 'gamut-axis-label gamut-axis-a-neg' },
|
||||
{ text: '+b* (Yellow →)', pos: [0, 0, 140], cls: 'gamut-axis-label gamut-axis-b-pos' },
|
||||
{ text: '← −b* (Blue)', pos: [0, 0, -140], cls: 'gamut-axis-label gamut-axis-b-neg' },
|
||||
// Tick value labels on L* axis
|
||||
{ text: '25', pos: [10, 25, 0], cls: 'gamut-axis-label gamut-tick-label' },
|
||||
{ text: '50', pos: [10, 50, 0], cls: 'gamut-axis-label gamut-tick-label' },
|
||||
{ text: '75', pos: [10, 75, 0], cls: 'gamut-axis-label gamut-tick-label' },
|
||||
{ text: '100', pos: [10, 100, 0], cls: 'gamut-axis-label gamut-tick-label' },
|
||||
];
|
||||
|
||||
for (const { text, pos, cls } of labels) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
div.className = cls;
|
||||
const label = new THREE.CSS2DObject(div);
|
||||
label.position.set(...pos);
|
||||
axisScaffoldGroup.add(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: create a THREE.Line between two points
|
||||
function _line(from, to, material) {
|
||||
const geo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(...from),
|
||||
new THREE.Vector3(...to)
|
||||
]);
|
||||
return new THREE.Line(geo, material);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Parse an Argyll `.gam` text file into vertex and face arrays.
|
||||
*
|
||||
* Argyll `.gam` files contain a header followed by one or more `BEGIN_DATA`
|
||||
* ... `END_DATA` blocks. The first data block is a vertex list
|
||||
* (index L a b); subsequent blocks contain triangle face indices (v0 v1 v2).
|
||||
* Blank lines and hash `#` comments outside data blocks are ignored.
|
||||
*
|
||||
* @param {string} text - Raw contents of the .gam file.
|
||||
* @returns {{ vertices: number[][], faces: number[][], warnings: string[] }}
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export function parseGamutFile(text) {
|
||||
const lines = text.split('\n');
|
||||
const vertices = [];
|
||||
const faces = [];
|
||||
const warnings = [];
|
||||
let dataStarted = false;
|
||||
let dataBlock = 0; // 1 = vertices section, 2+ = faces sections
|
||||
|
||||
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
||||
const raw = lines[lineIdx];
|
||||
const trimmed = raw.replace(/#.*$/, '').trim(); // strip inline comments
|
||||
if (trimmed === '') continue;
|
||||
|
||||
if (trimmed.toUpperCase() === 'BEGIN_DATA') {
|
||||
dataBlock++;
|
||||
dataStarted = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('END_DATA')) break;
|
||||
if (trimmed.toUpperCase() === 'END_DATA') {
|
||||
dataStarted = false;
|
||||
continue;
|
||||
}
|
||||
if (!dataStarted) continue;
|
||||
|
||||
if (dataStarted) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const parts = trimmed.split(/\s+/).map(Number);
|
||||
const allNumeric = parts.every(n => !Number.isNaN(n));
|
||||
if (!allNumeric) {
|
||||
warnings.push(`Skipping non-numeric data at line ${lineIdx + 1}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dataBlock === 1) {
|
||||
if (parts.length >= 4) {
|
||||
const L = parseFloat(parts[1]);
|
||||
const a = parseFloat(parts[2]);
|
||||
const b = parseFloat(parts[3]);
|
||||
if (!isNaN(L) && !isNaN(a) && !isNaN(b)) {
|
||||
points.push({ L, a, b });
|
||||
// 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 points;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 3D Convex Hull (QuickHull in 3D) for CIELAB point clouds.
|
||||
* Input: points array of { L, a, b }
|
||||
* Output: { vertices: Float32Array, indices: Uint32Array } or null
|
||||
*/
|
||||
export function compute3DConvexHull(pts) {
|
||||
const faces = computeQuickHull(pts);
|
||||
if (!faces || faces.length === 0) return null;
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Build a BufferGeometry from parsed gamut data
|
||||
// Returns null if insufficient data.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function _buildGeometry(vertices, faces) {
|
||||
if (vertices.length < 4) return null;
|
||||
|
||||
const verticesList = [];
|
||||
const indices = [];
|
||||
const ptMap = new Map();
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
|
||||
for (const f of faces) {
|
||||
for (const p of [f.a, f.b, f.c]) {
|
||||
const key = `${p.x}_${p.y}_${p.z}`;
|
||||
if (!ptMap.has(key)) {
|
||||
ptMap.set(key, verticesList.length / 3);
|
||||
verticesList.push(p.x, p.y, p.z);
|
||||
if (faces.length > 0) {
|
||||
// Pre-triangulated mesh from .gam file
|
||||
const posArr = new Float32Array(vertices.length * 3);
|
||||
for (let i = 0; i < vertices.length; i++) {
|
||||
const [L, a, b] = vertices[i];
|
||||
posArr[i * 3] = a; // X = a*
|
||||
posArr[i * 3 + 1] = L; // Y = L*
|
||||
posArr[i * 3 + 2] = b; // Z = b*
|
||||
}
|
||||
const idxArr = new Uint32Array(faces.length * 3);
|
||||
for (let i = 0; i < faces.length; i++) {
|
||||
idxArr[i * 3] = faces[i][0];
|
||||
idxArr[i * 3 + 1] = faces[i][1];
|
||||
idxArr[i * 3 + 2] = faces[i][2];
|
||||
}
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(posArr, 3));
|
||||
geometry.setIndex(new THREE.BufferAttribute(idxArr, 1));
|
||||
geometry.computeVertexNormals();
|
||||
} else {
|
||||
// Fallback: convex hull from point cloud
|
||||
const pts = vertices.map(v => ({ x: v[1], y: v[0], z: v[2] }));
|
||||
const hullFaces = computeQuickHull(pts);
|
||||
if (!hullFaces || hullFaces.length === 0) return null;
|
||||
|
||||
const vList = [];
|
||||
const idxList = [];
|
||||
const ptMap = new Map();
|
||||
for (const f of hullFaces) {
|
||||
for (const p of [f.a, f.b, f.c]) {
|
||||
const key = `${p.x}_${p.y}_${p.z}`;
|
||||
if (!ptMap.has(key)) { ptMap.set(key, vList.length / 3); vList.push(p.x, p.y, p.z); }
|
||||
idxList.push(ptMap.get(key));
|
||||
}
|
||||
indices.push(ptMap.get(key));
|
||||
}
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vList), 3));
|
||||
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(idxList), 1));
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
// Re-extract vertices for colour calculation below
|
||||
const posAttr = geometry.getAttribute('position');
|
||||
vertices = [];
|
||||
for (let i = 0; i < posAttr.count; i++) {
|
||||
// Note: stored as (a, L, b) — convert back to [L, a, b]
|
||||
vertices.push([posAttr.getY(i), posAttr.getX(i), posAttr.getZ(i)]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
vertices: new Float32Array(verticesList),
|
||||
indices: new Uint32Array(indices)
|
||||
};
|
||||
return { geometry, vertices };
|
||||
}
|
||||
|
||||
export function renderGamutFromText(text, color, isWireframe, previousMesh) {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Render sRGB reference gamut as EdgesGeometry outline + faint solid fill
|
||||
// Returned as a THREE.Group stored in sRgbGroup
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function _renderSrgbReference(text, previousGroup) {
|
||||
if (!scene) return null;
|
||||
|
||||
if (previousGroup) {
|
||||
scene.remove(previousGroup);
|
||||
previousGroup.traverse(child => {
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) child.material.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||
if (warnings.length > 0) {
|
||||
console.warn('Gamut parser warnings:', warnings.join('\n'));
|
||||
}
|
||||
const built = _buildGeometry(vertices, faces);
|
||||
if (!built) return null;
|
||||
|
||||
const { geometry } = built;
|
||||
const group = new THREE.Group();
|
||||
|
||||
// ── 1. Faint transparent solid fill ──────────────────────────────────────
|
||||
const solidMat = new THREE.MeshLambertMaterial({
|
||||
color: 0x8899bb,
|
||||
transparent: true,
|
||||
opacity: 0.07,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false
|
||||
});
|
||||
group.add(new THREE.Mesh(geometry, solidMat));
|
||||
|
||||
// ── 2. Clean structural outline via EdgesGeometry (15° threshold) ─────────
|
||||
// This removes all co-planar internal triangle edges, leaving only the
|
||||
// structural contours of the gamut surface hull.
|
||||
const edgesGeo = new THREE.EdgesGeometry(geometry, 15);
|
||||
const edgesMat = new THREE.LineBasicMaterial({
|
||||
color: 0x6688aa,
|
||||
transparent: true,
|
||||
opacity: 0.55
|
||||
});
|
||||
group.add(new THREE.LineSegments(edgesGeo, edgesMat));
|
||||
|
||||
scene.add(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Render profile gamut with per-vertex true Lab→sRGB colour
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function _renderProfileGamut(text, previousMesh) {
|
||||
if (!scene) return null;
|
||||
|
||||
if (previousMesh) {
|
||||
@@ -153,22 +401,35 @@ export function renderGamutFromText(text, color, isWireframe, previousMesh) {
|
||||
if (previousMesh.material) previousMesh.material.dispose();
|
||||
}
|
||||
|
||||
const points = parseCGATS(text);
|
||||
if (points.length < 4) return null;
|
||||
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||
if (warnings.length > 0) {
|
||||
console.warn('Gamut parser warnings:', warnings.join('\n'));
|
||||
}
|
||||
const built = _buildGeometry(vertices, faces);
|
||||
if (!built) return null;
|
||||
|
||||
const hull = compute3DConvexHull(points);
|
||||
if (!hull) return null;
|
||||
const { geometry } = built;
|
||||
let verts = built.vertices;
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(hull.vertices, 3));
|
||||
geometry.setIndex(Array.from(hull.indices));
|
||||
geometry.computeVertexNormals();
|
||||
// ── Per-vertex colour: Lab → sRGB ─────────────────────────────────────────
|
||||
// geometry 'position' is laid out as [a, L, b] per vertex
|
||||
const posAttr = geometry.getAttribute('position');
|
||||
const colorArr = new Float32Array(posAttr.count * 3);
|
||||
for (let i = 0; i < posAttr.count; i++) {
|
||||
const a_star = posAttr.getX(i);
|
||||
const L_star = posAttr.getY(i);
|
||||
const b_star = posAttr.getZ(i);
|
||||
const [r, g, b] = labToSrgb(L_star, a_star, b_star);
|
||||
colorArr[i * 3] = r / 255;
|
||||
colorArr[i * 3 + 1] = g / 255;
|
||||
colorArr[i * 3 + 2] = b / 255;
|
||||
}
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(colorArr, 3));
|
||||
|
||||
const material = new THREE.MeshLambertMaterial({
|
||||
color: color,
|
||||
wireframe: isWireframe,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: isWireframe ? 0.35 : 0.75,
|
||||
opacity: 0.88,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
@@ -177,26 +438,164 @@ export function renderGamutFromText(text, color, isWireframe, previousMesh) {
|
||||
return mesh;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Camera & view controls
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Reset the camera to its default home position and orientation.
|
||||
* Also resets orbit controls target to the centre of the CIELAB volume.
|
||||
*/
|
||||
export function resetCamera() {
|
||||
if (!camera || !controls) return;
|
||||
camera.position.set(180, 120, 180);
|
||||
camera.lookAt(0, 50, 0);
|
||||
controls.target.set(0, 50, 0);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the opacity of the rendered profile gamut surface (0–1).
|
||||
* @param {number} opacity
|
||||
*/
|
||||
export function setProfileOpacity(opacity) {
|
||||
if (currentProfileMesh && currentProfileMesh.material) {
|
||||
currentProfileMesh.material.opacity = Math.max(0, Math.min(1, opacity));
|
||||
currentProfileMesh.material.transparent = opacity < 1;
|
||||
currentProfileMesh.material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the opacity of the sRGB reference wireframe and fill (0–1).
|
||||
* @param {number} opacity
|
||||
*/
|
||||
export function setSrgbReferenceOpacity(opacity) {
|
||||
if (sRgbGroup) {
|
||||
sRgbGroup.traverse((child) => {
|
||||
if (child.material) {
|
||||
child.material.opacity = child.material.opacity >= 0.5
|
||||
? Math.max(0.05, Math.min(1, opacity))
|
||||
: Math.max(0.02, Math.min(0.2, opacity * 0.2));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the opacity of the CIELAB axis scaffold (0–1).
|
||||
* @param {number} opacity
|
||||
*/
|
||||
export function setAxisOpacity(opacity) {
|
||||
if (axisScaffoldGroup) {
|
||||
axisScaffoldGroup.traverse((child) => {
|
||||
if (child.material) {
|
||||
child.material.opacity = Math.max(0, Math.min(1, opacity));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard shortcuts for the gamut viewer.
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
function _onKeyDown(e) {
|
||||
if (e.key === 'r' || e.key === 'R') {
|
||||
resetCamera();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Load and render the bundled sRGB reference gamut.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function loadSrgbReferenceGamut() {
|
||||
try {
|
||||
const response = await fetch('assets/sRGB.gam');
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
sRgbMesh = renderGamutFromText(text, 0x94a3b8, true, sRgbMesh);
|
||||
sRgbGroup = _renderSrgbReference(text, sRgbGroup);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Could not load sRGB reference gamut:", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadGamutMesh(gamFilePath, color = 0x3b82f6, isWireframe = false) {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Load and render a profile gamut from a `.gam` file path.
|
||||
* @param {string} gamFilePath - Absolute path to the gamut file.
|
||||
* @returns {Promise<THREE.Mesh|null>}
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export async function loadGamutMesh(gamFilePath) {
|
||||
try {
|
||||
const base64Data = await invoke('read_file_base64', { path: gamFilePath });
|
||||
const text = atob(base64Data);
|
||||
currentProfileMesh = renderGamutFromText(text, color, isWireframe, currentProfileMesh);
|
||||
currentProfileMesh = _renderProfileGamut(text, currentProfileMesh);
|
||||
return currentProfileMesh;
|
||||
} catch (e) {
|
||||
console.error("Failed to load gamut mesh:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Toggle functions (exported for external use; also wired internally)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export function toggleSrgbReference(visible) {
|
||||
if (sRgbGroup) sRgbGroup.visible = visible;
|
||||
}
|
||||
|
||||
export function toggleProfileGamut(visible) {
|
||||
if (currentProfileMesh) currentProfileMesh.visible = visible;
|
||||
}
|
||||
|
||||
export function toggleAxes(visible) {
|
||||
if (axisScaffoldGroup) axisScaffoldGroup.visible = visible;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Wire legend toggle checkboxes, opacity sliders, and reset button
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function _wireToggles() {
|
||||
const bindings = [
|
||||
['chkProfileGamut', toggleProfileGamut ],
|
||||
['chkSrgbReference', toggleSrgbReference ],
|
||||
['chkLabAxes', toggleAxes ],
|
||||
];
|
||||
for (const [id, fn] of bindings) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('change', (e) => fn(e.target.checked));
|
||||
}
|
||||
|
||||
const resetBtn = document.getElementById('btnGamutResetCamera');
|
||||
if (resetBtn) resetBtn.addEventListener('click', resetCamera);
|
||||
|
||||
const profileOpacity = document.getElementById('rngProfileOpacity');
|
||||
if (profileOpacity) {
|
||||
profileOpacity.addEventListener('input', (e) => setProfileOpacity(parseFloat(e.target.value)));
|
||||
}
|
||||
|
||||
const srgbOpacity = document.getElementById('rngSrgbOpacity');
|
||||
if (srgbOpacity) {
|
||||
srgbOpacity.addEventListener('input', (e) => setSrgbReferenceOpacity(parseFloat(e.target.value)));
|
||||
}
|
||||
|
||||
const axisOpacity = document.getElementById('rngAxisOpacity');
|
||||
if (axisOpacity) {
|
||||
axisOpacity.addEventListener('input', (e) => setAxisOpacity(parseFloat(e.target.value)));
|
||||
}
|
||||
|
||||
// Only listen for 'R' reset when the viewer tab is active.
|
||||
const stage5 = document.getElementById('stage-5');
|
||||
if (stage5) {
|
||||
stage5.addEventListener('keydown', (e) => {
|
||||
if ((e.key === 'r' || e.key === 'R') && !stage5.classList.contains('hidden')) {
|
||||
resetCamera();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Manual / browser-console tests for gamut_viewer.js and profcheck.js parsing.
|
||||
// Run in a browser/devtools console after the app has loaded:
|
||||
// import('./gamut_viewer.test.js').then(m => m.runAll())
|
||||
|
||||
import { parseGamutFile } from './gamut_viewer.js';
|
||||
|
||||
export function runAll() {
|
||||
console.group('gamut/profcheck parser tests');
|
||||
testParseGamutBasic();
|
||||
testParseGamutDualTable();
|
||||
testParseGamutWithComments();
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, message) {
|
||||
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||
if (ok) {
|
||||
console.log('PASS:', message);
|
||||
} else {
|
||||
console.error('FAIL:', message, 'expected', expected, 'got', actual);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
function testParseGamutBasic() {
|
||||
const text = `GAMUT file
|
||||
BEGIN_DATA
|
||||
0 50 0 0
|
||||
1 100 0 0
|
||||
2 0 -128 0
|
||||
3 0 0 128
|
||||
END_DATA
|
||||
BEGIN_DATA
|
||||
0 1 2
|
||||
1 2 3
|
||||
END_DATA`;
|
||||
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||
assertEqual(vertices.length, 4, 'basic gamut vertex count');
|
||||
assertEqual(faces.length, 2, 'basic gamut face count');
|
||||
assertEqual(warnings.length, 0, 'basic gamut no warnings');
|
||||
}
|
||||
|
||||
function testParseGamutDualTable() {
|
||||
const text = `GAMUT file
|
||||
BEGIN_DATA
|
||||
0 50 0 0
|
||||
1 100 0 0
|
||||
2 0 -128 0
|
||||
3 0 0 128
|
||||
END_DATA
|
||||
BEGIN_DATA
|
||||
0 1 2
|
||||
END_DATA
|
||||
BEGIN_DATA
|
||||
1 2 3
|
||||
END_DATA`;
|
||||
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||
assertEqual(vertices.length, 4, 'dual-table gamut vertex count');
|
||||
assertEqual(faces.length, 2, 'dual-table gamut face count');
|
||||
assertEqual(warnings.length, 0, 'dual-table gamut no warnings');
|
||||
}
|
||||
|
||||
function testParseGamutWithComments() {
|
||||
const text = `GAMUT file
|
||||
# this is a comment
|
||||
BEGIN_DATA
|
||||
0 50 0 0
|
||||
1 100 0 0
|
||||
# inline comment
|
||||
2 0 -128 0
|
||||
3 0 0 128
|
||||
END_DATA
|
||||
BEGIN_DATA
|
||||
0 1 2
|
||||
1 2 3
|
||||
END_DATA`;
|
||||
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||
assertEqual(vertices.length, 4, 'commented gamut vertex count');
|
||||
assertEqual(faces.length, 2, 'commented gamut face count');
|
||||
}
|
||||
@@ -244,6 +244,32 @@ export async function initPresets() {
|
||||
|
||||
const colprofAlgorithm = document.getElementById("colprofAlgorithm");
|
||||
if (colprofAlgorithm && preset.colprof_algorithm) colprofAlgorithm.value = preset.colprof_algorithm;
|
||||
|
||||
const colprofFwa = document.getElementById("colprofFwa");
|
||||
const colprofCustomSpRow = document.getElementById("colprofCustomSpRow");
|
||||
const colprofCustomSpPath = document.getElementById("colprofCustomSpPath");
|
||||
if (colprofFwa) {
|
||||
if (preset.colprof_fwa && preset.colprof_fwa.endsWith(".sp")) {
|
||||
colprofFwa.value = "custom";
|
||||
if (colprofCustomSpPath) colprofCustomSpPath.value = preset.colprof_fwa;
|
||||
if (colprofCustomSpRow) colprofCustomSpRow.classList.remove("hidden");
|
||||
} else {
|
||||
colprofFwa.value = preset.colprof_fwa || "D50";
|
||||
if (colprofCustomSpRow) colprofCustomSpRow.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
const colprofIlluminant = document.getElementById("colprofIlluminant");
|
||||
if (colprofIlluminant) colprofIlluminant.value = preset.colprof_illuminant || "";
|
||||
|
||||
const colprofObserver = document.getElementById("colprofObserver");
|
||||
if (colprofObserver) colprofObserver.value = preset.colprof_observer || "";
|
||||
|
||||
const colprofInputViewCond = document.getElementById("colprofInputViewCond");
|
||||
if (colprofInputViewCond) colprofInputViewCond.value = preset.colprof_input_viewing_cond || "none";
|
||||
|
||||
const colprofOutputViewCond = document.getElementById("colprofOutputViewCond");
|
||||
if (colprofOutputViewCond) colprofOutputViewCond.value = preset.colprof_output_viewing_cond || "none";
|
||||
}
|
||||
|
||||
function collectCurrentSettingsAsPreset(name, description) {
|
||||
@@ -335,6 +361,29 @@ export async function initPresets() {
|
||||
const colprofAlgorithm = document.getElementById("colprofAlgorithm");
|
||||
const colprof_algorithm = colprofAlgorithm ? colprofAlgorithm.value : "l";
|
||||
|
||||
const colprofFwa = document.getElementById("colprofFwa");
|
||||
const colprofCustomSpPath = document.getElementById("colprofCustomSpPath");
|
||||
let colprof_fwa = "D50";
|
||||
if (colprofFwa) {
|
||||
if (colprofFwa.value === "custom") {
|
||||
colprof_fwa = colprofCustomSpPath ? colprofCustomSpPath.value : "none";
|
||||
} else {
|
||||
colprof_fwa = colprofFwa.value;
|
||||
}
|
||||
}
|
||||
|
||||
const colprofIlluminant = document.getElementById("colprofIlluminant");
|
||||
const colprof_illuminant = colprofIlluminant && colprofIlluminant.value ? colprofIlluminant.value : null;
|
||||
|
||||
const colprofObserver = document.getElementById("colprofObserver");
|
||||
const colprof_observer = colprofObserver && colprofObserver.value ? colprofObserver.value : null;
|
||||
|
||||
const colprofInputViewCond = document.getElementById("colprofInputViewCond");
|
||||
const colprof_input_viewing_cond = colprofInputViewCond && colprofInputViewCond.value !== "none" ? colprofInputViewCond.value : null;
|
||||
|
||||
const colprofOutputViewCond = document.getElementById("colprofOutputViewCond");
|
||||
const colprof_output_viewing_cond = colprofOutputViewCond && colprofOutputViewCond.value !== "none" ? colprofOutputViewCond.value : null;
|
||||
|
||||
return {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: name || "Custom Preset",
|
||||
@@ -363,6 +412,11 @@ export async function initPresets() {
|
||||
colprof_algorithm,
|
||||
colprof_quality,
|
||||
colprof_intent: null,
|
||||
colprof_fwa,
|
||||
colprof_illuminant,
|
||||
colprof_observer,
|
||||
colprof_input_viewing_cond,
|
||||
colprof_output_viewing_cond,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+67
-3
@@ -10,6 +10,11 @@ let stage1Cwd = "";
|
||||
let currentManifest = null;
|
||||
let discoveredPrinters = [];
|
||||
|
||||
// Captured CUPS options from the native macOS print panel, keyed by printer
|
||||
// name. These are fed back into print_target_native so the lp job uses the
|
||||
// user's media type / quality / color-bypass selections.
|
||||
let capturedCupsOptions = {};
|
||||
|
||||
let updateLabelPreviewFn = null;
|
||||
|
||||
/**
|
||||
@@ -146,6 +151,7 @@ export function initPrinttarg() {
|
||||
const btnRefreshPrinters = document.getElementById("btnRefreshPrinters");
|
||||
const btnPrinterProperties = document.getElementById("btnPrinterProperties");
|
||||
const printerTraySelect = document.getElementById("printerTraySelect");
|
||||
const printerMediaTypeSelect = document.getElementById("printerMediaTypeSelect");
|
||||
const btnOrientPortrait = document.getElementById("btnOrientPortrait");
|
||||
const btnOrientLandscape = document.getElementById("btnOrientLandscape");
|
||||
const printerStatusBadge = document.getElementById("printerStatusBadge");
|
||||
@@ -219,6 +225,9 @@ export function initPrinttarg() {
|
||||
async function loadPrinterCapabilities(printerName) {
|
||||
if (!printerTraySelect || !printerName) return;
|
||||
printerTraySelect.innerHTML = '<option value="" selected>Default / Auto Select</option>';
|
||||
if (printerMediaTypeSelect) {
|
||||
printerMediaTypeSelect.innerHTML = '<option value="" selected>Default / Auto Select</option>';
|
||||
}
|
||||
|
||||
try {
|
||||
const caps = await invoke("get_printer_capabilities", { printerName });
|
||||
@@ -230,6 +239,15 @@ export function initPrinttarg() {
|
||||
printerTraySelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
if (caps && caps.media_types && caps.media_types.length > 0 && printerMediaTypeSelect) {
|
||||
caps.media_types.forEach(media => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = media.id;
|
||||
opt.textContent = media.name;
|
||||
printerMediaTypeSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[ICCery Print] Could not fetch printer capabilities:", err);
|
||||
}
|
||||
@@ -242,12 +260,17 @@ export function initPrinttarg() {
|
||||
const trayVal = printerTraySelect ? printerTraySelect.value : "";
|
||||
const paperSource = trayVal ? parseInt(trayVal, 10) : null;
|
||||
const ppdFallback = chkPpdFallback ? chkPpdFallback.checked : false;
|
||||
const mediaType = printerMediaTypeSelect ? printerMediaTypeSelect.value : "";
|
||||
const activePrinter = printerSelect ? printerSelect.value : "";
|
||||
const cupsOpts = (activePrinter && capturedCupsOptions[activePrinter]) ? capturedCupsOptions[activePrinter] : null;
|
||||
|
||||
return {
|
||||
paper_source: paperSource,
|
||||
orientation: selectedOrientation,
|
||||
paper_size: pageSizeSelect ? pageSizeSelect.value : null,
|
||||
media_type: mediaType ? mediaType : null,
|
||||
ppd_uncorrected_passthrough: ppdFallback,
|
||||
cups_options: cupsOpts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -306,7 +329,10 @@ export function initPrinttarg() {
|
||||
discoveredPrinters.forEach((p, idx) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.name;
|
||||
opt.textContent = `${p.name}${p.is_default ? ' (Default)' : ''} [${p.status || 'Ready'}]`;
|
||||
const label = p.display_name
|
||||
? `${p.display_name} (${p.name})`
|
||||
: p.name;
|
||||
opt.textContent = `${label}${p.is_default ? ' (Default)' : ''} [${p.status || 'Ready'}]`;
|
||||
if (p.is_default && !defaultSelected) {
|
||||
opt.selected = true;
|
||||
defaultSelected = true;
|
||||
@@ -358,8 +384,46 @@ export function initPrinttarg() {
|
||||
|
||||
try {
|
||||
showNotification("info", `Opening native printer preferences for '${printerName}'...`);
|
||||
await invoke("show_printer_properties", { printerName });
|
||||
showNotification("success", `✓ Printer driver preferences configured for '${printerName}'.`);
|
||||
const result = await invoke("show_printer_properties", { printerName });
|
||||
|
||||
if (result === null) {
|
||||
// User cancelled the native panel.
|
||||
showNotification("info", "Printer properties dialog cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
// macOS: the backend returned a PrintPropertiesResult with the
|
||||
// effective printer and a PrintOptions snapshot containing captured
|
||||
// CUPS options from the native print panel.
|
||||
const effectivePrinter = result.selected_printer || printerName;
|
||||
|
||||
// If the user switched printer in the panel, update the dropdown.
|
||||
if (result.selected_printer && printerSelect && result.selected_printer !== printerName) {
|
||||
const exists = Array.from(printerSelect.options).some(
|
||||
o => o.value === result.selected_printer
|
||||
);
|
||||
if (exists) {
|
||||
printerSelect.value = result.selected_printer;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.options && result.options.cups_options) {
|
||||
capturedCupsOptions[effectivePrinter] = result.options.cups_options;
|
||||
} else {
|
||||
delete capturedCupsOptions[effectivePrinter];
|
||||
}
|
||||
|
||||
// If a media type was captured, update the dropdown to match.
|
||||
if (result.options && result.options.media_type && printerMediaTypeSelect) {
|
||||
const matchOpt = Array.from(printerMediaTypeSelect.options).find(
|
||||
o => o.value === result.options.media_type
|
||||
);
|
||||
if (matchOpt) {
|
||||
printerMediaTypeSelect.value = result.options.media_type;
|
||||
}
|
||||
}
|
||||
|
||||
showNotification("success", `Printer driver preferences configured for '${effectivePrinter}'.`);
|
||||
} catch (err) {
|
||||
console.error("[ICCery Print] Failed to open printer properties:", err);
|
||||
showNotification("error", `Could not open printer properties: ${err}`);
|
||||
|
||||
+76
-13
@@ -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);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
import { logger } from './logger.js';
|
||||
|
||||
function getInputValueAsFloat(id, fallback) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return fallback;
|
||||
const v = parseFloat(el.value);
|
||||
return Number.isNaN(v) ? fallback : v;
|
||||
}
|
||||
|
||||
export async function initSettings() {
|
||||
const dialog = document.getElementById('settingsDialog');
|
||||
const openBtn = document.getElementById('openSettingsBtn');
|
||||
@@ -11,6 +18,10 @@ export async function initSettings() {
|
||||
const btnCopyLogPath = document.getElementById('btnCopyLogPath');
|
||||
const btnCopyLogExcerpt = document.getElementById('btnCopyLogExcerpt');
|
||||
const logPathDisplay = document.getElementById('logPathDisplay');
|
||||
const deltaEGoodMax = document.getElementById('deltaEGoodMax');
|
||||
const deltaEWarningMax = document.getElementById('deltaEWarningMax');
|
||||
const deltaEThresholdError = document.getElementById('deltaEThresholdError');
|
||||
const enableI1Pro2Leds = document.getElementById('enable_i1pro2_leds');
|
||||
|
||||
if (!dialog || !openBtn) return;
|
||||
|
||||
@@ -24,14 +35,42 @@ export async function initSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
function validateDeltaEThresholds() {
|
||||
if (!deltaEGoodMax || !deltaEWarningMax) return true;
|
||||
const good = parseFloat(deltaEGoodMax.value);
|
||||
const warn = parseFloat(deltaEWarningMax.value);
|
||||
const valid = !Number.isNaN(good) && !Number.isNaN(warn) && good < warn && good >= 0 && warn >= 0;
|
||||
if (deltaEThresholdError) {
|
||||
deltaEThresholdError.style.display = valid ? 'none' : 'block';
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
if (deltaEGoodMax) {
|
||||
deltaEGoodMax.addEventListener('input', validateDeltaEThresholds);
|
||||
}
|
||||
if (deltaEWarningMax) {
|
||||
deltaEWarningMax.addEventListener('input', validateDeltaEThresholds);
|
||||
}
|
||||
|
||||
openBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const settings = await invoke('load_settings');
|
||||
document.getElementById('argyll_binary_dir').value = settings.argyll_binary_dir || '';
|
||||
document.getElementById('default_instrument').value = settings.default_instrument || '';
|
||||
if (enableI1Pro2Leds) {
|
||||
enableI1Pro2Leds.checked = Boolean(settings.enable_i1pro2_leds);
|
||||
}
|
||||
if (logLevelSelect && settings.log_level) {
|
||||
logLevelSelect.value = settings.log_level;
|
||||
}
|
||||
if (deltaEGoodMax) {
|
||||
deltaEGoodMax.value = Number(settings.delta_e_good_max ?? 2.0).toFixed(1);
|
||||
}
|
||||
if (deltaEWarningMax) {
|
||||
deltaEWarningMax.value = Number(settings.delta_e_warning_max ?? 5.0).toFixed(1);
|
||||
}
|
||||
validateDeltaEThresholds();
|
||||
await refreshLogPath();
|
||||
dialog.showModal();
|
||||
} catch (e) {
|
||||
@@ -99,18 +138,27 @@ export async function initSettings() {
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
if (!validateDeltaEThresholds()) {
|
||||
throw new Error('Invalid ΔE thresholds');
|
||||
}
|
||||
const currentSettings = await invoke('load_settings').catch(() => ({}));
|
||||
const settings = {
|
||||
...currentSettings,
|
||||
argyll_binary_dir: document.getElementById('argyll_binary_dir').value.trim() || null,
|
||||
default_instrument: document.getElementById('default_instrument').value.trim() || null,
|
||||
log_level: logLevelSelect ? logLevelSelect.value : (currentSettings.log_level || 'info'),
|
||||
delta_e_good_max: getInputValueAsFloat('deltaEGoodMax', 2.0),
|
||||
delta_e_warning_max: getInputValueAsFloat('deltaEWarningMax', 5.0),
|
||||
enable_i1pro2_leds: enableI1Pro2Leds ? enableI1Pro2Leds.checked : false,
|
||||
};
|
||||
await invoke('save_settings', { settings });
|
||||
logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings');
|
||||
// Notify other UI modules (e.g. swatch grid) that settings changed.
|
||||
window.dispatchEvent(new CustomEvent('settings-saved', { detail: settings }));
|
||||
dialog.close();
|
||||
} catch (e) {
|
||||
logger.error(`Failed to save settings: ${e}`, 'Settings');
|
||||
alert(`Failed to save settings: ${e}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+127
-21
@@ -1,20 +1,90 @@
|
||||
import { computeDeltaE00 } from './delta_e.js';
|
||||
import { labToCss, deviceRgbToCss } from './color_convert.js';
|
||||
import { labToCss, deviceRgbToCss, deviceCmykToCss } from './color_convert.js';
|
||||
|
||||
const { listen } = window.__TAURI__.event;
|
||||
const { listen, emit } = window.__TAURI__.event;
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
|
||||
let unlistenJsonRow = null;
|
||||
|
||||
// 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")
|
||||
* @param {Function} [onRowComplete] - Optional callback invoked on each completed row
|
||||
*/
|
||||
export async function startSwatchListener(processId) {
|
||||
export async function startSwatchListener(processId, onRowComplete) {
|
||||
const grid = document.getElementById("swatchGrid");
|
||||
const progressBar = document.getElementById("readProgress");
|
||||
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;
|
||||
@@ -35,12 +105,23 @@ export async function startSwatchListener(processId) {
|
||||
|
||||
if (data.event !== "row_complete") return;
|
||||
|
||||
if (onRowComplete && typeof onRowComplete === "function") {
|
||||
onRowComplete({
|
||||
rowIndex: data.row_index,
|
||||
totalRows: data.total_rows,
|
||||
rowId: data.row_id,
|
||||
isAllComplete: data.row_index + 1 >= data.total_rows,
|
||||
});
|
||||
}
|
||||
|
||||
// Update progress bar
|
||||
const progress = ((data.row_index + 1) / data.total_rows) * 100;
|
||||
if (progressBar) progressBar.style.width = `${progress}%`;
|
||||
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";
|
||||
|
||||
@@ -53,43 +134,68 @@ export async function startSwatchListener(processId) {
|
||||
rowPatches.className = "swatch-row-patches";
|
||||
|
||||
for (const patch of data.patches) {
|
||||
if (patch.is_pad) continue; // Skip spacer 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");
|
||||
patchEl.className = "swatch-patch";
|
||||
|
||||
// Determine the display colour
|
||||
let bgColor;
|
||||
if (patch.measured && patch.measured.Lab) {
|
||||
bgColor = labToCss(patch.measured.Lab);
|
||||
let intendedCss = "#888";
|
||||
if (patch.expected && patch.expected.Lab) {
|
||||
intendedCss = labToCss(patch.expected.Lab);
|
||||
} else if (patch.device && patch.device.length === 3) {
|
||||
bgColor = deviceRgbToCss(patch.device);
|
||||
} else {
|
||||
bgColor = "#888";
|
||||
intendedCss = deviceRgbToCss(patch.device);
|
||||
} else if (patch.device && patch.device.length === 4) {
|
||||
intendedCss = deviceCmykToCss(patch.device);
|
||||
}
|
||||
|
||||
let measuredCss = null;
|
||||
if (patch.measured && patch.measured.Lab) {
|
||||
measuredCss = labToCss(patch.measured.Lab);
|
||||
}
|
||||
|
||||
const swatch = document.createElement("div");
|
||||
swatch.className = "swatch-color";
|
||||
swatch.style.backgroundColor = bgColor;
|
||||
|
||||
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 += `\nIntended Device: ${patch.device.map(v => v.toFixed(1)).join(', ')}`;
|
||||
}
|
||||
|
||||
if (patch.measured && patch.measured.Lab) {
|
||||
titleStr += `\nMeasured Lab: ${patch.measured.Lab.map(v => v.toFixed(1)).join(', ')}`;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// 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
|
||||
@@ -98,7 +204,7 @@ export async function startSwatchListener(processId) {
|
||||
if (deltaE > maxDeltaE) maxDeltaE = deltaE;
|
||||
}
|
||||
|
||||
patchEl.title = `${patch.loc} (ID: ${patch.id})`;
|
||||
patchEl.title = titleStr;
|
||||
rowPatches.appendChild(patchEl);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
+1
-1
@@ -93,7 +93,7 @@ export function initTargen() {
|
||||
if (btnBrowsePrecondProfile && targenPrecondProfile) {
|
||||
btnBrowsePrecondProfile.addEventListener("click", async () => {
|
||||
try {
|
||||
const filePath = await invoke("select_existing_target", {
|
||||
const filePath = await invoke("select_profile_file", {
|
||||
defaultDir: currentWorkingDir || null,
|
||||
});
|
||||
if (filePath) {
|
||||
|
||||
+403
-25
@@ -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 {
|
||||
@@ -124,24 +129,104 @@ h2 {
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--accent-color), #2d73a8);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 9px 18px;
|
||||
border-radius: var(--btn-radius-md);
|
||||
padding: 0 18px;
|
||||
height: var(--btn-height-md);
|
||||
font-size: 0.95rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 14px;
|
||||
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 {
|
||||
background-color: #005f9e;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
@@ -157,7 +242,8 @@ button.primary:hover {
|
||||
|
||||
select,
|
||||
.form-group select {
|
||||
padding: 8px 12px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
background-color: var(--bg-color);
|
||||
color: #ffffff;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -228,12 +314,24 @@ 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;
|
||||
}
|
||||
|
||||
input[type="number"], input[type="text"] {
|
||||
padding: 8px;
|
||||
padding: 0 8px;
|
||||
height: 36px;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -256,9 +354,11 @@ button.secondary {
|
||||
background-color: #444;
|
||||
color: white;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
padding: 0 16px;
|
||||
height: var(--btn-height-md);
|
||||
border-radius: var(--btn-radius-sm);
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
@@ -461,6 +561,7 @@ button:disabled {
|
||||
.chartread-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -469,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;
|
||||
}
|
||||
|
||||
@@ -554,6 +656,8 @@ button.danger:hover {
|
||||
.swatch-color {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.swatch-de {
|
||||
@@ -748,10 +852,16 @@ button.danger:hover {
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
border-radius: var(--btn-radius-sm);
|
||||
color: #d1d5db;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
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;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
@@ -937,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;
|
||||
@@ -948,6 +1059,7 @@ button.danger:hover {
|
||||
gap: 6px;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn-properties:hover {
|
||||
@@ -1006,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 {
|
||||
@@ -1185,6 +1301,10 @@ button.danger:hover {
|
||||
|
||||
.tooltip-text {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
color: #e0e0e0;
|
||||
@@ -1192,9 +1312,9 @@ button.danger:hover {
|
||||
border: 1px solid #444455;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
margin-top: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
z-index: 10;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
|
||||
z-index: 100;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.has-tooltip:hover .tooltip-text,
|
||||
@@ -1204,6 +1324,9 @@ button.danger:hover {
|
||||
|
||||
.show-all-tooltips .tooltip-text {
|
||||
display: block !important;
|
||||
position: static !important;
|
||||
margin-top: 6px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.advanced-section-details {
|
||||
@@ -1257,7 +1380,16 @@ button.danger:hover {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.advanced-grid-2col .form-group {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.advanced-grid-2col .form-group > :nth-child(2):not(.tooltip-text) {
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.slider-input-container {
|
||||
@@ -1265,6 +1397,7 @@ button.danger:hover {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.slider-input-container input[type="range"] {
|
||||
@@ -1284,6 +1417,7 @@ button.danger:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.checkbox-control-container label {
|
||||
@@ -1295,3 +1429,247 @@ button.danger:hover {
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
3D Gamut Viewer — Stage 5
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.gamut-viewer-section {
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.gamut-viewer-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.gamut-viewer-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color, #d4d4d4);
|
||||
}
|
||||
|
||||
.gamut-viewer-subtitle {
|
||||
font-weight: 400;
|
||||
opacity: 0.55;
|
||||
font-size: 0.85rem;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.gamut-viewer-hint {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.45;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Wrapper gives the overlay panel a positioning context */
|
||||
.gamut-viewer-wrap {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
#gamutViewerContainer {
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
background: #0e0e14;
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Canvas fills the container */
|
||||
#gamutViewerContainer canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* ── Legend / controls panel ─────────────────────────────────────────────── */
|
||||
|
||||
.gamut-controls-panel {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 20;
|
||||
min-width: 154px;
|
||||
background: rgba(14, 14, 22, 0.82);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: 10px;
|
||||
padding: 10px 13px 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.gamut-legend-title {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0.45;
|
||||
font-weight: 600;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.gamut-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 7px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color, #d4d4d4);
|
||||
}
|
||||
|
||||
.gamut-legend-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.gamut-legend-item.gamut-slider-item {
|
||||
margin: -2px 0 8px 27px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gamut-slider-label {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.65;
|
||||
min-width: 46px;
|
||||
}
|
||||
|
||||
.gamut-slider-item input[type="range"] {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
height: 4px;
|
||||
accent-color: var(--accent-color, #3b82f6);
|
||||
}
|
||||
|
||||
.gamut-controls-actions {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gamut-controls-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gamut-keyboard-hint {
|
||||
font-size: 0.68rem;
|
||||
opacity: 0.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gamut-legend-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Colour swatches */
|
||||
.gamut-swatch {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.gamut-swatch-profile {
|
||||
/* Gradient hint at the three primary hue quadrants */
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #ef4444 50%, #22c55e 100%);
|
||||
}
|
||||
|
||||
.gamut-swatch-srgb {
|
||||
background: #6688aa;
|
||||
}
|
||||
|
||||
.gamut-swatch-axes {
|
||||
background: #3a3a5a;
|
||||
}
|
||||
|
||||
/* ── Mini iOS-style toggle switch ─────────────────────────────────────────── */
|
||||
|
||||
.gamut-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 30px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gamut-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.gamut-toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 16px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.gamut-toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
left: 2.5px;
|
||||
top: 2.5px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.gamut-toggle input:checked + .gamut-toggle-slider {
|
||||
background: var(--accent-color, #007acc);
|
||||
}
|
||||
|
||||
.gamut-toggle input:checked + .gamut-toggle-slider::before {
|
||||
transform: translateX(14px);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── CSS2D axis label styles (injected into overlay DOM by CSS2DRenderer) ── */
|
||||
|
||||
.gamut-axis-label {
|
||||
font-family: system-ui, -apple-system, monospace;
|
||||
font-size: 11px;
|
||||
color: rgba(200, 210, 230, 0.85);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9), 0 0 8px rgba(0, 0, 0, 0.7);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gamut-axis-primary {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(220, 230, 255, 0.95);
|
||||
}
|
||||
|
||||
.gamut-axis-a-pos { color: rgba(255, 160, 150, 0.85); }
|
||||
.gamut-axis-a-neg { color: rgba(140, 220, 160, 0.85); }
|
||||
.gamut-axis-b-pos { color: rgba(255, 230, 130, 0.85); }
|
||||
.gamut-axis-b-neg { color: rgba(140, 170, 255, 0.85); }
|
||||
|
||||
.gamut-tick-label {
|
||||
font-size: 10px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user