Files
iccery-v2-mac/docs/10-print-system.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

185 lines
11 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.
# 10 — Print system (shared types & UI)
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
## Architecture map
```
Stage 2 UI (src/js/printtarg.js + src/index.html #rawPrintPanel)
│ invoke("get_printers")
│ invoke("get_printer_capabilities", { printerName })
│ invoke("show_printer_properties", { printerName })
│ invoke("print_target_native", { printerName, tiffPath, options })
▼
src-tauri/src/commands.rs (cfg-gated dispatch)
│
├── windows → print/windows.rs GDI + DEVMODE + SetICMMode(ICM_OFF)
├── macos → print/macos.rs NSPrintPanel + private SPI + lp
└── unix → print/unix.rs lpstat/lpoptions + lp -o raw
```
Shared types live in `src-tauri/src/print/mod.rs`. `PrinterDevModeStore` is a Tauri managed state (`lib.rs:53`). macOS re-exports Unix enumeration (`macos.rs:10`).
ICCery-CPU (`TargetPrint.app`) is a **separate** AppKit process that ICCery is specified to spawn fire-and-forget with `--job <json>`. It is **not yet wired** into the Tauri commands; the current ICCery print path is in-process / `lp`.
---
## Shared print types (`src-tauri/src/print/mod.rs`)
All structs derive `Debug, Clone, Serialize, Deserialize`. Frontend JS uses **snake_case** field names when building the `PrintOptions` payload (see `printtarg.js:268-275`), matching serde's default.
### `Printer` (mod.rs:6–13)
| Field | Type | Meaning |
|-------|------|---------|
| `name` | `String` | OS queue / destination name. Windows: `PRINTER_INFO_4W.pPrinterName`. Unix/macOS: CUPS destination from `lpstat -e` / `-p`. This is the value sent back as `printerName` to all subsequent commands. |
| `status` | `String` | Windows always `"Ready"`. Unix: `"Idle"` / `"Printing"` / `"Stopped"` / `"Unknown"` from `lpstat -p`. |
| `is_default` | `bool` | Windows: always `false` (default printer is **not** queried). Unix: true when name matches `lpstat -d` (`system default destination:`). |
| `display_name` | `Option<String>` | Human-readable CUPS `printer-info` from `lpoptions -p <name>`. Windows always `None`. Used on macOS as `NSPrinter::printerWithName` fallback when `PMPrinterCreateFromPrinterID` fails (#188). Serde `#[serde(default)]`. |
AGENTS.md warns: adding fields requires updating **every** platform constructor (`windows.rs`, `unix.rs`) or the other OS builds break.
### `PrinterTray` (mod.rs:16–19)
| Field | Type | Meaning |
|-------|------|---------|
| `id` | `u16` | Windows: Win32 bin ID from `DC_BINS`. Unix: 1-based index of the `InputSlot` / `MediaSource` value (not a PPD code). |
| `name` | `String` | Windows: 24-WCHAR `DC_BINNAMES` string, or `"Tray {id}"` if empty. Unix: PPD choice with leading `*` stripped (e.g. `Auto`, `Upper`). |
### `PrinterPaperSize` (mod.rs:22–25)
| Field | Type | Meaning |
|-------|------|---------|
| `id` | `u16` | Windows: `DC_PAPERS` DMPAPER_* id. Unix: 1-based index of `PageSize` / `MediaSize` choice. |
| `name` | `String` | Windows: 64-WCHAR `DC_PAPERNAMES`, or `"Paper Size {id}"`. Unix: PPD token (`A4`, `Letter`, …). |
**Gap:** the frontend never populates a paper-size `<select>` from `caps.paper_sizes`. Stage 2's `#pageSizeSelect` is the **printtarg layout** page size (mm), not the printer's PPD `PageSize`. `PrintOptions.paper_size` is set from that same `#pageSizeSelect` (`printtarg.js:271`) and forwarded to `lp -o PageSize=` on Unix/macOS. Windows `apply_print_options_to_devmode` **ignores** `paper_size`.
### `PrinterMediaType` (mod.rs:28–31)
| Field | Type | Meaning |
|-------|------|---------|
| `id` | `String` | Windows: `DC_MEDIATYPES` DWORD as decimal string (parsed back to `u32` for `dmMediaType`). Unix: PPD choice token (`92`, `13`, `Plain`, …). When a PPD file is readable, `id` is the machine token and `name` is the human label after `/`. |
| `name` | `String` | Display label. Unix `lpoptions -l` path uses the same token for both. Unix PPD path (`parse_ppd_media_types`) splits `*CNIJMediaType 42/Photo Paper Plus Semi-gloss:` into id=`42`, name=`Photo Paper Plus Semi-gloss`. |
### `PrinterCapabilities` (mod.rs:34–40)
| Field | Type | Meaning |
|-------|------|---------|
| `trays` | `Vec<PrinterTray>` | Paper sources. |
| `paper_sizes` | `Vec<PrinterPaperSize>` | Queried but unused by the Stage 2 UI. |
| `media_types` | `Vec<PrinterMediaType>` | `#[serde(default)]`. Frontend fills `#printerMediaTypeSelect`. |
| `supports_orientation` | `bool` | Always `true` on both Windows and Unix. |
### `PrintOptions` (mod.rs:42–55) — `Default` + `PartialEq + Eq` — historical v1
> The `lp`/`-o` columns below describe the v0.8.5 spool contract. v2.0
> replaced the macOS `lp` path with a headless `NSPrintOperation` (#201).
| Field | Type | Windows | Linux | macOS |
|-------|------|---------|-------|-------|
| `paper_source` | `Option<u16>` | `dmDefaultSource` (tray id) | **ignored** (unix `build_lp_args` never emits `InputSlot`) | **ignored** unless present in captured `cups_options` |
| `orientation` | `Option<String>` | `"landscape"` → `DMORIENT_LANDSCAPE` (2), else portrait (1) | `-o orientation-requested=4` (landscape) or `=3` (portrait) | same, skipped if `cups_options` already has the key |
| `paper_size` | `Option<String>` | **ignored** | `-o PageSize=<value>` | same, skipped if `pagesize` already added |
| `media_type` | `Option<String>` | parsed as `u32` → `dmMediaType` | `-o MediaType=<value>` (generic key only) | uses `detect_media_type_key` (`CNIJMediaType` / `EPIJ_Medi` / `StpMediaType` / `MediaType`); skipped if any of those keys already in `cups_options` |
| `ppd_uncorrected_passthrough` | `Option<bool>` | **ignored** | if true: `-o ColorModel=Gray -o cm-calibration` instead of `-o raw` | **ignored as a gate**; macOS always ColorSync-bypasses and always injects driver color-bypass. Native panel sets this to `Some(true)` on OK. |
| `cups_options` | `Option<String>` | **ignored** | **ignored** | space-separated `key=value` captured from `PMPrintSettingsToOptions`, filtered, then expanded to `-o key=value`. `#[serde(default)]`. |
### `PrintPropertiesResult` (mod.rs:57–61)
Returned by `show_printer_properties` on macOS only.
| Field | Type | Meaning |
|-------|------|---------|
| `selected_printer` | `Option<String>` | CUPS Printer ID from `PMPrinterGetID` after the panel, or the original `printer_name` if the session printer cannot be read but `PMPrinterCreateFromPrinterID` had succeeded. `None` if the panel was opened via the `NSPrinter::printerWithName(display_name)` fallback. |
| `options` | `PrintOptions` | Snapshot: `media_type` extracted from captured options, `cups_options` filtered string, `ppd_uncorrected_passthrough: Some(true)`, everything else `Default`. |
Windows `show_printer_properties` returns `Ok(None)` from the Tauri command after storing DEVMODE (the inner fn returns `Result<(), String>`). Linux returns `Ok(None)` with no dialog. Frontend treats `null` as **user cancelled** (`printtarg.js:390-393`) — so on Windows a successful Preferences OK **also** shows "Printer properties dialog cancelled." That is a known UX mismatch: Windows success and cancel both surface as `null`.
### `PrinterDevModeStore` (mod.rs:63–87)
```rust
pub struct PrinterDevModeStore {
pub devmodes: Arc<Mutex<HashMap<String, Vec<u8>>>>,
}
```
- Keyed by printer name.
- Stores the **full** `DocumentPropertiesW` output buffer (public `DEVMODEW` + `dmDriverExtra` private OEM bytes). This is the #36 fix.
- `get` clones; `set` inserts. Poisoned mutex → `get` returns `None`, `set` is a no-op.
- `#[allow(dead_code)]` because the type is compiled on all platforms; only Windows uses it.
- Constructed once in `lib.rs:53`: `.manage(print::PrinterDevModeStore::new())`.
- Session-only. Not persisted to disk.
### Module cfg gates (mod.rs:89–99)
```
#[cfg(windows)] pub mod windows;
#[cfg(any(target_os = "macos", test))] pub mod macos;
#[cfg(any(unix, test))] pub mod unix;
#[cfg(test)] mod tests;
```
macOS **is** unix, so both `macos` and `unix` compile on macOS. Tests always compile both `macos` and `unix`. `macos.rs` `pub use`s `unix::{get_printer_capabilities, get_printers}`.
---
## Tauri command surface (`src-tauri/src/commands.rs:1304–1415`)
Registered in `lib.rs:92–95`.
| Command | Args | Windows | macOS | Linux |
|---------|------|---------|-------|-------|
| `get_printers` | none | `windows::get_printers` | `macos::get_printers` (= unix) | `unix::get_printers` |
| `get_printer_capabilities` | `printer_name: String` | DeviceCapabilitiesW | unix lpoptions/PPD | unix lpoptions/PPD |
| `show_printer_properties` | `printer_name: String` + `AppHandle` + `State<PrinterDevModeStore>` | `DocumentPropertiesW` modal; returns `Ok(None)` | `NSPrintPanel` on main thread; returns `Option<PrintPropertiesResult>` | **no-op** `Ok(None)` |
| `print_target_native` | `printer_name`, `tiff_path`, `options: Option<PrintOptions>` | GDI path, passes store | `macos::print_target` → `lp` | `unix::print_target` → `lp` |
IPC argument casing is camelCase at the Tauri boundary (`printerName`, `tiffPath`) and snake_case inside the nested `PrintOptions` struct because the frontend builds that object itself.
---
## Frontend (`src/js/printtarg.js` + `src/index.html`)
### Stage 2 print UI (`index.html:568–638`)
Hidden `#rawPrintPanel` revealed after `printtarg` succeeds and a JSON manifest is parsed.
- `#printerSelect` + `#btnRefreshPrinters` (↻) + `#btnPrinterProperties` (⚙️ Preferences, class `.btn-properties`) + `#printerStatusBadge`
- `#cupsOptionsGroup` / `#chkPpdFallback` — "PPD Uncorrected Passthrough (Fallback)". **Hidden on Windows** (#48, `printtarg.js:167-170`) via `navigator.userAgent` / `userAgentData.platform`.
- `#printerTraySelect`, `#printerMediaTypeSelect`
- Orientation toggle `#btnOrientPortrait` / `#btnOrientLandscape` (default portrait)
- `#btnPrintAll` — "Print All Pages (Bypass CM)"
- `#btnAdvanceToStage3`
- Per-page "Print Page" buttons on gallery cards (`printtarg.js:737-748`)
There is **no** printer paper-size dropdown in the raw-print panel. `#pageSizeSelect` above is printtarg's layout size and is reused as `PrintOptions.paper_size`.
### `capturedCupsOptions` (`printtarg.js:14–17, 266, 411-415`)
Module-level `{}` keyed by **printer name**. Populated only when macOS `show_printer_properties` returns a result with `options.cups_options`. Cleared for that printer if the result has no cups_options. Fed into every subsequent `print_target_native` via `getSelectedPrintOptions()`.
Cancellation: `result === null` → info toast, **no** map mutation.
If the user switched printer inside `NSPrintPanel`, the dropdown is updated when that CUPS id exists in the option list (`printtarg.js:402-409`). Captured media type is applied to `#printerMediaTypeSelect` when a matching option exists.
### Print payload (`printtarg.js:260-276`)
```js
{
paper_source: trayVal ? parseInt(trayVal, 10) : null, // NaN if unix tray name is non-numeric — Unix tray ids are 1-based indexes so this is OK
orientation: selectedOrientation, // "portrait" | "landscape"
paper_size: pageSizeSelect.value, // printtarg page size, not printer PageSize
media_type: mediaType || null,
ppd_uncorrected_passthrough: chkPpdFallback.checked,
cups_options: capturedCupsOptions[activePrinter] || null,
}
```
### Wizard state
`wizardState.printerName` is set at spool time (`printtarg.js:579, 625`) so Stage 5 verification history (#95) can record the device.
---