From b89d9777ad58ed272ce15dcdea028c44007d116a Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 17 Sep 2026 17:40:25 +0100 Subject: [PATCH] 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. --- AGENTS.md | 26 ++++- README.md | 9 +- docs/10-print-system.md | 5 +- docs/11-print-macos.md | 136 +++++++++++++++++--------- docs/13-print-linux.md | 5 +- docs/14-iccery-cpu-targetprint.md | 20 ++-- docs/25-rewrite-notes.md | 2 +- docs/26-v2-mac-ticket-plan.md | 12 ++- docs/issue-printer-quality-ignored.md | 105 ++++++++++++++++++++ 9 files changed, 252 insertions(+), 68 deletions(-) create mode 100644 docs/issue-printer-quality-ignored.md diff --git a/AGENTS.md b/AGENTS.md index de52d9b..010914e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,12 @@ Native macOS printer ICC/ICM profiling frontend. Drives the Gronod ArgyllCMS 3.5 - No Tauri, no Rust host, no WKWebView, no Three.js. ## Package layout -- `ICCery` — app target (SwiftUI shell). -- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel). +- `ICCery` — app target (SwiftUI shell). Also owns the native print stack in + `Sources/ICCery/Print/`: `PMTicketBridge`, `PrintTicket`, + `TicketWriteResolver`, `NativeTargetSpooler` (+ `RecordingTargetSpooler`), + `TargetRaster`, `TargetPageCanvasView` (#201 D1 — AppKit/`NSPrintOperation` + lives here, never in `ICCeryCore`). +- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel; CUPS enumeration/parsers only). - `ICCeryPrintKit` — v2.1 only (issue 16). Zero deps on wizard types. ## AGPL boundary @@ -39,6 +43,17 @@ Empty cwd illegal (#59). Atomic writes = `.tmp` + rename (#213). User-supplied strings via SwiftUI `Text` only (#114). TIFF never rendered directly — host-side PNG preview (#58). +## Print spool — native since v2.0 (#201); v1 `lp` path eradicated +Target printing is a headless `NSPrintOperation` via `NativeTargetSpooler` +(#201): restore the captured `PrintTicket`, apply `TicketWriteResolver` +(Stage 2 always wins, D6), draw 1:1 with interpolation off. +`lp` is eradicated from the target-print path (historical v1: `LpArgs`, +`CupsService.printTarget`, `ICCERY_TEST_LP_ARGV` all deleted). +`CupsParsers`/`CupsOptionsFilter` stay (D4): enumeration, capabilities, +media/quality/bypass key detection and the Stage 2 mirror. +UI-test seam: `ICCERY_TEST_SPOOL_LOG` — DEBUG `RecordingTargetSpooler` +appends one resolved-ticket line per page (D8). + ## Versioning `scripts/version.sh` is the single source: tag/describe → `ICCERY_RELEASE_TAG` (About shows `tag (marketing)`), `MARKETING_VERSION` = strict `X.Y.Z`, @@ -81,7 +96,12 @@ Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for rel ## Private ColorSync SPI 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`. Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`. -`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix. +One spool path remains (#201 D2): write **both** vocabularies on the native +path — locked AP_* (`AP_ColorMatchingMode` + `AP.ColorMatchingMode` = +`AP_ApplicationColorMatching`) **and** the Quartz dictionary +(`PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, +legacy `com.apple.print.PrintSettings.PMColorMatchingMode`, nested +`com.apple.print.printSettings` mirror). ## Gitea issue dependencies Use the `gitea` MCP (custom build with blocking support — verified working): diff --git a/README.md b/README.md index bb10879..edd0a1c 100644 --- a/README.md +++ b/README.md @@ -207,15 +207,16 @@ not in this tree. leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate. - **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`, `ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`, - `ProfcheckArgs`, `LpArgs`, `SpotReadArgs`, …). UI must not concatenate flags. + `ProfcheckArgs`, `SpotReadArgs`, …). UI must not concatenate flags. - **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal` must not replace the input profile on cancel or non-zero exit. - **Concurrency.** View models are `@MainActor`. No blocking I/O on the main actor. Swift 5.7 / macOS 12: `ObservableObject`, not Observation `@Observable`. -- **Print.** Unmanaged `lp` with ColorSync suppression - (`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel` - options win over derived CUPS keys. Never `lp -o raw`. +- **Print.** Unmanaged headless `NSPrintOperation` (#201 — no `lp`): + restore the captured `PrintTicket`, write both ColorSync vocabularies + (locked `AP_*` + Quartz `PMColorMatchingMode`), Stage 2 selections always + win over the ticket, draw 1:1 with interpolation off. - **SwiftUI ViewBuilder.** Xcode 14.2 / Swift 5.7 still has the ten-child limit. Split large `VStack`/`Group` trees (#146). diff --git a/docs/10-print-system.md b/docs/10-print-system.md index a959413..f7279bd 100644 --- a/docs/10-print-system.md +++ b/docs/10-print-system.md @@ -71,7 +71,10 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor | `media_types` | `Vec` | `#[serde(default)]`. Frontend fills `#printerMediaTypeSelect`. | | `supports_orientation` | `bool` | Always `true` on both Windows and Unix. | -### `PrintOptions` (mod.rs:42–55) — `Default` + `PartialEq + Eq` +### `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 | |-------|------|---------|-------|-------| diff --git a/docs/11-print-macos.md b/docs/11-print-macos.md index ec2723a..7d246c7 100644 --- a/docs/11-print-macos.md +++ b/docs/11-print-macos.md @@ -19,7 +19,7 @@ This is the most implementation-sensitive chapter. A rewrite that opens System S - `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 `lp` invocation. +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): @@ -68,34 +68,31 @@ if pm_printer was created from ID: `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 `lp` +### ColorSync suppression strategy — UI click to `NSPrintOperation` -End-to-end, **six independent layers**. All of them exist because no single Apple API is sufficient across Epson PDE / Canon PDE / `cgpdftoraster` / CUPS. +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] - show_printer_properties - run_on_main_thread - run_native_print_panel - ① PMSessionSetCurrentPMPrinter bind queue + 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 - NSPrintPanel.runModalWithPrintInfo + ⑤′ ColorSyncSuppressor.applyQuartzMode PMColorMatchingMode + legacy + nested mirror + NSPrintPanel.runModal("Use Settings") user picks media / quality (color locked) - ⑥ PMPrintSettingsToOptions → filter → PrintPropertiesResult + ⑥ PMPrintSettingsToOptions → filter → Stage 2 mirror + ⑦ PMTicketBridge.serialise(printInfo) → PrintTicket ticket capture [frontend] - capturedCupsOptions[printer] = cups_options + capturedCupsOptions[queue] = mirror + capturedTickets[queue] = ticket [Print Target] - print_target_native → macos::print_target → build_lp_args - ALWAYS -o AP_ColorMatchingMode=AP_ApplicationColorMatching - ALWAYS -o AP.ColorMatchingMode=AP_ApplicationColorMatching - THEN captured cups_options as -o k=v - THEN media_type if not already present (detected key) - THEN detect_driver_color_bypass if no color-bypass key yet - THEN orientation / PageSize if not already present - lp -d -t "ICCery 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". @@ -161,7 +158,7 @@ AGENTS.md:118, macos.rs:82-88: | `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. Do not mix the two dictionaries. +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) @@ -204,6 +201,18 @@ print_settings.insert(, ) `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) ``` @@ -231,9 +240,27 @@ After OK: 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) -Forwarded from the panel to `lp`: +Captured from the panel into the Stage 2 mirror (v1 forwarded them to `lp`): ``` Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType @@ -255,31 +282,38 @@ Duplex: Duplex, sides - Drops `collate`, `copies`, `pserrorhandler-requested`, `job-sheets` - **Keeps unknown non-`com.*` keys** (permissive: unknown driver keys survive) -### `build_lp_args` (macos.rs:518-643) +### Native spool — S1–S14 (v2.0, #201) -Always, even with `options=None`: +`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): ``` -lp -d -t "ICCery Target - " - -o AP_ColorMatchingMode=AP_ApplicationColorMatching - -o AP.ColorMatchingMode=AP_ApplicationColorMatching - … captured / detected options … - +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) ``` -Order after the two AP_* flags: - -1. Parse `opts.cups_options` into `-o k=v`, record lowercased keys in `added_keys`. -2. If `media_type` set and none of `mediatype` / `cnijmediatype` / `epij_medi` / `stpmediatype` already added: `detect_media_type_key(lpoptions)` and add it. -3. If no color-bypass key yet (`cnijintent2`, `cnijintent`, `epij_cmat`, `epij_ccor`, `epij_oscolmat`, `colorcorrection`, `stpcolorcorrection`, `epsoncolormode`): `detect_driver_color_bypass` and add. **Not gated on `ppd_uncorrected_passthrough`.** -4. Orientation → `orientation-requested=4|3` unless already present. -5. `PageSize=` unless `pagesize` already present. - -`ppd_uncorrected_passthrough` is stored from the panel but **does not change macOS lp flags**. There is no `-o raw` on macOS. - -### `print_target` (macos.rs:646-677) - -Exists-check, `build_lp_args`, `Command::new("lp").args(&args).output()`. Error wrapping same pattern as Unix (`"macOS CUPS print job failed: …"`). +`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` @@ -295,19 +329,26 @@ The rewrite: - `display_name` fallback for `NSPrinter::printerWithName`. - Private SPI to lock Color Matching. - Dual AP_* keys (underscore + dotted). -- Driver-specific PPD bypass pre-selected and re-applied on `lp`. -- Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions`. +- 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 (macos.rs:712-863 + tests.rs:278-318) +### 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. +- `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 @@ -338,7 +379,12 @@ PMRelease AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPanelOptions::all` + `ShowsPageSetupAccessory`. -### CUPS / lp flags +### 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 | |------|----------|------| diff --git a/docs/13-print-linux.md b/docs/13-print-linux.md index f99728d..6a933d3 100644 --- a/docs/13-print-linux.md +++ b/docs/13-print-linux.md @@ -53,7 +53,10 @@ First-match order: `CNIJMediaType` > `EPIJ_Medi` > `StpMediaType` > `MediaType`. -### Linux `build_lp_args` / `print_target` (unix.rs:362-457) +### Linux `build_lp_args` / `print_target` (unix.rs:362-457) — historical v1 + +> v0.8.5 Linux spool contract, kept for reference. ICCery v2 is macOS-only +> and its native path spools via `NSPrintOperation`, not `lp` (#201). ``` lp -d -t "ICCery Target - " diff --git a/docs/14-iccery-cpu-targetprint.md b/docs/14-iccery-cpu-targetprint.md index cde65b4..fdb4dc9 100644 --- a/docs/14-iccery-cpu-targetprint.md +++ b/docs/14-iccery-cpu-targetprint.md @@ -8,7 +8,7 @@ Separate native macOS AppKit app. Spec: `/tmp/ICCery-CPU/SPEC.md`. Binary name ` ### Why it exists -ICCery's Tauri path spools TIFF via `lp` and never goes through Quartz. That is correct for "don't let ColorSync touch the file", but: +ICCery's Tauri path spooled TIFF via `lp` and never went through Quartz (v1; v2.0 spools via a headless `NSPrintOperation` in ICCery proper — #201). That is correct for "don't let ColorSync touch the file", but: - No 1:1 physical-size preview - Windows-style `StretchDIBits` scaler (macOS `lp` may still scale inside the filter) @@ -100,7 +100,7 @@ Injected into: - `printInfo.dictionary()["com.apple.print.PrintSettings.PMColorMatchingMode"]` (legacy) - nested `com.apple.print.printSettings` dictionary, same keys -**This is not `AP_ApplicationColorMatching`.** TargetPrint talks to Quartz/`NSPrintOperation`. ICCery talks to the CUPS `lp` ticket / `cgpdftoraster`. A rewrite that unifies them must keep both vocabularies or prove one is honored on both paths. +**This is not `AP_ApplicationColorMatching`.** TargetPrint talks to Quartz/`NSPrintOperation`. v1 ICCery talked to the CUPS `lp` ticket / `cgpdftoraster`; a rewrite that unifies them must keep both vocabularies or prove one is honored on both paths. **v2.0 (#201, D2) resolved this:** the Quartz vocabulary in this section is now live in ICCery proper — `ColorSyncSuppressor.applyQuartzMode` / `TicketWriteResolver` write `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, the legacy `com.apple.print.PrintSettings.PMColorMatchingMode` and the nested `com.apple.print.printSettings` mirror **alongside** the locked AP_* keys on the single native spool path. Panel policy (`ColorMatching.configurePanel`): @@ -125,7 +125,7 @@ static inline const char *TPCupsGetPPD(const char *name) { - AirPrint (SPEC §10.2) if any of: URI `apple-airprint://`; PPD `*APAirPrint: True`; make Apple + model contains AirPrint; `ipps://` **and** PPD text contains `airprint`. Persistent warning badge; unmanaged color cannot be trusted. Tests in `AirPrintTests.swift`. - Vendor bypass (SPEC §10.3) — **different keys from ICCery's lpoptions detector:** -| Vendor | TargetPrint keys | ICCery macOS `lp` keys | +| Vendor | TargetPrint keys | ICCery macOS `lpoptions`-detected keys (v1: `lp -o`) | |--------|------------------|------------------------| | Epson | `ColorModel=RGB`, `EPSONColorControls=Off` | `EPIJ_CMat=3` / `EPIJ_CCor=0` / `EpsonColorMode=Off` | | Canon | `CNColorMatching=None` | `CNIJIntent2=4` / `CNIJIntent=4` | @@ -176,9 +176,9 @@ Command::new("/Applications/TargetPrint.app/Contents/MacOS/TargetPrint") CI publishes `vendor-iccery.zip` with `macos-x86_64` / `macos-aarch64` / `macos-universal` app bundles to drop into `src-tauri/targetprint/`. -Suggested ICCery integration: +Suggested ICCery integration (**superseded** — v2.0 took option 2's rendering model in-process instead; #201 adopted Quartz/`NSPrintOperation` spooling inside ICCery proper and removed `lp` entirely. The `--job` companion-app contract remains the v2.1 plan for `ICCeryPrintKit`, #16): -1. Keep current `lp` path as the headless/fast path (and the only path on Linux). +1. ~~Keep current `lp` path as the headless/fast path (and the only path on Linux).~~ 2. On macOS, Preferences / Print can spawn TargetPrint with a `TargetJob` built from `PrintOptions` + TIFF list + `forceUnmanagedColor: true` + `lockColorManagement: true`. 3. Do not `CREATE_NO_WINDOW` (macOS); do not `wait()`. Cleanup of the JSON is ICCery's job after process exit, or leave in `/tmp` as an audit trail (SPEC §13). @@ -186,14 +186,14 @@ Suggested ICCery integration: | Concern | ICCery `macos.rs` | TargetPrint | Rewrite recommendation | |---------|-------------------|-------------|------------------------| -| Spool | `lp` TIFF | Quartz `NSPrintOperation` | Keep `lp` for unattended; TargetPrint for preview+panel | -| ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** if using NSPrintOperation; keep AP_* on `lp` | +| Spool | `lp` TIFF | Quartz `NSPrintOperation` | Quartz `NSPrintOperation` — **adopted in v2.0 via #201** (`NativeTargetSpooler`; `lp` removed from the target-print path) | +| ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** — **adopted in v2.0 via #201** (D2: the single native path carries AP_* and the Quartz §7 vocabulary) | | Lock PDE UI | private `PMSessionSetColorMatchingMode*` SPI | strip Color Matching accessories | Use SPI **and** strip; accessories API misses driver PDEs (the #188 failure mode) | | Canon off | `CNIJIntent2=4` | `CNColorMatching=None` | Apply both | | Epson off | `EPIJ_CMat=3` / `EPIJ_CCor=0` | `EPSONColorControls=Off` + `ColorModel=RGB` | Apply both; prefer captured panel values | -| Geometry | none (filter decides) | 1:1 pt from DPI | TargetPrint (or do not scale in GDI/`lp`) | -| Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges | -| AirPrint | none | detected + warned | Port detector into ICCery printer list | +| Geometry | none (filter decides) | 1:1 pt from DPI | 1:1 pt from DPI — **adopted in v2.0 via #201** (`TargetRasterLoader`/`TargetPageCanvasView`, docs/14 §6) | +| Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges — **adopted in v2.0 via #201** (interpolation/antialias off in `TargetPageCanvasView.draw`) | +| AirPrint | none | detected + warned | Ported into ICCery — **adopted in v2.0 via #201/#202** (`lpstat -v` + PPD §10.2 rules, Stage 2 `airPrintWarningBadge`) | | Linux | `-o raw` | n/a (macOS only) | Keep raw + PPD fallback | | Windows | GDI ICM_OFF | n/a | Keep GDI; do not route through TargetPrint | diff --git a/docs/25-rewrite-notes.md b/docs/25-rewrite-notes.md index 7b95dd7..045df80 100644 --- a/docs/25-rewrite-notes.md +++ b/docs/25-rewrite-notes.md @@ -85,7 +85,7 @@ Lock these before rewriting UI: 4. `parseProfcheckReport` JSON + legacy + empty→warning 5. Color bypass detector: Canon/Epson/Gutenprint samples 6. `filter_cups_options_string` drops `com.apple.*`, keeps `EPIJ_CMat` -7. `build_lp_args` always emits both AP_* keys +7. `build_lp_args` always emits both AP_* keys (**historical v1** — superseded by `TicketWriteResolverTests`, #201) 8. Threshold validation `good < warning` 9. `snapshot_ti3` 1-based and removes canonical 10. DEVMODE round-trip size diff --git a/docs/26-v2-mac-ticket-plan.md b/docs/26-v2-mac-ticket-plan.md index 03ce20c..d3f7628 100644 --- a/docs/26-v2-mac-ticket-plan.md +++ b/docs/26-v2-mac-ticket-plan.md @@ -29,6 +29,12 @@ ## Locked product decisions +> **Errata (M12, #201):** item 4's `lp` spool was replaced in v2.0 by a +> headless `NSPrintOperation` replaying the captured `PMPrintSettings` +> ticket, and the Quartz vocabulary of item 5 is now written **alongside** +> AP_* on the single native path (D2). "Never mix" no longer applies inside +> ICCery proper; it still governs the future `ICCeryPrintKit` boundary. + 1. **Stack:** SwiftUI (`@Observable`, `@MainActor` view models) + AppKit for printing/panels. No Tauri, no Rust, no WebView. 2. **Floor:** macOS 14.0, universal `arm64` + `x86_64`. 3. **AGPL:** never link Argyll. Spawn with piped stdio + `ARGYLL_NOT_INTERACTIVE=1` on **every** child (streaming and captured). @@ -120,7 +126,7 @@ Issues 1–6. **Hardware:** none. Issues 7–11. -### M3 — macOS unmanaged printing (`lp` path) +### M3 — macOS unmanaged printing (`lp` path) — historical v1, superseded by #201 **CI/mock:** `lpstat`/`lpoptions` parsers; `build_lp_args` golden vectors (both `AP_*` always present); option filter; cancel → nil. **Hardware:** Preferences opens **driver PDE** on a real Epson or Canon queue; colour matching off/grayed; printed TIFF measures unmanaged (no ColorSync transform). @@ -376,7 +382,7 @@ Six layers (spec [11](11-print-macos.md) roster): - Deps: 12, 13. - Test CI: injectable dlsym order; filter fixtures. Hardware: PDE colour grayed/off on Epson **and** Canon. -**Issue 15 — `lp` spool path** +**Issue 15 — `lp` spool path — historical v1, superseded by #201 native spool** Labels: `Feature/Backend`, `Priority/High` Milestone: M3 @@ -583,7 +589,7 @@ Milestone: **Later** - Separate Swift package `ICCeryPrintKit`. **Zero** deps on wizard types. - Public API: `TargetJob` v1 JSON + `--job` CLI (fire-and-forget) **and** in-process `NSPrintOperation`. Preserve extractability to a standalone app. -- ColorSync vocabulary is **not** the `lp` path: `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, legacy `com.apple.print.PrintSettings.PMColorMatchingMode`. **Never mix with `AP_ApplicationColorMatching`.** +- ColorSync vocabulary is **not** the `lp` path: `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, legacy `com.apple.print.PrintSettings.PMColorMatchingMode`. **Never mix with `AP_ApplicationColorMatching`** — amended by #201 (D2): with `lp` gone, ICCery's single native path writes **both** vocabularies; this constraint now governs only the future `ICCeryPrintKit` boundary. - Vendor keys (separate table from issue 14): Epson `ColorModel=RGB` + `EPSONColorControls=Off`; Canon `CNColorMatching=None`; HP `ColorModel=RGB` + `HPColorControl=Off`. - Geometry: 72pt=1in, no `backingScaleFactor`, interpolation `.none`, antialias off, pixel-integrity seam test. Resolve SPEC contradiction: job JSON `"centered": true` vs draw “no centering” — **lock “no centering, scale 1.0” for profiling targets.** - AirPrint detection → persistent warning. diff --git a/docs/issue-printer-quality-ignored.md b/docs/issue-printer-quality-ignored.md new file mode 100644 index 0000000..aa1e84c --- /dev/null +++ b/docs/issue-printer-quality-ignored.md @@ -0,0 +1,105 @@ +# Issue #201 — Printer quality and media type ignored when printing target + +> Analysis doc referenced by issue **[Bug/Critical] Printer quality and media +> type settings ignored when printing target — lp path missing PMPrintSettings +> ticket** (#201). Line numbers refer to `develop` @ `0513b27` (pre-M12 code, +> verified 2026-09-17). The fix landed on `milestone/m12-native-spool` — see +> "Resolution" below. + +## 0. Lifecycle trace — what the code did (pre-#201, historical v1) + +``` +Stage2View "btnPrinterProperties" + → PrintSessionViewModel.openPrinterPreferences() :141 + → PrintPanelService.showProperties() :59 + → runNativePanel() :85 + ① PMPrinterCreateFromPrinterID + PMSessionSetCurrentPMPrinter :96–113 + ▸ applyInitialSelections (PMPrintSettingsSetValue ×4) :216 + ▸ applyPaperPageFormat (PMPaper match → PMCopyPageFormat) :258 + ②–⑤ ColorSyncSuppressor SPI / AP_* / bypass / mirror :152–163 + NSPrintPanel.runModal("Use Settings") :173 + ⑥ PMPrintSettingsToOptions → CupsOptionsFilter → String :185–191 + ✗ THE TICKET IS DISCARDED HERE — only the flattened `k=v` string survives + → capturedCupsOptions[queue] = String :174 + +Stage2View "btnPrintAll" + → PrintSessionViewModel.printAllPages(from:) :205 + → spool(page, index:) :263 + → CupsService.printTarget() CupsService.swift:170 + → LpArgs.build() LpArgs.swift:42 + → ProcessManager.runCaptured("/usr/bin/lp", argv) +``` + +## Root causes (pre-#201, historical v1) + +Two defects, both confirmed by reading the code: + +### 1. Ticket loss + +`ColorSyncSuppressor.captureOptions` (:133) was the only capture path. It +funneled the whole `PMPrintSettings` object through `PMPrintSettingsToOptions` +→ a space-separated `key=value` string, then `CupsOptionsFilter.filter` +**dropped every `com.apple.*` key** (`CupsOptionsFilter.swift:49`) — i.e. it +deliberately discarded `com.apple.print.PrintSettings`, the ticket the +Epson/Canon raster filter reads. `lp` cannot reconstruct it, so the driver +fell back to plain paper / normal quality. + +### 2. Override inversion + +`LpArgs.build` recorded every captured key in `addedKeys` (:67–73) and then +*suppressed* the explicit Stage 2 value when the key was already present — +media (:81), quality (:90), bypass (:102), orientation (:109), `PageSize` +(:117). A Stage 2 dropdown change after a panel capture was silently ignored. + +### Dismissed hypothesis + +**argv tokenisation is safe** — `ProcessManager.runCaptured` hands an argv +array to `Process`, never a shell. Space-containing `-o` values were +preserved; this was checked and dismissed in the issue investigation and +re-verified during the M12 audit. + +## Locked decisions (D1–D13) + +Decisions taken by the M12 megaplan; **locked — do not re-derive.** +(`lp`/`LpArgs` mentions in this table name the deleted historical v1 path.) + +| # | Decision | Consequence | +|---|----------|-------------| +| D1 | Spooler lives in the **app target** (`Sources/ICCery/Print/`), not in `CupsService` | `ICCeryCore` stays AppKit-free (AGENTS §Package layout). `CupsService.printTarget` is **deleted**; `CupsService` keeps enumeration/capabilities/PPD only. The issue body's "in `CupsService`" is superseded — posted as errata on #201 | +| D2 | Write **both** ColorSync vocabularies on the native path | AP_* (locked) **and** Quartz `PMColorMatchingMode=APCustomColorMatching` + `PMCustomColorMatchingProfile=""` + legacy `com.apple.print.PrintSettings.PMColorMatchingMode` + the nested `com.apple.print.printSettings` mirror. AGENTS §"Private ColorSync SPI — never mix" is **amended**: with `lp` gone there is one path and it carries both dictionaries (docs/14 decision table, "Set both if using NSPrintOperation") | +| D3 | Ticket = `PMPrintSettings` **Data** + `PMPageFormat` **Data** + an `NSPrintInfo.dictionary()` plist fallback | `kPMDataFormatXMLDefault`. Restored via `PM*CreateWithDataRepresentation` → `PMCopy*` → `PMSessionValidate*` → `updateFromPM*`. Byte round-trip is unit-testable | +| D4 | Delete `lp`; **keep** `CupsParsers` + `CupsOptionsFilter` | Parsers still drive capabilities (#183/#180/#181), media/quality key detection, driver-bypass detection, and the panel→Stage-2 apply-back mirror (#186). Only `LpArgs` dies | +| D5 | Job granularity is **user-selectable**, default **one job per page** | New session-only `@Published var singleJobForAllPages = false`; Stage 2 checkbox `chkSingleSpoolJob`. Default preserves today's per-page notices and error attribution | +| D6 | **Stage 2 always wins** over the rehydrated ticket | Unconditional overwrite of paper / media / quality / orientation. Mitigation for vendor companion-key desync: only write when the value differs from the ticket's current value, and log both (R4) | +| D7 | Spool **silently** | `showsPrintPanel = false`, `showsProgressPanel = false`, `canSpawnSeparateThread = false`. Feedback stays on the existing `isPrinting` + `Notice`. No new system modal → XCUITest unaffected | +| D8 | Verification = **recorder seam + PDF harness** | DEBUG `RecordingTargetSpooler` writes resolved ticket lines to `ICCERY_TEST_SPOOL_LOG` (replaces `ICCERY_TEST_LP_ARGV`); a real `NSPrintOperation` with `jobDisposition = .save` produces a PDF for geometry assertions | +| D9 | Never scale; **warn + spool** | 1:1 always, anchored at the paper's top-left. `warn` when the DPI-derived size disagrees with the manifest `width_mm`/`height_mm` by >0.5 mm; warning `Notice` when the image exceeds the paper | +| D10 | Device colour spaces implemented **and verified** for 1/3/4 channels at 8 and 16 bpc | Re-tag the decoded `CGImage` into `DeviceGray`/`DeviceRGB`/`DeviceCMYK` reusing the source `dataProvider` — **no resample, no bit-depth change**. Hardware gate covers RGB-8, RGB-16, DeviceGray-8 and CMYK-16 | +| D11 | AirPrint detection + Stage 2 banner **in scope**, as its own issue | docs/14 §10.2 rules. Blocked-by #201 → filed as #202 | +| D12 | Extract a **shared** `PMTicketBridge` | Single `@MainActor enum` owning every `PM*` call, with one documented `PMRelease` rule. `PrintPanelService` migrates onto it | +| D13 | New milestone **M12 — Native print spool** | `milestone/m12-native-spool` from `develop`; #201 moves off the shipped M11 (id 34) | + +## Resolution + +Implemented on `milestone/m12-native-spool` (Phases 1–5, PRs #203–#207). +(`LpArgs`/`lp` references below name deleted historical v1 artefacts.) + +- `PMTicketBridge` owns every `PM*` call; `PrintPanelService.showProperties` + returns `PanelCaptureResult` carrying a `PrintTicket` (layer ⑦ serialise). +- `NativeTargetSpooler` rehydrates the ticket into a fresh `NSPrintInfo` + (S1–S14, docs/11 §native spool) and spools a headless `NSPrintOperation` + over `TargetPageCanvasView` — 1:1, top-left anchored, interpolation off. +- `TicketWriteResolver` replaces `LpArgs.build`: Stage 2 always wins (D6), + both ColorSync vocabularies written (D2), no `raw` can ever appear. +- `LpArgs`, `CupsService.printTarget`, the `lp` fixture and + `ICCERY_TEST_LP_ARGV` are deleted; `RecordingTargetSpooler` writes to + `ICCERY_TEST_SPOOL_LOG` for tests (D8). +- AirPrint detection + Stage 2 warning badge shipped as #202 (D11). + +## References + +- `Sources/ICCery/Print/{PMTicketBridge,PrintTicket,NativeTargetSpooler,TicketWriteResolver,TargetRaster,TargetPageCanvasView}.swift` +- `Packages/ICCeryCore/Sources/ICCeryCore/Print/{CupsService,CupsParsers,CupsOptionsFilter,PrinterModels,ColorMatchingAttempts}.swift` +- `docs/11-print-macos.md` §ColorSync suppression — UI click to `NSPrintOperation` +- `docs/14-iccery-cpu-targetprint.md` §6–§7 (geometry + Quartz vocabulary) +- M12 megaplan (`plan-be303e6f3f5e89da`) -- 2.39.5