Files
iccery-v2-mac/docs/11-print-macos.md
gronod b89d9777ad
macOS CI / build-and-test (push) Skipped
docs(print): native spool documentation update + issue #201 analysis (#201 Phase 6)
- Create docs/issue-printer-quality-ignored.md: lifecycle trace, ticket-loss and override-inversion root causes with file:line evidence, dismissed argv tokenisation hypothesis, locked D1-D13 table.
- docs/11: retitle walkthrough to UI click to NSPrintOperation; replace build_lp_args/print_target with the S1-S14 native-spool trace; add layers 5' (Quartz vocabulary) and 7 (ticket serialise/restore); mark lp flag table and macOS test list historical v1.
- docs/14: decision table rows (Spool, ColorSync ticket, Geometry, Interpolation, AirPrint) marked adopted in v2.0 via #201; note section 7 Quartz vocabulary now live in ICCery proper.
- AGENTS.md: spooler documented in app target (D1); new Print spool section (lp eradicated, parsers retained per D4, ICCERY_TEST_SPOOL_LOG seam); ColorSync SPI rule replaced by the D2 single-path dual-vocabulary rule.
- Eradication sweep: label remaining lp/LpArgs references historical v1 in docs/10, docs/13, docs/25, docs/26; drop deleted LpArgs from README.
2026-09-17 17:40:25 +01:00

416 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 11 — macOS printing and ColorSync suppression
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
This is the most implementation-sensitive chapter. A rewrite that opens System Settings or the CUPS web UI instead of `NSPrintPanel` will regress #188.
## macOS (`src-tauri/src/print/macos.rs`) — MOST DETAIL
864 lines. This is the ColorSync-suppression engine built for #92 and rewritten for #188.
### What Preferences opens — NSPrintPanel vs CUPS web UI vs System Settings
**Issue #188 (user report):** clicking Preferences opened **System Settings → Printers & Scanners** and did not bind the selected Epson/Canon queue. No driver PDE, so no "Off (No Color Adjustment)".
**Current code (AGENTS.md:105-118, macos.rs:245-516, 679-710):** Preferences opens a **native AppKit `NSPrintPanel`**, pre-bound to the selected CUPS destination via Core Printing. It is **not**:
- CUPS web UI (`http://localhost:631/printers/…`)
- System Settings → Printers & Scanners
- `NSWorkspace` open of the printer
- `lpoptions` GUI
Linux/Windows do not share this panel. Default button title is **"Use Settings"** (macos.rs:439) — this is a settings-capture dialog, not a print-now dialog. Actual spooling is a later, separate step — in v2.0 a headless `NSPrintOperation` (#201; v1 used `lp`).
Must run on the Cocoa main thread. `show_printer_properties` (macos.rs:689-710):
1. Resolve `display_name` **off** the main thread via `unix::get_printer_display_name` (`lpoptions -p`).
2. `app.run_on_main_thread` → `run_native_print_panel`.
3. `tokio::sync::oneshot` awaits the result.
### objc2 crates (`Cargo.toml:42-47`, AGENTS.md:120-126)
```
objc2 0.6 MainThreadMarker
objc2-app-kit 0.3.2 features = ["NSPrintInfo", "NSPrintPanel", "NSPrinter"]
objc2-foundation 0.3.2 NSString
objc2-core-foundation 0.3.2 CFString, CFType
objc2-application-services 0.3.2 features = ["PMCore", "PMDefinitions", "PrintCore"]
PMPrintSession, PMPrinter, PMPrintSettings, PMPageFormat, …
```
Local `extern "C" { fn free; fn dlsym; }`. No `libloading` crate.
### Binding the selected printer (macos.rs:276-347)
CUPS destination IDs ≠ NSPrinter display names. Core Printing "Printer ID" **is** the CUPS queue name.
```
printer_id_cf = CFString(printer_name)
pm_printer = PMPrinterCreateFromPrinterID(&printer_id_cf)
if pm_printer is null:
fallback NSPrinter::printerWithName(display_name)
print_info.setPrinter(ns_printer)
print_info.setUpPrintOperationDefaultValues()
if that also fails → Err("No printer found for '…'")
pm_session = print_info.PMPrintSession()
pm_settings = print_info.PMPrintSettings()
pm_page_format = print_info.PMPageFormat()
if pm_printer was created from ID:
PMSessionSetCurrentPMPrinter(pm_session, pm_printer) // non-zero → Err
PMSessionDefaultPrintSettings(pm_session, pm_settings) // warn on fail
PMSessionDefaultPageFormat(pm_session, pm_page_format) // warn on fail
```
`Printer.display_name` is the CUPS `printer-info` cached at enumeration. That is what System Settings shows ("Epson XP-55 Series") while `printer_name` is the queue id (`EPSON_XP_55_Series`). #188 failed because the old path used the wrong name with the wrong API.
`PMPrinter` from `PMPrinterCreateFromPrinterID` is released with `PMRelease` on all exit paths (cancel, error, success). The NSPrinter fallback path leaves `pm_printer` null so no release.
### ColorSync suppression strategy — UI click to `NSPrintOperation`
End-to-end, **seven independent layers**. All of them exist because no single Apple API is sufficient across Epson PDE / Canon PDE / `cgpdftoraster` / CUPS. v2.0 (#201) replaced the `lp` tail with a headless `NSPrintOperation` that replays a captured `PMPrintSettings`/`PMPageFormat` ticket — layer ⑦ is new.
```
[Preferences click]
PrintSessionViewModel.openPrinterPreferences
PrintPanelService.showProperties
runNativePanel
① PMPrinterCreateFromPrinterID + PMSessionSetCurrentPMPrinter bind queue
② set_session_color_matching_mode SPI gray out PDE Color Matching
③ PMPrintSettingsSetValue AP_ColorMatchingMode + dotted
④ detect_driver_color_bypass → SetValue pre-select Canon/Epson/Gutenprint "off"
⑤ NSPrintInfo.printSettings dictionary same keys for AppKit PDEs
⑤′ ColorSyncSuppressor.applyQuartzMode PMColorMatchingMode + legacy + nested mirror
NSPrintPanel.runModal("Use Settings")
user picks media / quality (color locked)
⑥ PMPrintSettingsToOptions → filter → Stage 2 mirror
⑦ PMTicketBridge.serialise(printInfo) → PrintTicket ticket capture
[frontend]
capturedCupsOptions[queue] = mirror + capturedTickets[queue] = ticket
[Print Target]
PrintSessionViewModel → TargetPrintRequest → NativeTargetSpooler.spool
restore ticket → Stage 2 writes → NSPrintOperation.runOperation
(S1–S14 below — 1:1, device colour space, panels off)
```
Linux uses `-o raw` instead of AP_* flags. macOS **does not** use `-o raw`: a raw queue would skip the raster filter that actually understands `AP_ColorMatchingMode`. The macOS strategy is "tell the filter the application already matched color", not "skip the filter".
### Layer ② — private SPI via `dlsym` (`set_session_color_matching_mode`, macos.rs:32-130)
Used by Photoshop, Lightroom, X-Rite i1Profiler to gray-out Color Matching in driver PDEs. Undocumented; resolved at runtime so the binary does not hard-link a private symbol.
```
RTLD_DEFAULT = (-2isize) as *mut c_void
dlsym(RTLD_DEFAULT, "PMSessionSetColorMatchingModeLock")
dlsym(RTLD_DEFAULT, "PMSessionSetColorMatchingMode")
dlsym(RTLD_DEFAULT, "PMSessionSetColorMatchingModeNoLock")
```
**Signature (all three):**
```c
OSStatus PMSessionSetColorMatchingModeLock (PMPrintSession, CFStringRef mode);
OSStatus PMSessionSetColorMatchingMode (PMPrintSession, CFStringRef mode);
OSStatus PMSessionSetColorMatchingModeNoLock(PMPrintSession, CFStringRef mode);
```
Rust:
```rust
type ModeFn = unsafe extern "C" fn(PMPrintSession, *const CFString) -> i32;
```
`status == 0` is success (`noErr`).
**Priority:**
1. `PMSessionSetColorMatchingModeLock` — sets **and locks** the UI in one call (controls grayed).
2. `PMSessionSetColorMatchingMode` — sets mode; lock behaviour unspecified.
3. `PMSessionSetColorMatchingModeNoLock` — sets without locking.
For each symbol, try mode strings in order:
```
"AP_ApplicationColorMatching" // kPMApplicationColorMatching
"ApplicationColorMatching" // unprefixed alias
```
First `(symbol, mode)` that returns 0 wins; then break both loops.
**Preconditions:** `pm_session` non-null; `PMSessionGetCurrentPrinter` succeeds with a non-null printer. Otherwise skip SPI (session has no printer to attach a PDE to).
**Stub on non-macOS:** always `false` so unit tests compile.
If all fail: log `"All PMSessionSetColorMatchingMode calls failed; color controls may not be grayed"` and fall through to public `PMPrintSettingsSetValue`.
### Why `AP_ColorSyncMatching` and `AP_VendorColorMatching` are avoided
AGENTS.md:118, macos.rs:82-88:
> Only application-managed color-matching modes are appropriate for profiling targets. ColorSync or vendor modes would apply color management and corrupt the target patches.
| Mode string | Effect on a profiling target |
|-------------|------------------------------|
| `AP_ApplicationColorMatching` / `ApplicationColorMatching` | **Used.** "Application already did color." Filters/PDEs must not transform pixels. UI Color Matching grayed. |
| `AP_ColorSyncMatching` | **Avoided.** ColorSync applies the printer/display profile. Patches become color-managed. |
| `AP_VendorColorMatching` | **Avoided.** Epson/Canon driver color engine (ICM inside the PDE). Same corruption. |
ICCery-CPU uses a **different** vocabulary (`APCustomColorMatching` / `APColorSync` / `APPrinterExtension` on `PMColorMatchingMode`). See the CPU section. v1 kept the two dictionaries on separate paths (`lp` vs Quartz); v2.0 (#201, D2) has a single native path that writes **both** vocabularies — layer ⑤′ below.
### Layer ③ — `PMPrintSettingsSetValue` (macos.rs:357-375)
```
key = "AP_ColorMatchingMode" and "AP.ColorMatchingMode"
val = "AP_ApplicationColorMatching"
lock = true
PMPrintSettingsSetValue(pm_settings, key, val, true)
```
The dotted `AP.ColorMatchingMode` is a **legacy form used by some raster drivers and PDEs** (macos.rs:540-541). Both are always written.
### Layer ④ — pre-select driver "no color adjustment" in the panel (macos.rs:377-397, 422-432)
`lpoptions -p <printer> -l` → `unix::detect_driver_color_bypass`:
| Driver | Key | Value |
|--------|-----|-------|
| Canon | `CNIJIntent2` | `4` |
| Canon (old) | `CNIJIntent` | `4` |
| Epson | `EPIJ_CCor` | `0` (if that key exists, preferred) |
| Epson | `EPIJ_CMat` | `3` |
| Gutenprint | `StpColorCorrection` | `Uncorrected` |
| generic | `ColorCorrection` | `Uncorrected` |
| Epson alt | `EpsonColorMode` | `Off` |
Written twice: `PMPrintSettingsSetValue(..., lock=false)` **and** `print_info.printSettings().insert`. Lock is false so the user can still change media/quality; only color matching is intended to be locked by the SPI.
### Layer ⑤ — `NSPrintInfo.printSettings` dictionary (macos.rs:399-432)
AppKit PDEs and some raster drivers read this `NSMutableDictionary`, not the PM object.
```
print_info.updateFromPMPageFormat()
print_info.updateFromPMPrintSettings()
print_settings.insert("AP_ColorMatchingMode", "AP_ApplicationColorMatching")
print_settings.insert("AP.ColorMatchingMode", "AP_ApplicationColorMatching")
print_settings.insert(<bypass_key>, <bypass_val>)
```
`NSString` is transmuted to `&AnyObject` for the dictionary (`macos.rs:414-416`).
### Layer ⑤′ — Quartz vocabulary (v2.0, #201 D2)
With `lp` gone there is a single native path, and it carries **both**
dictionaries. `ColorSyncSuppressor.applyQuartzMode` additionally writes the
Quartz/`NSPrintOperation` vocabulary (docs/14 §7):
- `PMColorMatchingMode` = `APCustomColorMatching`
- `PMCustomColorMatchingProfile` = `""`
- `com.apple.print.PrintSettings.PMColorMatchingMode` (legacy)
- the same keys inside the nested `com.apple.print.printSettings`
sub-dictionary of `printInfo.dictionary()`
### Panel options (macos.rs:434-449)
```
NSPrintPanel::printPanel(mtm)
opts = NSPrintPanelOptions::all()
opts.insert(ShowsPageSetupAccessory)
panel.setOptions(opts)
panel.setDefaultButtonTitle(Some("Use Settings"))
response = panel.runModalWithPrintInfo(&print_info)
```
`response != 1` (`NSModalResponseOK` / `NSOKButton`) → **`Ok(None)`** (cancellation is not an error). `PMPrinter` released.
### Layer ⑥ — `PMPrintSettingsToOptions` capture (macos.rs:452-515)
After OK:
1. `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → `selected_printer` string.
2. `panel.printInfo().PMPrintSettings()`.
3. `PMPrintSettingsToOptions(updated_settings, &mut opts_ptr)` — CUPS malloc'd C string of `key=value key=value …`.
4. Copy via `CStr`, `free(opts_ptr)`.
5. `filter_cups_options_string` → `cups_options`.
6. `extract_media_type_from_options` → `media_type`.
7. Return `PrintPropertiesResult { selected_printer, options: PrintOptions { media_type, cups_options, ppd_uncorrected_passthrough: Some(true), ..Default } }`.
Failure of `PMPrintSettingsToOptions` is a hard `Err`.
### Layer ⑦ — ticket serialise/restore (v2.0, #201 D3)
The layer-⑥ flattening is what lost the ticket (#201 root cause 1): it kept
only a `key=value` string and `CupsOptionsFilter` drops every `com.apple.*`
key, so `com.apple.print.PrintSettings` never survived. After the layer-⑥
capture, `PMTicketBridge.serialise(printInfo, queue:)` now snapshots the
whole ticket:
1. `PMPrintSettingsCreateDataRepresentation(settings, &data, kPMDataFormatXMLDefault)` → `PrintTicket.printSettings`.
2. `PMPageFormatCreateDataRepresentation` → `PrintTicket.pageFormat`.
3. Binary-plist snapshot of `NSPrintInfo.dictionary()`, plist-filtered → `PrintTicket.printInfoPlist` — fallback only, never the primary restore path.
`PrintTicket` is in-memory, session-only, keyed by queue. On spool,
`PMTicketBridge.restore` replays it into the job's `NSPrintInfo`:
`PM*CreateWithDataRepresentation` → `PMCopy*` → `PMSessionValidate*` →
`updateFromPM*`. A ticket captured for queue A is never replayed onto
queue B.
### `RELEVANT_CUPS_OPTION_KEYS` (macos.rs:142-174)
Captured from the panel into the Stage 2 mirror (v1 forwarded them to `lp`):
```
Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType
Tray: InputSlot, AP_D_InputSlot
Size: PageSize
Color: CNIJIntent2, CNIJIntent, EPIJ_CMat, EPIJ_CCor, EPIJ_OSColMat,
ColorCorrection, StpColorCorrection, EpsonColorMode, ColorModel
Quality: Resolution, cupsPrintQuality, Quality, EPIJ_Quality, CNIJQuality,
StpQuality, OutputMode
Duplex: Duplex, sides
```
`is_relevant_cups_option` (macos.rs:178-204) additionally:
- Drops empty keys
- Drops `com.apple.*` ticket keys
- Drops `AP_ColorMatchingMode` and `AP.ColorMatchingMode` (we always set those ourselves)
- Drops empty values (`AP_D_InputSlot=`)
- Drops `collate`, `copies`, `pserrorhandler-requested`, `job-sheets`
- **Keeps unknown non-`com.*` keys** (permissive: unknown driver keys survive)
### Native spool — S1–S14 (v2.0, #201)
`NativeTargetSpooler.spool(_:)` (app target, `Sources/ICCery/Print/` — `ICCeryCore` stays AppKit-free, D1). One `TargetPrintRequest` per page, or one per run when `singleJobForAllPages` is on (D5):
```
S1 NSPrintInfo()
S2 printInfo.printer = NSPrinter(name: queue) ?? NSPrinter(name: displayName) [best effort]
S3 PMTicketBridge.makePrinter(queue:) → bind(printer:to:) ① + PMSessionDefault*
S4 ticket != nil → PMTicketBridge.restore(ticket, into: printInfo) ⑦′
S5 TicketWriteResolver.resolve(...) → apply to PMPrintSettings + mirror dict ③④⑤+D2
S6 paper override → PMTicketBridge.applyPaper(token:…)
S7 orientation → printInfo.orientation + orientation-requested
S8 suppressor.applySPIMode(session) ②
S9 Cocoa geometry: margins 0, pagination .clip, scaling 1.0, centering off,
jobDisposition .spool (or .save + jobSavingURL under the PDF harness)
S10 TargetRasterLoader.load(each page) → device-tagged CGImage + pointSize
S11 TargetPageCanvasView(pages:paperSize: printInfo.paperSize)
S12 NSPrintOperation(view:printInfo:) — panels off, jobTitle set
S13 operation.runOperation() → false ⇒ throw TargetSpoolError.operationFailed
S14 PMRelease the printer on every path (defer)
```
`TicketWriteResolver` produces the resolved write list as a pure value, in a
locked order: both `AP_*` keys (locked) → the three Quartz keys (⑤′) →
`PageSize` → the detected media key → the detected quality key → the driver
colour bypass → `orientation-requested`. Stage 2 overrides **always win**
over the rehydrated ticket (D6 — the v1 captured-wins inversion is gone);
`raw` can never appear because there is no `lp`. Panels stay off
(`showsPrintPanel` / `showsProgressPanel` false, `canSpawnSeparateThread`
false); a multi-page job is one `TargetPageCanvasView` driven by
`knowsPageRange` / `rectForPage`, drawing each page 1:1 top-left anchored
with interpolation and antialiasing disabled (D9, docs/14 §6).
### Cancellation as `None`
`run_native_print_panel` → `Ok(None)` on cancel. `show_printer_properties` propagates that. Frontend: `if (result === null) { showNotification("info", "Printer properties dialog cancelled."); return; }`.
### Preferences crash / #188
#188 was not a segfault; it was a **wrong UI**: System Settings instead of the driver PDE, so color management could not be disabled on Epson XP-55 / Canon Pro 9500 II.
The rewrite:
- Bind by CUPS Printer ID (`PMPrinterCreateFromPrinterID` + `PMSessionSetCurrentPMPrinter`), not by opening System Settings.
- `display_name` fallback for `NSPrinter::printerWithName`.
- Private SPI to lock Color Matching.
- Dual AP_* keys (underscore + dotted).
- Driver-specific PPD bypass pre-selected in the panel and re-applied on the spool ticket (v1 re-applied it on `lp`).
- Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions` (Stage 2 mirror) plus the `PrintTicket` serialise (layer ⑦, v2.0).
`PMPrinter` lifetime is explicit `PMRelease` on every path. SPI is `dlsym`'d so missing symbols on old OS X do not prevent launch. Panel **must** be main-thread (`MainThreadMarker::new().ok_or("Print panel must be invoked on the main thread")`).
### macOS tests — historical v1 (macos.rs:712-863 + tests.rs:278-318)
- Filter drops `com.apple.*`, `collate`, `copies`, `AP_ColorMatchingMode`, empty `AP_D_InputSlot`; keeps `MediaType`, `EPIJ_CMat`, `PageSize`, `CNIJIntent2`, `ColorCorrection`.
- `extract_media_type_from_options` prefers `MediaType` then `EPIJ_Medi`.
- `build_lp_args` always contains both AP_* flags; captured options win over explicit `media_type` / orientation / auto color-bypass; last arg is the TIFF path. (**Historical v1** — the captured-wins behaviour recorded here is #201 root cause 2; v2.0 inverts it: Stage 2 always wins, D6.)
- `detect_driver_color_bypass` Canon `4`, Epson `3`, Gutenprint `Uncorrected`.
- Missing TIFF errors.
v2.0 replacements (`TicketWriteResolverTests`, `PrintTicketTests`,
`TargetRasterTests`, `TargetCanvasGeometryTests`, `NativeSpoolPDFTests`):
both AP_* keys locked + all three Quartz keys always present; Stage 2
always wins (D6); `raw` never emitted; ticket serialise/restore
round-trips bytes; 1:1 geometry and draw flags asserted against a real
`.save`-to-PDF `NSPrintOperation`.
---
## ColorSync suppression — complete key/SPI/flag roster
### Private SPI symbols (dlsym, macOS only)
```
PMSessionSetColorMatchingModeLock (PMPrintSession, CFStringRef) -> i32
PMSessionSetColorMatchingMode (PMPrintSession, CFStringRef) -> i32
PMSessionSetColorMatchingModeNoLock (PMPrintSession, CFStringRef) -> i32
```
Mode strings attempted: `AP_ApplicationColorMatching`, `ApplicationColorMatching`.
Public Core Printing used around the SPI:
```
PMPrinterCreateFromPrinterID
PMSessionSetCurrentPMPrinter
PMSessionGetCurrentPrinter
PMSessionDefaultPrintSettings
PMSessionDefaultPageFormat
PMPrintSettingsSetValue
PMPrintSettingsToOptions
PMPrinterGetID
PMRelease
```
AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPanelOptions::all` + `ShowsPageSetupAccessory`.
### CUPS / lp flags — historical v1
> No `lp` invocation exists on the v2.0 target-print path (#201 — `LpArgs`,
> `CupsService.printTarget` and the `lp` fixture are deleted). This table
> records the v1 `lp -o` contract for reference only; the live write list is
> `TicketWriteResolver`'s locked order (§native spool above).
| Flag | Platform | When |
|------|----------|------|
| `-o AP_ColorMatchingMode=AP_ApplicationColorMatching` | macOS | always |
| `-o AP.ColorMatchingMode=AP_ApplicationColorMatching` | macOS | always |
| `-o CNIJIntent2=4` | macOS (auto) / if captured | Canon |
| `-o CNIJIntent=4` | macOS auto if no Intent2 | Canon old |
| `-o EPIJ_CCor=0` | macOS auto if present in lpoptions | Epson |
| `-o EPIJ_CMat=3` | macOS auto | Epson Off |
| `-o StpColorCorrection=Uncorrected` | macOS auto | Gutenprint |
| `-o ColorCorrection=Uncorrected` | macOS auto | generic |
| `-o EpsonColorMode=Off` | macOS auto | Epson alt |
| `-o CNIJMediaType=` / `EPIJ_Medi=` / `StpMediaType=` / `MediaType=` | macOS (detected key); Linux always `MediaType=` | media |
| `-o PageSize=` | macOS/Linux | if set |
| `-o orientation-requested=3\|4` | macOS/Linux | portrait/landscape |
| `-o raw` | **Linux only** | default |
| `-o ColorModel=Gray -o cm-calibration` | **Linux only** | PPD fallback checkbox |
| captured `InputSlot`, `AP_D_InputSlot`, `Resolution`, `cupsPrintQuality`, `Quality`, `EPIJ_Quality`, `CNIJQuality`, `StpQuality`, `OutputMode`, `Duplex`, `sides`, `EPIJ_OSColMat`, `ColorModel` | macOS | if panel produced them |
### PPD keys (detection)
```
Media: CNIJMediaType, EPIJ_Medi, StpMediaType, MediaType, MediaSource, InputSlot, PageSize, MediaSize
Color: CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, EpsonColorMode
```
---