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

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

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

Closes #189

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

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

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

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

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

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-02 21:21:52 +01:00
gronod 47a5373a8e chore: merge development into main (fix Windows build failure)
Build Windows Packages / Build Windows (push) Successful in 7m28s
Build Linux Packages / Build Linux (push) Successful in 10m39s
Merge pull request #193 from development into main
2026-09-02 19:07:58 +01:00
gronod 6a4551bab6 fix(print): implement Windows media_types support and resolve CI build failure
Merge pull request #192 from fix/windows-media-types into development
2026-09-02 19:07:42 +01:00
Gordon Bolton 0b6974d060 fix(print): implement Windows media_types support and update print tests
- Populate media_types in PrinterCapabilities on Windows using DeviceCapabilitiesW (DC_MEDIATYPES / DC_MEDIATYPENAMES)
- Apply media_type option to DEVMODEW (DM_MEDIATYPE) in apply_print_options_to_devmode
- Fix missing media_types/media_type in PrinterCapabilities and PrintOptions struct initializers in tests
- Align macOS and Unix print arg tests with new ColorSync bypass flags
2026-09-02 18:54:06 +01:00
gronod 75cba657cd chore: release v0.7.2
Build Windows Packages / Build Windows (push) Failing after 2m2s
Build Linux Packages / Build Linux (push) Successful in 10m31s
Merge pull request 'chore: release v0.7.2' (#191) from development into main
2026-09-02 18:11:29 +01:00
Gordon Bolton a8764d90fd chore: bump version to 0.7.2 2026-09-02 18:10:25 +01:00
gronod b78002b856 Merge pull request 'Fix #188: Add Media Type selection and bypass macOS ColorSync and driver color management' (#190) from fix/issue-188 into development
Reviewed-on: #190
2026-09-02 18:07:33 +01:00
Gordon Bolton aad79681af Fix #188: Add Media Type selection and bypass macOS ColorSync and driver color management
Build Windows Packages / Build Windows (push) Failing after 2m22s
Build Linux Packages / Build Linux (push) Successful in 13m52s
2026-09-02 17:36:55 +01:00
Gordon Bolton be0f55aefc Merge branch 'development' into main 2026-09-02 12:55:47 +01:00
Gordon Bolton 9a3f841f9f build(git): add .gitattributes for cross-platform EOL normalization 2026-09-02 12:54:41 +01:00
gronod 9768252f6b Merge pull request 'chore: release v0.7.1' (#187) from development into main
Build Windows Packages / Build Windows (push) Successful in 8m1s
Build Linux Packages / Build Linux (push) Successful in 9m45s
2026-09-02 10:55:20 +01:00
gronod c37662daed Merge pull request 'feat: 3D gamut visualization rework (#185)' (#186) from feat/185-3d-gamut-visualization into development 2026-09-02 10:55:11 +01:00
gronod 2079556d61 feat(visualizer): 3D gamut visualization rework with CIELAB axes, CSS2D labels, clean reference edges, per-vertex coloring, and layer controls (#185) 2026-09-02 10:54:43 +01:00
gronod fb438a7880 Merge pull request 'fix: missing ProfilingPreset fields for v0.7.0 build' (#183) from development into main
Build Windows Packages / Build Windows (push) Successful in 7m28s
Build Linux Packages / Build Linux (push) Successful in 10m26s
2026-09-02 01:47:10 +01:00
gronod 5c2c90f6e0 Merge pull request 'fix: missing ProfilingPreset fields for v0.7.0 build' (#182) from fix/v0.7.0-build into development 2026-09-02 01:46:55 +01:00
gronod c917f059b1 fix: missing ProfilingPreset fields for v0.7.0 build 2026-09-02 01:46:35 +01:00
gronod a66b98fd9a Merge pull request 'chore: release v0.7.0' (#181) from development into main
Build Windows Packages / Build Windows (push) Failing after 2m41s
Build Linux Packages / Build Linux (push) Failing after 6m10s
2026-09-02 01:37:19 +01:00
22 changed files with 2221 additions and 250 deletions
+57
View File
@@ -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
+9
View File
@@ -62,6 +62,15 @@ jobs:
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/instlist"
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/targen"
- name: Run Rust tests
shell: bash
env:
CARGO_INCREMENTAL: "0"
CARGO_TARGET_DIR: "${{ runner.temp }}/cargo-target"
run: |
cd src-tauri
cargo test --target ${{ matrix.platform.target }}
- name: Set Release Environment
id: set_env
shell: bash
+16 -1
View File
@@ -16,7 +16,7 @@ jobs:
matrix:
platform:
- name: Intel
os: macos-latest
os: macos-26-intel
target: x86_64-apple-darwin
binary_dir: macos-x86_64
arch: x86_64
@@ -74,7 +74,22 @@ jobs:
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
- name: Warm up macOS GUI for DMG layout
if: runner.os == 'macOS'
run: |
# Tauri's DMG bundler runs an AppleScript that asks Finder to set the
# background picture and icon positions. On CI runners, Finder or the
# Aqua session may be idle, which can cause AppleEvent timeouts. Try to
# start/awaken Finder and System Events before the real build.
open -g -a Finder || true
open -g -a "System Events" || true
# Send a harmless probe to force Finder to initialise a scripting session.
osascript -e 'tell application "Finder" to get name' || true
sleep 10
- name: Build Tauri App
env:
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
run: npm run tauri build -- --target ${{ matrix.platform.target }}
- name: Prepare Release Assets
+51
View File
@@ -0,0 +1,51 @@
# ICCery Agent Notes
## 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`
+1 -1
View File
@@ -2,7 +2,7 @@
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS.
[![Release](https://img.shields.io/badge/version-v0.7.0-blue.svg)](https://git.i3omb.com/gronod/ICCery)
[![Release](https://img.shields.io/badge/version-v0.7.2-blue.svg)](https://git.i3omb.com/gronod/ICCery)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-lightgrey.svg)](https://git.i3omb.com/gronod/ICCery)
[![Framework](https://img.shields.io/badge/framework-Tauri%20v2%20%2B%20Rust-orange.svg)](https://tauri.app)
[![License](https://img.shields.io/badge/license-Proprietary%20%2F%20EULA-blue.svg)](LICENCE.md)
+3 -1
View File
@@ -79,7 +79,9 @@ ICCery is a native, cross-platform desktop application built with:
- [x] **Stage 1 Layout Normalization & Stage 2 Deterministic Target Generation (#162, #163)** (`v0.5.5`): Reorganized Stage 1 Advanced Options into structured 2-column grids with normalized heights; enforced deterministic `-R 1` target generation with custom seed and raster order (`-r`) support in Stage 2.
- [x] **macOS Universal Binary Target (#164)** (`v0.5.5`): Added macOS Universal Binary (`universal-apple-darwin`) build target combining Intel (`x86_64`) and Apple Silicon (`arm64`), ArgyllCMS universal sidecar packaging, runtime fallback resolution, and CI release asset automation.
### Milestone 11 — Enterprise Colour Workflow (`v0.6.0` – `v0.7.0`)
### Milestone 11 — Enterprise Colour Workflow (`v0.6.0` – `v0.7.2`)
- [x] **macOS Driver Colour Management Bypass & Media Type Selection (#188)** (`v0.7.2`): Direct media type extraction via PPD/`lpoptions`, vendor uncorrected color bypass detection (Canon `CNIJIntent2`, Epson `ColorCorrection`), `AP_ColorMatchingMode=AP_ApplicationColorMatching` ColorSync suppression, CUPS printer management links, and Media Type selection UI.
- [x] **3D Gamut Visualisation Rework (#185)** (`v0.7.1`): Purpose-built CIELAB axis scaffold with tick marks and crisp CSS2D HTML text labels, clean sRGB reference rendering using `THREE.EdgesGeometry` with faint transparent solid volume, per-vertex true-colour profile gamut shading via `labToSrgb()`, and glassmorphic legend overlay with independent layer visibility toggles.
- [x] **Workflow & Visualizer Enhancements (#176, #177, #178, #179)** (`v0.7.0`): Stage 4 OBA/FWA compensation and viewing conditions UI (`-c`, `-d`), global button standardization, Stage 3 swatch grid diagonal split rendering for CIEDE2000 visual comparison, and robust gamut `.gam` dual-table face parsing with regex fixes for profcheck.
- [x] **CGATS Dataset Interoperability (#94)** (`v0.6.0`): Native Rust CGATS and Argyll `.ti3` dataset parser, canonical normalizer (0-255 scaling, field aliasing, metadata synthesis), and direct-jump workflow to Stage 4 (Profile Generation) and Stage 5 (Verification) using imported external datasets.
- [x] **Stage 1 Layout Normalization (#162)** (`v0.6.1`): Standardized control heights and structural flexbox auto-margin layout for multi-column Stage 1 advanced settings.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "iccery",
"private": true,
"version": "0.7.0",
"version": "0.7.4",
"type": "module",
"scripts": {
"fetch-argyll": "node scripts/fetch-argyll.mjs",
+68 -1
View File
@@ -1423,11 +1423,16 @@ dependencies = [
[[package]]
name = "iccery"
version = "0.6.6"
version = "0.7.4"
dependencies = [
"base64 0.22.1",
"image",
"log",
"objc2",
"objc2-app-kit",
"objc2-application-services",
"objc2-core-foundation",
"objc2-foundation",
"serde",
"serde_json",
"tauri",
@@ -2037,8 +2042,31 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation",
"objc2-quartz-core",
]
[[package]]
name = "objc2-application-services"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69282c2b5bc58fba07cb9de2113619532eb551e98efe3d8d695509ef45fbd53b"
dependencies = [
"bitflags 2.13.1",
"libc",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-services",
"objc2-foundation",
]
@@ -2059,6 +2087,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.13.1",
"objc2",
"objc2-foundation",
]
@@ -2070,7 +2099,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.1",
"block2",
"dispatch2",
"libc",
"objc2",
]
@@ -2107,6 +2138,18 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-core-services"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b"
dependencies = [
"dispatch2",
"objc2",
"objc2-core-foundation",
"objc2-security",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
@@ -2119,6 +2162,19 @@ dependencies = [
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.13.1",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -2170,6 +2226,17 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-security"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
"bitflags 2.13.1",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
+8 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "iccery"
version = "0.7.0"
version = "0.7.4"
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",
+9 -6
View File
@@ -1152,26 +1152,29 @@ pub async fn get_printer_capabilities(
#[tauri::command]
pub async fn show_printer_properties(
app: AppHandle,
state: State<'_, crate::print::PrinterDevModeStore>,
printer_name: String,
) -> Result<(), String> {
) -> Result<Option<crate::print::PrintPropertiesResult>, String> {
#[cfg(windows)]
{
crate::print::windows::show_printer_properties(&printer_name, &state)
let _ = app;
crate::print::windows::show_printer_properties(&printer_name, &state)?;
Ok(None)
}
#[cfg(target_os = "macos")]
{
let _ = state;
crate::print::macos::show_printer_properties(&printer_name)
crate::print::macos::show_printer_properties(&printer_name, &app).await
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let _ = (state, printer_name);
Ok(())
let _ = (app, state, printer_name);
Ok(None)
}
#[cfg(not(any(windows, unix)))]
{
let _ = (state, printer_name);
let _ = (app, state, printer_name);
Err("Printer properties dialog is not supported on this platform.".to_string())
}
}
+788 -38
View File
@@ -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"))
);
}
}
+24
View File
@@ -7,6 +7,9 @@ pub struct Printer {
pub name: String,
pub status: String,
pub is_default: bool,
/// Human-readable name from CUPS `printer-info` (macOS display name).
#[serde(default)]
pub display_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -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)]
+45 -18
View File
@@ -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]
+236 -57
View File
@@ -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,123 @@ 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_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("ColorCorrection") {
Some(("ColorCorrection", "Uncorrected"))
} else if output.contains("StpColorCorrection") {
Some(("StpColorCorrection", "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 +334,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 +405,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());
@@ -384,7 +556,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 +568,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 +637,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]
+63 -1
View File
@@ -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;
}
}
}
}
}
+20
View File
@@ -99,6 +99,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(),
@@ -128,6 +133,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(),
@@ -157,6 +167,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(),
@@ -353,6 +368,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");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ICCery",
"version": "0.7.0",
"version": "0.7.4",
"identifier": "com.gronod.iccery",
"build": {
"frontendDist": "../src"
+50 -3
View File
@@ -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>
@@ -503,6 +504,13 @@
</select>
</div>
<div class="form-group media-group" id="mediaTypeGroup">
<label for="printerMediaTypeSelect">Media Type</label>
<select id="printerMediaTypeSelect">
<option value="" selected>Default / Auto Select</option>
</select>
</div>
<div class="form-group orientation-group">
<label>Orientation</label>
<div class="orientation-toggle-group">
@@ -776,8 +784,47 @@
<!-- 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 &bull; Scroll to zoom &bull; 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">
<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">
<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>
</div>
</div>
<!-- Process Log -->
@@ -837,7 +884,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> &bull; <strong>Build Date:</strong> <span id="aboutBuildDate">August 2026</span></p>
<p><strong>Version:</strong> <span id="aboutVersion">v0.7.2</span> &bull; <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
<p><strong>Copyright &copy; 2026 Gordon Bolton. All rights reserved.</strong></p>
<h3>Licences & EULA</h3>
<div class="license-text-container">
+161
View File
@@ -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;
} )();
+328 -109
View File
@@ -1,72 +1,89 @@
import { computeQuickHull } from "./vendor/quickhull.js";
import { computeQuickHull } from "./vendor/quickhull.js";
import { labToSrgb } from "./color_convert.js";
const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event;
let scene, camera, renderer, controls;
let scene, camera, renderer, labelRenderer, controls;
let currentProfileMesh = null;
let sRgbMesh = null;
let sRgbGroup = null;
let axisScaffoldGroup = null;
// ─────────────────────────────────────────────────────────────────────────────
// Public: initialise the gamut viewer
// ─────────────────────────────────────────────────────────────────────────────
export async function initGamutViewer() {
try {
const container = document.getElementById('gamutViewerContainer');
if (!container || typeof THREE === 'undefined') return;
// Clear any existing contents if re-initialized
// Clear any existing contents if re-initialised
container.innerHTML = "";
scene = new THREE.Scene();
scene.background = new THREE.Color(0x111116);
scene.background = new THREE.Color(0x0e0e14);
const width = container.clientWidth > 0 ? container.clientWidth : 500;
const height = container.clientHeight > 0 ? container.clientHeight : 360;
const width = container.clientWidth > 0 ? container.clientWidth : 500;
const height = container.clientHeight > 0 ? container.clientHeight : 400;
camera = new THREE.PerspectiveCamera(45, width / height, 1, 1000);
camera.position.set(150, 110, 150);
// ── WebGL renderer ────────────────────────────────────────────────────
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000);
camera.position.set(180, 120, 180);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio || 1);
renderer.domElement.style.touchAction = 'none';
renderer.domElement.style.display = 'block';
container.appendChild(renderer.domElement);
// ── CSS2D label renderer ──────────────────────────────────────────────
if (typeof THREE.CSS2DRenderer !== 'undefined') {
labelRenderer = new THREE.CSS2DRenderer();
labelRenderer.setSize(width, height);
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0';
labelRenderer.domElement.style.left = '0';
labelRenderer.domElement.style.pointerEvents = 'none';
container.appendChild(labelRenderer.domElement);
}
// ── Orbit controls ────────────────────────────────────────────────────
if (typeof THREE.OrbitControls !== 'undefined') {
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.target.set(0, 50, 0); // orbit around the centre of L*=50
controls.update();
controls.touches = {
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN
};
}
// Add CIELAB orientation helpers (Axes: X=a*, Y=L*, Z=b*)
const axesHelper = new THREE.AxesHelper(100);
scene.add(axesHelper);
// Add grid helper at L*=0 plane
const gridHelper = new THREE.GridHelper(200, 20, 0x444455, 0x222233);
gridHelper.position.y = 0;
scene.add(gridHelper);
// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
// ── Lighting ──────────────────────────────────────────────────────────
const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
scene.add(ambientLight);
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight1.position.set(100, 200, 100);
dirLight1.position.set(150, 250, 150);
scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
dirLight2.position.set(-100, -100, -100);
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.35);
dirLight2.position.set(-120, -80, -120);
scene.add(dirLight2);
// Handle viewport resize dynamically (e.g., when switching to Stage 5 tab)
// ── CIELAB axis scaffold + labels ─────────────────────────────────────
buildAxisScaffold();
// ── Resize handling ───────────────────────────────────────────────────
const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
for (const entry of entries) {
const w = entry.contentRect.width;
const h = entry.contentRect.height;
if (w > 0 && h > 0 && renderer && camera) {
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
if (labelRenderer) labelRenderer.setSize(w, h);
}
}
});
@@ -74,59 +91,255 @@ export async function initGamutViewer() {
animate();
// Load bundled sRGB reference gamut wireframe on startup
// ── Wire toggle controls ──────────────────────────────────────────────
_wireToggles();
// ── Load bundled sRGB reference gamut ─────────────────────────────────
loadSrgbReferenceGamut();
} catch (err) {
console.warn("Gamut viewer initialization notice:", err);
console.warn("Gamut viewer initialisation notice:", err);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Animation loop
// ─────────────────────────────────────────────────────────────────────────────
function animate() {
requestAnimationFrame(animate);
if (controls) controls.update();
if (renderer && scene && camera) renderer.render(scene, camera);
if (renderer && scene && camera) {
renderer.render(scene, camera);
if (labelRenderer) labelRenderer.render(scene, camera);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Build custom CIELAB axis scaffold (replaces THREE.AxesHelper)
// CIELAB mapping: X = a* (−128 → +128), Y = L* (0 → 100), Z = b* (−128 → +128)
// ─────────────────────────────────────────────────────────────────────────────
function buildAxisScaffold() {
axisScaffoldGroup = new THREE.Group();
const AXIS_MAT_L = new THREE.LineBasicMaterial({ color: 0xcccccc, transparent: true, opacity: 0.7 });
const AXIS_MAT_A = new THREE.LineBasicMaterial({ color: 0x99bbcc, transparent: true, opacity: 0.7 });
const AXIS_MAT_B = new THREE.LineBasicMaterial({ color: 0x99bbcc, transparent: true, opacity: 0.7 });
const TICK_MAT = new THREE.LineBasicMaterial({ color: 0x556677, transparent: true, opacity: 0.8 });
const BOX_MAT = new THREE.LineBasicMaterial({ color: 0x232336, transparent: true, opacity: 0.9 });
const GRID_MAT = new THREE.LineBasicMaterial({ color: 0x1e1e2e });
// ── Bounding box (full CIELAB domain: a*/b* ±128, L* 0→100) ──────────────
const boxGeo = new THREE.BoxGeometry(256, 100, 256);
const edgesGeo = new THREE.EdgesGeometry(boxGeo);
const boxLines = new THREE.LineSegments(edgesGeo, BOX_MAT);
boxLines.position.set(0, 50, 0); // centre at a*=0, L*=50, b*=0
axisScaffoldGroup.add(boxLines);
// ── Ground plane grid at L*=0 (a*/b* plane) ──────────────────────────────
const gridHelper = new THREE.GridHelper(256, 16, 0x1e1e2e, 0x1a1a28);
gridHelper.position.y = 0;
axisScaffoldGroup.add(gridHelper);
// ── L* axis line (vertical, Y): 0 → 100 ──────────────────────────────────
axisScaffoldGroup.add(_line([0, 0, 0], [0, 100, 0], AXIS_MAT_L));
// ── a* axis line (X): −128 → +128 ────────────────────────────────────────
axisScaffoldGroup.add(_line([-128, 0, 0], [128, 0, 0], AXIS_MAT_A));
// ── b* axis line (Z): −128 → +128 ────────────────────────────────────────
axisScaffoldGroup.add(_line([0, 0, -128], [0, 0, 128], AXIS_MAT_B));
// ── Tick marks — L* axis (y direction) ───────────────────────────────────
for (const v of [25, 50, 75, 100]) {
axisScaffoldGroup.add(_line([-4, v, 0], [4, v, 0], TICK_MAT));
axisScaffoldGroup.add(_line([0, v, -4], [0, v, 4], TICK_MAT));
}
// ── Tick marks — a* axis (x direction) ───────────────────────────────────
for (const v of [-128, -64, 64, 128]) {
axisScaffoldGroup.add(_line([v, -3, 0], [v, 3, 0], TICK_MAT));
}
// ── Tick marks — b* axis (z direction) ───────────────────────────────────
for (const v of [-128, -64, 64, 128]) {
axisScaffoldGroup.add(_line([0, -3, v], [0, 3, v], TICK_MAT));
}
scene.add(axisScaffoldGroup);
// ── CSS2D axis labels ─────────────────────────────────────────────────────
if (typeof THREE.CSS2DObject !== 'undefined') {
const labels = [
// Primary axis name labels
{ text: 'L*', pos: [0, 110, 0 ], cls: 'gamut-axis-label gamut-axis-primary' },
{ text: '+a* (Red →)', pos: [140, 0, 0 ], cls: 'gamut-axis-label gamut-axis-a-pos' },
{ text: '← −a* (Green)', pos: [-140, 0, 0 ], cls: 'gamut-axis-label gamut-axis-a-neg' },
{ text: '+b* (Yellow →)', pos: [0, 0, 140], cls: 'gamut-axis-label gamut-axis-b-pos' },
{ text: '← −b* (Blue)', pos: [0, 0, -140], cls: 'gamut-axis-label gamut-axis-b-neg' },
// Tick value labels on L* axis
{ text: '25', pos: [10, 25, 0], cls: 'gamut-axis-label gamut-tick-label' },
{ text: '50', pos: [10, 50, 0], cls: 'gamut-axis-label gamut-tick-label' },
{ text: '75', pos: [10, 75, 0], cls: 'gamut-axis-label gamut-tick-label' },
{ text: '100', pos: [10, 100, 0], cls: 'gamut-axis-label gamut-tick-label' },
];
for (const { text, pos, cls } of labels) {
const div = document.createElement('div');
div.textContent = text;
div.className = cls;
const label = new THREE.CSS2DObject(div);
label.position.set(...pos);
axisScaffoldGroup.add(label);
}
}
}
// Helper: create a THREE.Line between two points
function _line(from, to, material) {
const geo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(...from),
new THREE.Vector3(...to)
]);
return new THREE.Line(geo, material);
}
// ─────────────────────────────────────────────────────────────────────────────
// Parse .gam file text → { vertices: [[L,a,b],...], faces: [[v0,v1,v2],...] }
// ─────────────────────────────────────────────────────────────────────────────
export function parseGamutFile(text) {
const lines = text.split('\n');
const vertices = []; // [[L, a, b], ...]
const faces = []; // [[v0, v1, v2], ...]
const vertices = [];
const faces = [];
let dataStarted = false;
let dataBlock = 0; // 0 = not in data, 1 = vertices, 2 = faces
let fieldCount = 0;
let dataBlock = 0; // 1 = vertices section, 2 = faces section
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === 'BEGIN_DATA') { dataBlock++; dataStarted = true; continue; }
if (trimmed === 'END_DATA') { dataStarted = false; continue; }
if (!dataStarted) continue;
if (trimmed.startsWith('NUMBER_OF_FIELDS')) {
fieldCount = parseInt(trimmed.split(/\s+/)[1], 10);
}
if (trimmed === 'BEGIN_DATA') {
dataBlock++;
dataStarted = true;
continue;
}
if (trimmed === 'END_DATA') {
dataStarted = false;
continue;
}
if (dataStarted) {
const parts = trimmed.split(/\s+/).map(Number);
if (dataBlock === 1 && parts.length >= 4) {
// Vertex: VERTEX_NO LAB_L LAB_A LAB_B
vertices.push([parts[1], parts[2], parts[3]]); // [L, a, b]
} else if (dataBlock === 2 && parts.length >= 3) {
// Face: VERTEX_0 VERTEX_1 VERTEX_2
faces.push([parts[0], parts[1], parts[2]]);
}
const parts = trimmed.split(/\s+/).map(Number);
if (dataBlock === 1 && parts.length >= 4) {
vertices.push([parts[1], parts[2], parts[3]]); // [L, a, b]
} else if (dataBlock === 2 && parts.length >= 3) {
faces.push([parts[0], parts[1], parts[2]]);
}
}
return { vertices, faces };
}
export function renderGamutFromText(text, color, isWireframe, previousMesh) {
// ─────────────────────────────────────────────────────────────────────────────
// Build a BufferGeometry from parsed gamut data
// Returns null if insufficient data.
// ─────────────────────────────────────────────────────────────────────────────
function _buildGeometry(vertices, faces) {
if (vertices.length < 4) return null;
const geometry = new THREE.BufferGeometry();
if (faces.length > 0) {
// Pre-triangulated mesh from .gam file
const posArr = new Float32Array(vertices.length * 3);
for (let i = 0; i < vertices.length; i++) {
const [L, a, b] = vertices[i];
posArr[i * 3] = a; // X = a*
posArr[i * 3 + 1] = L; // Y = L*
posArr[i * 3 + 2] = b; // Z = b*
}
const idxArr = new Uint32Array(faces.length * 3);
for (let i = 0; i < faces.length; i++) {
idxArr[i * 3] = faces[i][0];
idxArr[i * 3 + 1] = faces[i][1];
idxArr[i * 3 + 2] = faces[i][2];
}
geometry.setAttribute('position', new THREE.BufferAttribute(posArr, 3));
geometry.setIndex(new THREE.BufferAttribute(idxArr, 1));
geometry.computeVertexNormals();
} else {
// Fallback: convex hull from point cloud
const pts = vertices.map(v => ({ x: v[1], y: v[0], z: v[2] }));
const hullFaces = computeQuickHull(pts);
if (!hullFaces || hullFaces.length === 0) return null;
const vList = [];
const idxList = [];
const ptMap = new Map();
for (const f of hullFaces) {
for (const p of [f.a, f.b, f.c]) {
const key = `${p.x}_${p.y}_${p.z}`;
if (!ptMap.has(key)) { ptMap.set(key, vList.length / 3); vList.push(p.x, p.y, p.z); }
idxList.push(ptMap.get(key));
}
}
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vList), 3));
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(idxList), 1));
geometry.computeVertexNormals();
// Re-extract vertices for colour calculation below
const posAttr = geometry.getAttribute('position');
vertices = [];
for (let i = 0; i < posAttr.count; i++) {
// Note: stored as (a, L, b) — convert back to [L, a, b]
vertices.push([posAttr.getY(i), posAttr.getX(i), posAttr.getZ(i)]);
}
}
return { geometry, vertices };
}
// ─────────────────────────────────────────────────────────────────────────────
// Render sRGB reference gamut as EdgesGeometry outline + faint solid fill
// Returned as a THREE.Group stored in sRgbGroup
// ─────────────────────────────────────────────────────────────────────────────
function _renderSrgbReference(text, previousGroup) {
if (!scene) return null;
if (previousGroup) {
scene.remove(previousGroup);
previousGroup.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
});
}
const { vertices, faces } = parseGamutFile(text);
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) {
@@ -136,61 +349,31 @@ export function renderGamutFromText(text, color, isWireframe, previousMesh) {
}
const { vertices, faces } = parseGamutFile(text);
if (vertices.length < 4) return null;
const built = _buildGeometry(vertices, faces);
if (!built) return null;
let geometry = new THREE.BufferGeometry();
const { geometry } = built;
let verts = built.vertices;
if (faces.length > 0) {
// We have a pre-triangulated mesh
const positionArray = new Float32Array(vertices.length * 3);
for (let i = 0; i < vertices.length; i++) {
const [L, a, b] = vertices[i];
positionArray[i * 3] = a; // x = a*
positionArray[i * 3 + 1] = L; // y = L*
positionArray[i * 3 + 2] = b; // z = b*
}
const indexArray = new Uint32Array(faces.length * 3);
for (let i = 0; i < faces.length; i++) {
indexArray[i * 3] = faces[i][0];
indexArray[i * 3 + 1] = faces[i][1];
indexArray[i * 3 + 2] = faces[i][2];
}
geometry.setAttribute('position', new THREE.BufferAttribute(positionArray, 3));
geometry.setIndex(new THREE.BufferAttribute(indexArray, 1));
geometry.computeVertexNormals();
} else {
// Fallback to point cloud hull if no faces are present
const pts = vertices.map(v => ({ x: v[1], y: v[0], z: v[2] })); // { x: a, y: L, z: b }
const hullFaces = computeQuickHull(pts);
if (!hullFaces || hullFaces.length === 0) return null;
const verticesList = [];
const indices = [];
const ptMap = new Map();
for (const f of hullFaces) {
for (const p of [f.a, f.b, f.c]) {
const key = `${p.x}_${p.y}_${p.z}`;
if (!ptMap.has(key)) {
ptMap.set(key, verticesList.length / 3);
verticesList.push(p.x, p.y, p.z);
}
indices.push(ptMap.get(key));
}
}
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(verticesList), 3));
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1));
geometry.computeVertexNormals();
// ── Per-vertex colour: Lab → sRGB ─────────────────────────────────────────
// geometry 'position' is laid out as [a, L, b] per vertex
const posAttr = geometry.getAttribute('position');
const colorArr = new Float32Array(posAttr.count * 3);
for (let i = 0; i < posAttr.count; i++) {
const a_star = posAttr.getX(i);
const L_star = posAttr.getY(i);
const b_star = posAttr.getZ(i);
const [r, g, b] = labToSrgb(L_star, a_star, b_star);
colorArr[i * 3] = r / 255;
colorArr[i * 3 + 1] = g / 255;
colorArr[i * 3 + 2] = b / 255;
}
geometry.setAttribute('color', new THREE.BufferAttribute(colorArr, 3));
const material = new THREE.MeshLambertMaterial({
color: color,
wireframe: isWireframe,
vertexColors: true,
transparent: true,
opacity: isWireframe ? 0.35 : 0.75,
opacity: 0.88,
side: THREE.DoubleSide
});
@@ -199,26 +382,62 @@ export function renderGamutFromText(text, color, isWireframe, previousMesh) {
return mesh;
}
// ─────────────────────────────────────────────────────────────────────────────
// Public: load bundled sRGB reference gamut
// ─────────────────────────────────────────────────────────────────────────────
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) {
// ─────────────────────────────────────────────────────────────────────────────
// Public: load and render a profile gamut from a .gam file path
// ─────────────────────────────────────────────────────────────────────────────
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 to the toggle functions
// ─────────────────────────────────────────────────────────────────────────────
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));
}
}
+67 -3
View File
@@ -10,6 +10,11 @@ let stage1Cwd = "";
let currentManifest = null;
let discoveredPrinters = [];
// Captured CUPS options from the native macOS print panel, keyed by printer
// name. These are fed back into print_target_native so the lp job uses the
// user's media type / quality / color-bypass selections.
let capturedCupsOptions = {};
let updateLabelPreviewFn = null;
/**
@@ -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}`);
+207
View File
@@ -1349,3 +1349,210 @@ 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-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;
}