Files
iccery-v2-mac/docs/12-print-windows.md
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 4c2863e52c Add bootstrap docs: AGENTS.md, BUILD-PLAN.md, LICENCE.md, vendored spec (docs/), brand assets
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 17:36:50 +01:00

171 lines
8.7 KiB
Markdown

# 12 — Windows GDI raw printing
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
## Windows (`src-tauri/src/print/windows.rs`)
Direct Win32. No `lp`, no XPS, no `winprint` crate. Evaluated and rejected in #26.
### Exact Win32 APIs used
From `windows` crate 0.61 (`Cargo.toml:50-55`: `Win32_Foundation`, `Win32_Graphics_Gdi`, `Win32_Graphics_Printing`, `Win32_UI_WindowsAndMessaging`) plus a local `extern "system"` block for symbols the crate feature set does not expose.
| API | Header / crate | Where | Purpose |
|-----|----------------|-------|---------|
| `EnumPrintersW` | `Win32::Graphics::Printing` | `get_printers` | Level 4 (`PRINTER_INFO_4W`) first; fall back to Level 1 (`PRINTER_INFO_1W`) |
| `OpenPrinterW` | Printing | properties + print | `PRINTER_HANDLE` |
| `ClosePrinter` | Printing | both | |
| `DocumentPropertiesW` | Printing | properties + print | size query (`fMode=0`), `DM_OUT_BUFFER` init, `DM_IN_PROMPT\|DM_IN_BUFFER\|DM_OUT_BUFFER` dialog |
| `GetForegroundWindow` | `Win32::UI::WindowsAndMessaging` | properties | parent HWND of the modal |
| `DeviceCapabilitiesW` | **local extern** | capabilities | `DC_BINS`(6), `DC_BINNAMES`(12), `DC_PAPERS`(2), `DC_PAPERNAMES`(16), `DC_MEDIATYPES`(35), `DC_MEDIATYPENAMES`(34) |
| `CreateDCW` | `Win32::Graphics::Gdi` | print | printer DC with DEVMODE |
| `DeleteDC` | Gdi | print | |
| `GetDeviceCaps` | Gdi | print | `LOGPIXELSX/Y`, `HORZRES`, `VERTRES` |
| `SetICMMode` | **local extern** | print | `ICM_OFF = 1` |
| `StartDocW` / `StartPage` / `EndPage` / `EndDoc` | **local extern** | print | GDI job |
| `StretchDIBits` | Gdi | print | unmanaged 24-bit BGR DIB |
`DOCINFOW` is a **local** `#[repr(C)]` struct (windows.rs:24-31), not the crate type, because `StartDocW` is also local-extern.
Constants (windows.rs:48-66):
```
ICM_OFF = 1
DM_IN_PROMPT = 4
DM_IN_BUFFER = 8
DM_OUT_BUFFER = 2
DM_ORIENTATION = 0x00000001
DM_DEFAULTSOURCE = 0x00000200
DM_ICMMETHOD = 0x00800000
DMICMMETHOD_NONE = 1
DMORIENT_PORTRAIT = 1
DMORIENT_LANDSCAPE = 2
DM_MEDIATYPE = 0x02000000
DC_PAPERS=2, DC_BINS=6, DC_BINNAMES=12, DC_PAPERNAMES=16,
DC_MEDIATYPENAMES=34, DC_MEDIATYPES=35
IDOK = 1
```
### Enumeration (`get_printers`, windows.rs:78-168)
1. `EnumPrintersW(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, NULL, 4, …)` to get byte count.
2. If `bytes_needed == 0`, retry at Level 1 (older/limited spooler).
3. Level 4: `pPrinterName`. Level 1: `pName`.
4. `is_default` always false, `display_name` always `None`, `status` always `"Ready"`.
5. Empty list is success, not error.
### Capabilities (`get_printer_capabilities`, windows.rs:171-326)
`DeviceCapabilitiesW(printer, NULL port, cap, …)`. Bin names are 24 WCHARs each; paper and media names 64 WCHARs. Media type IDs are read as `u32` via a `*mut u16` cast of a `Vec<u32>` (windows.rs:288-289) — the Win32 API writes DWORDs for `DC_MEDIATYPES`.
`supports_orientation: true` unconditionally.
### Driver preferences dialog (`show_printer_properties`, windows.rs:329-401) — #34 / #36
```
OpenPrinterW
DocumentPropertiesW(..., fMode=0) → required buffer size
load cached DEVMODE or zeros
if all-zero: DocumentPropertiesW(DM_OUT_BUFFER) to populate defaults
DocumentPropertiesW(DM_IN_PROMPT | DM_IN_BUFFER | DM_OUT_BUFFER)
ClosePrinter
if res == IDOK (1): store out_buf, Ok(())
if res == 2: user cancel, Ok(()) // IDCANCEL
else: Err(...)
```
Parent window is `GetForegroundWindow()`, **not** the Tauri webview HWND. The dialog is modal to whatever is foreground.
The **entire** `out_buf` (header + `dmDriverExtra`) is stored. That is the #36 fix: Epson "Print Preview" and other OEM private flags live past `dmSize` and were previously dropped.
### ICM bypass + option overlay (`apply_print_options_to_devmode`, windows.rs:404-437)
Always:
```
dmFields |= DM_ICMMETHOD
dmICMMethod = DMICMMETHOD_NONE // 1
```
Then, if options present:
- `paper_source``dmFields |= DM_DEFAULTSOURCE`; `dmDefaultSource = tray_id as i16`
- `orientation == "landscape"` (case-insensitive) → `DMORIENT_LANDSCAPE` else portrait
- `media_type` parsed as `u32``dmFields |= DM_MEDIATYPE`; `dmMediaType = id`
**Not applied:** `paper_size`, `ppd_uncorrected_passthrough`, `cups_options`. Private OEM bytes after the public header are **not** touched (verified by `tests.rs:219-276`).
### GDI raw print path (`print_target`, windows.rs:440-643)
1. `image::open` TIFF → `to_rgb8()`. Crate features: `png`, `tiff` (`Cargo.toml:40`).
2. Pack **24-bit BGR** DIB, 4-byte row stride: `row_stride = ((w*3+3)/4)*4`. Top-down (`biHeight` negative).
3. `OpenPrinterW``DocumentPropertiesW` size → reuse cached DEVMODE (resized up if needed) or `DM_OUT_BUFFER` defaults → `apply_print_options_to_devmode`.
4. `CreateDCW(NULL, printer, NULL, pDevMode)`.
5. **`SetICMMode(hdc, ICM_OFF)`** — "STRICT ICM BYPASS".
6. `StartDocW` with title `ICCery Target - <filename>`. `lpszDatatype` null (driver default, not `"RAW"`).
7. `StartPage`.
8. Auto-fit scaler (#34):
```
dpi_x/y = GetDeviceCaps(LOGPIXELSX/Y) // used only for BITMAPINFO biX/YPelsPerMeter
page_w/h = GetDeviceCaps(HORZRES/VERTRES)
scale = min(page_w/img_w, page_h/img_h)
dest = floor(img * scale), centered
```
Physical offsets (`PHYSICALOFFSETX/Y`) from #34's wish-list are **not** queried. Fit is to the printable DC area (`HORZRES`/`VERTRES`), which already excludes hardware margins.
9. `StretchDIBits(..., DIB_RGB_COLORS, SRCCOPY)`. This **can resample** if dest ≠ source pixels. Color is unmanaged (ICM off, BI_RGB) but geometric interpolation is GDI's. Contrast with ICCery-CPU, which forbids interpolation.
10. `EndPage` / `EndDoc` / `DeleteDC` / `ClosePrinter`.
Zero-dimension images error out before GDI. Missing TIFF: `"Target TIFF file not found: …"`.
### CREATE_NO_WINDOW vs print
`CREATE_NO_WINDOW` (`0x08000000`) is **not used on the print path**. Windows printing is in-process GDI; no child process is spawned.
`CREATE_NO_WINDOW` **is** applied to ArgyllCMS console-subsystem children so a black `cmd` window does not flash (#46):
- `src-tauri/src/process_manager.rs:99-103` — every `ProcessManager::spawn` (`targen`, `printtarg`, `chartread`, `colprof`, `profcheck`, `instlist`, …)
- `src-tauri/src/calibration.rs:849-853``printcal` / `applycal` captured runs
`printtarg` itself only **generates** TIFFs; it does not print. Native print is a separate IPC command.
### NSIS USB drivers (#127) — not printer drivers
USB install is for **spectrophotometer** libusb-win32 drivers (i1Pro, ColorMunki, SpyderPrint), **not** printer OEM drivers.
- Fetch: `scripts/fetch-argyll.mjs` copies `Argyll_V*/usb/``src-tauri/argyll/usb/` from the Windows zip (`ArgyllCMS_install_USB.exe`, `.inf`, `.cat`, `libusb0.sys` for x86/amd64/arm64).
- Bundle: `tauri.conf.json` `resources: ["argyll/**/*"]` + NSIS `installMode: "both"` + `installerHooks: "windows/hooks.nsh"`.
- `hooks.nsh` `NSIS_HOOK_PREINSTALL`: if elevated, `MessageBox` Yes/No "Install ArgyllCMS USB instrument drivers?".
- `NSIS_HOOK_POSTINSTALL`: `ExecWait` `$INSTDIR\argyll\usb\ArgyllCMS_install_USB.exe` or `$INSTDIR\resources\argyll\usb\...`. Not silent. Missing file → exclamation box.
- Uninstall does **not** run `ArgyllCMS_uninstall_USB.exe` (would break other Argyll apps).
- Also maps missing HKCU shell-folder drive letters via `DefineDosDevice` to avoid "Invalid Drive" on domain profiles.
WiX/MSI has no equivalent prompt.
### Windows idiosyncrasies / bugs fixed
| Issue | Symptom | Fix |
|-------|---------|-----|
| #25/#26 | Need ACPU-equivalent raw print | GDI + `SetICMMode(ICM_OFF)` + `DMICMMETHOD_NONE` + 24-bit BGR DIB |
| #34 | No driver dialog / trays / orientation / scaler | `DocumentPropertiesW` modal, `DeviceCapabilitiesW`, auto-fit `StretchDIBits` dest |
| #36 | Preferences (Epson Print Preview, private OEM) ignored | Cache **full** DEVMODE buffer including `dmDriverExtra`; pass that pointer to `CreateDCW` |
| #48 | "PPD Uncorrected Passthrough" shown on Windows | Hide `#cupsOptionsGroup` |
| #46 | Argyll console windows cover UI | `CREATE_NO_WINDOW` on subprocess spawn **only** — not on print |
| #67 | Duplicate `get_windows_printers` / `print_target_windows` | Collapsed to `get_printers` / `print_target_native` |
Remaining gaps:
- Default printer never flagged.
- `paper_size` not applied to `DEVMODE` (`DM_PAPERSIZE` unused).
- `PHYSICALOFFSET*` not used; scaler uses `HORZRES`/`VERTRES` only.
- `StretchDIBits` may interpolate when scaling.
- `show_printer_properties` Tauri result is always `None`, so the frontend reports cancel even on OK.
- No `PrinterProperties` / `AdvancedDocumentProperties` alternative; only `DocumentPropertiesW`.
- `GetForegroundWindow` can attach the modal to the wrong top-level window.
---