From 12061a03ac5eec773c2df3cda79ac983303c1f66 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 22 Sep 2026 17:41:36 +0100 Subject: [PATCH 1/3] fix(print): restore captured ticket when reopening properties dialog (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bound NSPrintPanel always opened on queue driver defaults — capturedTickets[queue] was stored but never passed back, so every vendor-PDE setting outside the four mirrored fields was discarded on reopen (RC1), and pre-selection writes were never validated into the ticket before runModal (RC2). Extract the pre-modal configuration into a testable configureBoundPanel seam with the spooler's S4→S7 order: ticket restore (warn-only, unlike the spooler's fatal try — a stale ticket must not keep the dialog from opening → Stage 2 selections (D6 parity) → PMSessionValidatePrintSettings + updateFromPMPrintSettings → applyPaper → suppression ②–⑤. ICCERY_DEBUG_PANEL_DUMP=1 logs the raw PMPrintSettingsToOptions output and serialised ticket sizes pre/post modal for the XP-55 RC3 verification. Refs #217 EOF ) --- .../ICCery/Print/ColorSyncSuppressor.swift | 27 ++- Sources/ICCery/Print/PrintPanelService.swift | 214 +++++++++++++----- .../ICCery/Print/PrintSessionViewModel.swift | 4 +- .../PrintPanelTicketTests.swift | 148 ++++++++++++ 4 files changed, 327 insertions(+), 66 deletions(-) create mode 100644 Tests/ICCeryCoreTests/PrintPanelTicketTests.swift diff --git a/Sources/ICCery/Print/ColorSyncSuppressor.swift b/Sources/ICCery/Print/ColorSyncSuppressor.swift index 2764b43..8378711 100644 --- a/Sources/ICCery/Print/ColorSyncSuppressor.swift +++ b/Sources/ICCery/Print/ColorSyncSuppressor.swift @@ -186,17 +186,9 @@ struct ColorSyncSuppressor { func captureOptions( from settings: PMPrintSettings ) -> (cupsOptions: String?, mediaType: String?) { - guard let toOptions = optionsResolver() else { - log("ColorSync: PMPrintSettingsToOptions unavailable — " - + "panel options not captured") + guard let unfiltered = rawOptionsDump(from: settings) else { return (nil, nil) } - var raw: UnsafeMutablePointer? - guard toOptions(settings, &raw) == 0, let raw else { - return (nil, nil) - } - defer { free(raw) } - let unfiltered = String(cString: raw) let filtered = CupsOptionsFilter.filter(unfiltered) return ( filtered.isEmpty ? nil : filtered, @@ -204,6 +196,23 @@ struct ColorSyncSuppressor { ) } + /// Unfiltered `PMPrintSettingsToOptions` output — the diagnostics + /// half of the capture path (#217 `ICCERY_DEBUG_PANEL_DUMP`). + /// `nil` when the symbol or the call fails. + func rawOptionsDump(from settings: PMPrintSettings) -> String? { + guard let toOptions = optionsResolver() else { + log("ColorSync: PMPrintSettingsToOptions unavailable — " + + "panel options not captured") + return nil + } + var raw: UnsafeMutablePointer? + guard toOptions(settings, &raw) == 0, let raw else { + return nil + } + defer { free(raw) } + return String(cString: raw) + } + // MARK: - dlsym private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? { diff --git a/Sources/ICCery/Print/PrintPanelService.swift b/Sources/ICCery/Print/PrintPanelService.swift index 613fea0..1109fe9 100644 --- a/Sources/ICCery/Print/PrintPanelService.swift +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -32,6 +32,19 @@ struct PrintPanelInitialSelections { var orientation: String? } +/// One bound-panel invocation (#217): the queue, its `lpoptions -l` +/// key roster, the Stage 2 pre-selections, and the session's captured +/// ticket — everything `makePanelPrintInfo` needs before the modal. +struct PrintPanelRequest { + let queue: String + let displayName: String? + let optionKeys: Set + let initialSelections: PrintPanelInitialSelections + /// The queue's previously captured ticket — rehydrated before the + /// Stage 2 writes so vendor PDE state survives a reopen (#217). + let ticket: PrintTicket? +} + /// Preferences → native `NSPrintPanel` bound to the selected CUPS /// queue (issue 13, docs/11). /// @@ -58,7 +71,8 @@ struct PrintPanelService { displayName: String?, cupsService: CupsService, initialSelections: PrintPanelInitialSelections = - PrintPanelInitialSelections() + PrintPanelInitialSelections(), + ticket: PrintTicket? = nil ) async throws -> PanelCaptureResult? { #if DEBUG if UITestHooks.printPanelStubbed { @@ -75,83 +89,139 @@ struct PrintPanelService { let optionKeys = (try? await cupsService.optionKeys(for: queue)) ?? [] return try runNativePanel( - queue: queue, displayName: display, optionKeys: optionKeys, - initialSelections: initialSelections) + PrintPanelRequest( + queue: queue, displayName: display, + optionKeys: optionKeys, + initialSelections: initialSelections, ticket: ticket)) } // MARK: - Panel - private func runNativePanel( - queue: String, - displayName: String?, - optionKeys: Set, - initialSelections: PrintPanelInitialSelections - ) throws -> PanelCaptureResult? { + /// The pre-modal half of `runNativePanel` — a fully configured + /// `NSPrintInfo` plus the `boundViaPM` flag. Extracted (same + /// pattern as `NativeTargetSpooler.makePrintInfo`) so unit tests + /// assert ticket restore / Stage 2 precedence without driving the + /// modal (#217). The caller owns `printer` — it must stay alive + /// through the modal. + func makePanelPrintInfo( + for request: PrintPanelRequest, + boundTo printer: PMPrinter? + ) throws -> (printInfo: NSPrintInfo, boundViaPM: Bool) { let printInfo = NSPrintInfo() - var pmPrinter: PMPrinter? - var boundViaPM = false // ① Bind the session to the selected CUPS queue (docs/11). - if let printer = PMTicketBridge.makePrinter(queue: queue) { - pmPrinter = printer - let session = PMTicketBridge.session(printInfo) - let settings = PMTicketBridge.settings(printInfo) - - do { - try PMTicketBridge.bind(printer: printer, to: printInfo) - } catch { - PMTicketBridge.release(printer) - throw error - } - // Initial selections — after `PMSessionDefault*`, before - // ColorSync suppression ②–⑤ (locked write order, - // #183/#186). Paper is TWO writes (E1): the `PageSize` - // print-settings value drivers/capture read AND the - // `PMPageFormat` paper the panel's dropdown reflects. - applyInitialSelections( - initialSelections, to: settings, optionKeys: optionKeys) - if let paperToken = initialSelections.paperSize { - PMTicketBridge.applyPaper( - token: paperToken, printer: printer, - session: session, printInfo: printInfo) - } - boundViaPM = true + if let printer { + try PMTicketBridge.bind(printer: printer, to: printInfo) + configureBoundPanel( + printInfo, request: request, printer: printer) } else { // Fallback: NSPrinter by display name (docs/11 §binding). // Warn — the display name can resolve a *different* queue // (#186 E2: diagnosable, not a proven defect). AppLogger.shared.warn( - "Print panel: PM binding unavailable for '\(queue)' — " + "Print panel: PM binding unavailable for " + + "'\(request.queue)' — " + "falling back to NSPrinter(displayName)") - guard let displayName, + guard let displayName = request.displayName, let nsPrinter = NSPrinter(name: displayName) else { throw PrintPanelError.noPrinterFound( - displayName ?? queue) + request.displayName ?? request.queue) } printInfo.printer = nsPrinter printInfo.setUpPrintOperationDefaultValues() + return (printInfo, false) } + + return (printInfo, true) + } + + /// Post-bind configuration in locked order (#217 — mirrors the + /// spooler's S4→S7): ticket restore → Stage 2 selections → + /// validate → paper → suppression ②–⑤. Internal so unit tests can + /// drive it with `printer: nil` — `applyPaper` and the SPI layer + /// need a bound printer and skip when it is absent, while ticket + /// restore / selections / validate still run on the session-less + /// `NSPrintInfo`. + func configureBoundPanel( + _ printInfo: NSPrintInfo, + request: PrintPanelRequest, + printer: PMPrinter? + ) { + let session = PMTicketBridge.session(printInfo) + let settings = PMTicketBridge.settings(printInfo) + + // Rehydrate the captured ticket *before* the Stage 2 writes + // (#217 — restore-then-override). Warn-only, unlike the + // spooler's fatal `try`: a stale/corrupt ticket must not keep + // the dialog from opening. `restore` itself refuses a + // cross-queue replay (R3). + if let ticket = request.ticket { + do { + try PMTicketBridge.restore(ticket, into: printInfo) + } catch { + AppLogger.shared.warn( + "Print panel: ticket restore failed — " + + "opening on queue defaults " + + "(\(error.localizedDescription))") + } + } + + // Initial selections — Stage 2 wins over the restored ticket + // (D6 parity). Paper is TWO writes (E1): the `PageSize` + // print-settings value drivers/capture read AND the + // `PMPageFormat` paper the panel's dropdown reflects. + applyInitialSelections( + request.initialSelections, to: settings, + optionKeys: request.optionKeys) + + // Reconcile the pre-writes into the ticket before the modal + // (#217 RC2): the vendor PDE derives pane state from a + // validated ticket. Warn-only — log when the driver adjusts + // so a companion-key desync is diagnosable. + var changed = DarwinBoolean(false) + _ = PMSessionValidatePrintSettings(session, settings, &changed) + if changed.boolValue { + AppLogger.shared.info( + "Print panel: driver adjusted the pre-selections") + } + printInfo.updateFromPMPrintSettings() + + if let printer, let paperToken = request.initialSelections.paperSize { + PMTicketBridge.applyPaper( + token: paperToken, printer: printer, + session: session, printInfo: printInfo) + } + + // ②–⑤ ColourSync suppression. The SPI needs a session with a + // current printer to attach to; the SetValue/mirror layers + // are warn-only and run regardless. + if printer != nil { + suppressor.applySPIMode(to: session) // ② + } + suppressor.applyLockedKeys(to: settings) // ③ + suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2) + let driverBypass = suppressor.applyDriverBypass( // ④ + to: settings, optionKeys: request.optionKeys) + suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤ + } + + private func runNativePanel( + _ request: PrintPanelRequest + ) throws -> PanelCaptureResult? { + // The PM printer handle is held through the modal and + // released on every path. + let pmPrinter = PMTicketBridge.makePrinter(queue: request.queue) defer { if let printer = pmPrinter { PMTicketBridge.release(printer) } } - // ②–⑤ ColourSync suppression — only on the PM path: the SPI - // and PMPrintSettingsSetValue need a session with a current - // printer to attach to. - var settings = PMTicketBridge.settings(printInfo) - var driverBypass: (key: String, value: String)? - if boundViaPM { - let session = PMTicketBridge.session(printInfo) - suppressor.applySPIMode(to: session) // ② - suppressor.applyLockedKeys(to: settings) // ③ - suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2) - driverBypass = suppressor.applyDriverBypass( // ④ - to: settings, optionKeys: optionKeys) - suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤ - } + let (printInfo, boundViaPM) = try makePanelPrintInfo( + for: request, boundTo: pmPrinter) + + debugDump(printInfo, when: "pre-modal") let panel = NSPrintPanel() panel.options = [ @@ -174,8 +244,9 @@ struct PrintPanelService { var cupsOptions: String? var mediaType: String? if boundViaPM { - settings = PMTicketBridge.settings(printInfo) - let captured = suppressor.captureOptions(from: settings) + debugDump(printInfo, when: "post-OK") + let captured = suppressor.captureOptions( + from: PMTicketBridge.settings(printInfo)) cupsOptions = captured.cupsOptions mediaType = captured.mediaType } @@ -183,8 +254,8 @@ struct PrintPanelService { let resolvedQueue = boundViaPM ? PMTicketBridge.currentPrinterID( session: PMTicketBridge.session(printInfo), - fallback: queue) - : queue + fallback: request.queue) + : request.queue // ⑦ Serialise the native ticket — the payload `lp -o` could // never carry (#201). Warn-only via `try?`: a serialise // failure must not lose the Stage 2 mirror above. @@ -243,4 +314,35 @@ struct PrintPanelService { } } + /// `ICCERY_DEBUG_PANEL_DUMP=1` — log the unfiltered + /// `PMPrintSettingsToOptions` output and the serialised + /// settings/page-format byte counts (#217 diagnostics for the + /// Epson RC3 question: does the PDE source pane state from the + /// session ticket or lpoptions?). DEBUG-only, warn-only. + private func debugDump(_ printInfo: NSPrintInfo, when phase: String) { + #if DEBUG + guard ProcessInfo.processInfo.environment[ + "ICCERY_DEBUG_PANEL_DUMP"] == "1" else { return } + let raw = suppressor.rawOptionsDump( + from: PMTicketBridge.settings(printInfo)) ?? "" + var settingsSize = -1 + var ref: Unmanaged? + if PMPrintSettingsCreateDataRepresentation( + PMTicketBridge.settings(printInfo), &ref, + kPMDataFormatXMLDefault) == noErr, let ref { + settingsSize = (ref.takeRetainedValue() as Data).count + } + var formatSize = -1 + var formatRef: Unmanaged? + if PMPageFormatCreateDataRepresentation( + PMTicketBridge.pageFormat(printInfo), &formatRef, + kPMDataFormatXMLDefault) == noErr, let formatRef { + formatSize = (formatRef.takeRetainedValue() as Data).count + } + AppLogger.shared.info( + "Print panel dump (\(phase)): settings=\(settingsSize)B " + + "pageFormat=\(formatSize)B options: \(raw)") + #endif + } + } diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index 29dc0f5..f9855f2 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -231,13 +231,15 @@ final class PrintSessionViewModel: ObservableObject { quality: selectedQuality, mediaType: selectedMediaType, orientation: printOrientation) + let ticket = capturedTickets[queue] Task { @MainActor in do { guard let result = try await PrintPanelService() .showProperties( queue: queue, displayName: displayName, cupsService: cups, - initialSelections: selections) + initialSelections: selections, + ticket: ticket) else { printNotice = Notice( kind: .info, diff --git a/Tests/ICCeryCoreTests/PrintPanelTicketTests.swift b/Tests/ICCeryCoreTests/PrintPanelTicketTests.swift new file mode 100644 index 0000000..908eff3 --- /dev/null +++ b/Tests/ICCeryCoreTests/PrintPanelTicketTests.swift @@ -0,0 +1,148 @@ +import AppKit +import Foundation +import XCTest +@testable import ICCery + +/// Issue #217 — the panel's pre-modal seam +/// (`PrintPanelService.configureBoundPanel`) must rehydrate the +/// captured `PrintTicket` before the Stage 2 writes, validate the +/// result, and refuse a foreign-queue ticket — all without driving +/// the modal or needing a real queue (`printer: nil` exercises +/// restore → selections → validate on a session-less `NSPrintInfo`). +@MainActor +final class PrintPanelTicketTests: XCTestCase { + + private let service = PrintPanelService() + + /// The queue a destination `NSPrintInfo` will report as bound — + /// the session's current printer, else a synthetic token for a + /// queue-less environment (same convention as PrintTicketTests). + private func boundQueue(of printInfo: NSPrintInfo) -> String { + PMTicketBridge.currentPrinterID( + session: PMTicketBridge.session(printInfo), + fallback: "UnboundQueue") + } + + private func makeTicket( + queue: String, + key: String = "EPIJ_Qual", + value: String = "305" + ) throws -> PrintTicket { + let source = NSPrintInfo() + XCTAssertTrue( + PMTicketBridge.setValue( + value, forKey: key, locked: false, + in: PMTicketBridge.settings(source), + context: "PrintPanelTicketTests")) + return try PMTicketBridge.serialise(source, queue: queue) + } + + private func request( + ticket: PrintTicket?, + selections: PrintPanelInitialSelections = + PrintPanelInitialSelections() + ) -> PrintPanelRequest { + PrintPanelRequest( + queue: "Panel_Q", displayName: nil, optionKeys: [], + initialSelections: selections, ticket: ticket) + } + + /// RC1 — a captured vendor key is present in the configured + /// printInfo before the modal would open. + func testTicketRestoredPreModal() throws { + let printInfo = NSPrintInfo() + let ticket = try makeTicket(queue: boundQueue(of: printInfo)) + + service.configureBoundPanel( + printInfo, request: request(ticket: ticket), printer: nil) + + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "EPIJ_Qual", + in: PMTicketBridge.settings(printInfo)), + "305") + } + + /// D6 parity — Stage 2 selections win over the restored ticket. + func testStage2WinsOverTicket() throws { + let printInfo = NSPrintInfo() + let ticket = try makeTicket(queue: boundQueue(of: printInfo)) + var selections = PrintPanelInitialSelections() + selections.qualityKey = "EPIJ_Qual" + selections.quality = "303" + + service.configureBoundPanel( + printInfo, + request: request(ticket: ticket, selections: selections), + printer: nil) + + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "EPIJ_Qual", + in: PMTicketBridge.settings(printInfo)), + "303") + } + + /// R3 — a ticket captured for another queue is refused; the + /// destination's settings stay untouched and nothing throws. + func testCrossQueueTicketRefusedAtPanel() throws { + let printInfo = NSPrintInfo() + let bound = boundQueue(of: printInfo) + let foreign = "DefinitelyNot_\(bound)" + try XCTSkipIf( + bound == foreign, + "destination resolved to the foreign queue") + XCTAssertTrue( + PMTicketBridge.setValue( + "999", forKey: "EPIJ_Qual", locked: false, + in: PMTicketBridge.settings(printInfo), + context: "PrintPanelTicketTests")) + let ticket = try makeTicket(queue: foreign) + + service.configureBoundPanel( + printInfo, request: request(ticket: ticket), printer: nil) + + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "EPIJ_Qual", + in: PMTicketBridge.settings(printInfo)), + "999") + } + + /// First-ever open — no ticket, selections still land and the + /// post-selections validate keeps them readable. + func testNoTicketKeepsSelections() { + let printInfo = NSPrintInfo() + var selections = PrintPanelInitialSelections() + selections.paperSize = "A4" + selections.qualityKey = "EPIJ_Qual" + selections.quality = "303" + selections.orientation = "landscape" + + service.configureBoundPanel( + printInfo, + request: request(ticket: nil, selections: selections), + printer: nil) + + let settings = PMTicketBridge.settings(printInfo) + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "PageSize", in: settings), "A4") + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "EPIJ_Qual", in: settings), "303") + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "orientation-requested", in: settings), "4") + // The suppression writes still land on the ticket so the PDE + // sees the locked AP_* pair (③) and Quartz vocabulary (⑤′). + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "AP_ColorMatchingMode", in: settings), + "AP_ApplicationColorMatching") + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "PMColorMatchingMode", in: settings), + "APCustomColorMatching") + } +} -- 2.39.5 From ecbf46723b42d59bf24e7673c81295b95b4e837b Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 22 Sep 2026 19:19:33 +0100 Subject: [PATCH 2/3] feat(print): #218 Stage 2 paper-source selection + orientation dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect vendor tray keys in CupsParsers with InputSlot/MediaSource/EPIJ_FdSo/EPIJ_PSrc/CNIJMediaSupply roster; EPIJ_FdSo wins over EPIJ_PSrc. - PrinterTray now carries raw token + display name; PrinterCapabilities exposes trayKey, trayDefault and tray-specific quality maps. - CupsService enumerates trays from the detected key, enriches labels from PPD, seeds selection from CUPS default, and precomputes per-tray media→quality constraints. - MediaQualityConstraints resolves quality availability from both selected media and selected tray, falling back to the media-only map. - TicketWriteResolver emits the selected tray key=token in the locked write order between PageSize and media key. - PrintPanelService applies the Stage 2 tray token to PMPrintSettings on open and captures the tray back from the full roster plus AP_D_InputSlot. - PrintSessionViewModel keeps selectedTrayToken, applies capture-back, preserves unknown captured tokens as synthetic trays, and passes tray into spool overrides. - Stage2View replaces portrait/landscape buttons with an orientation dropdown (printerOrientationSelect) while preserving portrait/landscape semantics and orientation-requested=3|4. - Add/update core, UI and fixture tests for tray detection, enumeration, PPD labels, defaults, unknown-token synthesis, panel capture, ticket write order and tray-aware quality clamping. - Update docs/10-print-system.md, docs/11-print-macos.md, docs/21-ui-reference.md and docs/24-issues-invariants.md. Verified on the macOS 12.7.6 x86_64 CI runner: xcodebuild build-for-testing and ICCeryCoreTests pass (557 tests, 0 failures). --- AGENTS.md | 4 + .../ICCeryCore/Print/CupsOptionsFilter.swift | 3 +- .../ICCeryCore/Print/CupsParsers.swift | 49 +++++++++ .../ICCeryCore/Print/CupsService.swift | 55 ++++++++-- .../Print/MediaQualityConstraints.swift | 6 +- .../ICCeryCore/Print/PrinterModels.swift | 73 ++++++++++--- Sources/ICCery/AppEnvironment.swift | 14 ++- .../ICCery/Print/ColorSyncSuppressor.swift | 18 ++-- Sources/ICCery/Print/PrintPanelService.swift | 43 ++++++-- .../ICCery/Print/PrintSessionViewModel.swift | 82 +++++++++++--- .../ICCery/Print/TicketWriteResolver.swift | 26 +++-- Sources/ICCery/Stage2View.swift | 16 ++- .../CupsOptionsFilterTests.swift | 22 ++++ Tests/ICCeryCoreTests/CupsParserTests.swift | 86 ++++++++++++++- .../MediaQualityConstraintsTests.swift | 101 ++++++++++++++++++ Tests/ICCeryCoreTests/PrintPanelTests.swift | 64 ++++++++++- .../PrintPanelTicketTests.swift | 36 ++++++- .../PrintSessionViewModelTests.swift | 83 +++++++++++++- .../TicketWriteResolverTests.swift | 42 ++++++-- Tests/ICCeryUITests/Fixtures/bin/lpoptions | 2 +- .../Milestone11PrintSettingsUITests.swift | 3 +- Tests/ICCeryUITests/Milestone3UITests.swift | 6 +- docs/10-print-system.md | 7 +- docs/11-print-macos.md | 15 +-- docs/21-ui-reference.md | 4 +- docs/24-issues-invariants.md | 2 +- 26 files changed, 758 insertions(+), 104 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dc9c0ec..f2af437 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,10 @@ codesign -dvv Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging. ## Remote test builds (runner host) +**Policy: all builds and test runs that _can_ execute on the remote host +_should_ execute there.** Use `localadmin@192.168.0.172` whenever the +Gitea `macos_12` runner is not actively executing a workflow job. + Test builds done as part of change implementations run on the CI runner host, not the dev machine — the runner is a macOS 12.7.6 **x86_64** VM with Xcode 14.2, and CI failures there are often environmental and do not diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift index 40e43cb..14b1248 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift @@ -17,7 +17,8 @@ public enum CupsOptionsFilter { // Media "MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType", // Tray - "InputSlot", "AP_D_InputSlot", + "InputSlot", "MediaSource", "AP_D_InputSlot", + "EPIJ_FdSo", "EPIJ_PSrc", "CNIJMediaSupply", // Size "PageSize", // Colour bypass diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift index 45d632a..1fb541e 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift @@ -353,6 +353,55 @@ public enum CupsParsers { .map { $0 == "4" ? "landscape" : "portrait" } } + // MARK: - Tray detection (#218) + + /// Paper-source option key in preference order. `EPIJ_FdSo` is + /// preferred over `EPIJ_PSrc` because Epson InkjetPrinter2 PPDs + /// label `EPIJ_FdSo` as "Paper Source" and `EPIJ_PSrc` as + /// "Page Setup" — the feed source is what Stage 2 should control. + /// `AP_D_InputSlot` is a capture-side alias only. + public static let trayKeys = [ + "InputSlot", "MediaSource", "EPIJ_FdSo", "EPIJ_PSrc", + "CNIJMediaSupply" + ] + + public static func detectTrayKey(optionKeys: Set) -> String? { + trayKeys.first { optionKeys.contains($0) } + } + + /// Extract a paper-source token from a captured `key=value` string. + /// If `preferredKey` is supplied and present, it wins; otherwise + /// every roster key is tried. `AP_D_InputSlot` is accepted as an + /// alias when it carries a non-empty value. Empty values are + /// ignored so an `AP_D_InputSlot=` placeholder never maps to a + /// selection (#218 capture-back). + public static func extractTray( + fromOptionsString options: String, + preferredKey: String? = nil + ) -> String? { + let pairs = lpoptions(options) + if let preferredKey, + let value = pairs.first(where: { + $0.key.caseInsensitiveCompare(preferredKey) == .orderedSame + })?.value, !value.isEmpty { + return value + } + for key in trayKeys { + if let value = pairs.first(where: { + $0.key.caseInsensitiveCompare(key) == .orderedSame + })?.value, !value.isEmpty { + return value + } + } + // Capture-side alias used by some macOS Core Printing paths. + if let value = pairs.first(where: { + $0.key.caseInsensitiveCompare("AP_D_InputSlot") == .orderedSame + })?.value, !value.isEmpty { + return value + } + return nil + } + /// Driver "no colour adjustment" key=value for `lpoptions -l` keys /// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`; /// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`; diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift index 10e02dc..32e672e 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift @@ -122,8 +122,28 @@ public struct CupsService: Sendable { let listings = try await optionListings(for: queue) let ppd = loadPPD(for: queue) var caps = capabilities(from: listings, ppd: ppd) + // Default queue-level map, then per-tray overrides (#218). + // Share one file-read cache across all contexts so multiple + // tray-token resolutions read Epson PDEData.dat only once. + var fileCache: [URL: Data] = [:] + let readFile: (URL) -> Data? = { + if let cached = fileCache[$0] { return cached } + guard let data = try? Data(contentsOf: $0) else { return nil } + fileCache[$0] = data + return data + } caps.qualityIDsByMediaType = MediaQualityConstraints.resolve( - listings: listings, ppd: ppd) + listings: listings, ppd: ppd, + optionOverrides: [:], readFile: readFile) + if let trayKey = caps.trayKey { + for tray in caps.trays { + let map = MediaQualityConstraints.resolve( + listings: listings, ppd: ppd, + optionOverrides: [trayKey: tray.token], + readFile: readFile) + caps.qualityIDsByMediaTypeByTray[tray.token] = map + } + } return caps } @@ -132,16 +152,12 @@ public struct CupsService: Sendable { from listings: [CupsOptionListing], ppd: String? ) -> PrinterCapabilities { - var trays: [PrinterTray] = [] + let optionKeys = Set(listings.map(\.key)) var sizes: [PrinterPaperSize] = [] var media: [PrinterMediaType] = [] for listing in listings { switch listing.key { - case "InputSlot", "MediaSource": - trays = listing.choices.enumerated().map { - PrinterTray(id: $0.offset + 1, name: $0.element) - } case "PageSize", "MediaSize": sizes = listing.choices.enumerated().map { PrinterPaperSize(id: $0.offset + 1, name: $0.element) @@ -159,12 +175,32 @@ public struct CupsService: Sendable { } } + // Paper source — one deterministic vendor roster key (#218). + let trayKey = CupsParsers.detectTrayKey(optionKeys: optionKeys) + let trays: [PrinterTray] + let trayDefault: String? + if let trayKey, + let listing = listings.first(where: { $0.key == trayKey }) { + let labels = ppd.map { + CupsParsers.ppdChoiceLabels($0, key: trayKey) + } ?? [:] + trays = listing.choices.enumerated().map { + PrinterTray( + id: $0.offset + 1, + token: $0.element, + name: labels[$0.element] ?? $0.element) + } + trayDefault = listing.defaultChoice + } else { + trays = [] + trayDefault = nil + } + // Print quality — the detected roster key's listing maps to // `PrinterQuality` with PPD labels and the `*` default (#183). var qualities: [PrinterQuality] = [] var qualityDefault: String? - let qualityKey = CupsParsers.detectQualityKey( - optionKeys: Set(listings.map(\.key))) + let qualityKey = CupsParsers.detectQualityKey(optionKeys: optionKeys) if let qualityKey, let listing = listings.first(where: { $0.key == qualityKey }) { let labels = ppd.map { @@ -178,7 +214,8 @@ public struct CupsService: Sendable { return PrinterCapabilities( trays: trays, paperSizes: sizes, mediaTypes: media, - qualityKey: qualityKey, qualities: qualities, + trayKey: trayKey, qualityKey: qualityKey, + qualities: qualities, trayDefault: trayDefault, qualityDefault: qualityDefault) } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/MediaQualityConstraints.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/MediaQualityConstraints.swift index 857d7c0..fd3a683 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/MediaQualityConstraints.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/MediaQualityConstraints.swift @@ -32,6 +32,7 @@ public enum MediaQualityConstraints { public static func resolve( listings: [CupsOptionListing], ppd: String?, + optionOverrides: [String: String] = [:], readFile: (URL) -> Data? = { try? Data(contentsOf: $0) } ) -> [String: Set] { let optionKeys = Set(listings.map(\.key)) @@ -46,9 +47,12 @@ public enum MediaQualityConstraints { else { return [:] } let mediaIDs = Set(mediaListing.choices) let qualityIDs = Set(qualityListing.choices) - let defaults = Dictionary( + var defaults = Dictionary( listings.compactMap { l in l.defaultChoice.map { (l.key, $0) } }, uniquingKeysWith: { first, _ in first }) + for (key, value) in optionOverrides { + defaults[key] = value + } guard let ppd else { return [:] } let ppdMap = allowedMap( diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift index 5e648ba..e2a2f2c 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift @@ -48,13 +48,17 @@ public struct Printer: Codable, Equatable, Sendable { } /// Paper source. `id` is the 1-based index of the `InputSlot` / -/// `MediaSource` choice (not a PPD code) — docs/10. +/// `MediaSource` / vendor choice (not a PPD code); `token` is the +/// exact CUPS/PPD machine value written to the ticket; `name` is the +/// PPD-enriched display label (#218). public struct PrinterTray: Codable, Equatable, Sendable { public var id: Int + public var token: String public var name: String - public init(id: Int, name: String) { + public init(id: Int, token: String, name: String) { self.id = id + self.token = token self.name = name } } @@ -101,10 +105,16 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable { /// Always `true` on macOS (spec parity — CUPS honours /// `orientation-requested`). public var supportsOrientation: Bool + /// The queue's detected tray enumeration key + /// (`CupsParsers.detectTrayKey`), e.g. `EPIJ_FdSo` (#218). + public var trayKey: String? /// The queue's detected quality enumeration key /// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183). public var qualityKey: String? public var qualities: [PrinterQuality] + /// The `*`-marked default tray token from `lpoptions -l`, if any + /// (#218). + public var trayDefault: String? /// The `*`-marked default choice from `lpoptions -l`, if any. public var qualityDefault: String? /// Media-type id → the quality ids the driver accepts for it @@ -112,50 +122,81 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable { /// exposes no per-media quality matrix; a missing entry means /// "unconstrained" — show everything. public var qualityIDsByMediaType: [String: Set] + /// Tray token → media-type id → allowed quality ids. Computed + /// once at capability load; a missing tray falls back to + /// `qualityIDsByMediaType` (#218). + public var qualityIDsByMediaTypeByTray: [String: [String: Set]] public init( trays: [PrinterTray] = [], paperSizes: [PrinterPaperSize] = [], mediaTypes: [PrinterMediaType] = [], supportsOrientation: Bool = true, + trayKey: String? = nil, qualityKey: String? = nil, qualities: [PrinterQuality] = [], + trayDefault: String? = nil, qualityDefault: String? = nil, - qualityIDsByMediaType: [String: Set] = [:] + qualityIDsByMediaType: [String: Set] = [:], + qualityIDsByMediaTypeByTray: [String: [String: Set]] = [:] ) { self.trays = trays self.paperSizes = paperSizes self.mediaTypes = mediaTypes self.supportsOrientation = supportsOrientation + self.trayKey = trayKey self.qualityKey = qualityKey self.qualities = qualities + self.trayDefault = trayDefault self.qualityDefault = qualityDefault self.qualityIDsByMediaType = qualityIDsByMediaType + self.qualityIDsByMediaTypeByTray = qualityIDsByMediaTypeByTray } - /// Qualities valid for `mediaID`, preserving the driver's listing - /// order (#180). Falls back to the full list when the driver has - /// no per-media data, the media is absent from the map, or the - /// filter would empty the picker (#214). - public func qualities(forMediaType mediaID: String?) -> [PrinterQuality] { + /// The quality map for the requested tray token, if any. + /// Prefers the tray-specific map; otherwise falls back to the + /// queue-default map. An explicitly empty tray map means the tray + /// is unconstrained, so `nil` is returned only when neither map + /// exists. + private func qualityMap( + forTrayToken trayToken: String? + ) -> [String: Set]? { + if let trayToken, + let map = qualityIDsByMediaTypeByTray[trayToken], + !map.isEmpty { + return map + } + return qualityIDsByMediaType.isEmpty ? nil : qualityIDsByMediaType + } + + /// Qualities valid for `mediaID` and an optional tray token, + /// preserving the driver's listing order (#180/#218). Falls back + /// to the full list when the driver has no per-media data, the + /// media is absent from the map, or the filter would empty the + /// picker (#214). + public func qualities( + forMediaType mediaID: String?, + trayToken: String? = nil + ) -> [PrinterQuality] { guard let mediaID, - let allowed = qualityIDsByMediaType[mediaID], + let allowed = qualityMap(forTrayToken: trayToken)?[mediaID], !allowed.isEmpty else { return qualities } let filtered = qualities.filter { allowed.contains($0.id) } return filtered.isEmpty ? qualities : filtered } - /// Whether `qualityID` is usable with `mediaID` — `true` whenever - /// the driver exposes no constraint for that media (#214). A - /// quality absent from `qualities` entirely is still allowed: a - /// driver-captured token can be legitimate even when `lpoptions` - /// never listed it. + /// Whether `qualityID` is usable with `mediaID` and an optional + /// tray token (#218). A quality absent from `qualities` entirely + /// is still allowed: a driver-captured token can be legitimate + /// even when `lpoptions` never listed it. public func allowsQuality( - _ qualityID: String, forMediaType mediaID: String? + _ qualityID: String, + forMediaType mediaID: String?, + trayToken: String? = nil ) -> Bool { guard let mediaID, - let allowed = qualityIDsByMediaType[mediaID], + let allowed = qualityMap(forTrayToken: trayToken)?[mediaID], !allowed.isEmpty else { return true } return allowed.contains(qualityID) diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift index 792c694..2cfb499 100644 --- a/Sources/ICCery/AppEnvironment.swift +++ b/Sources/ICCery/AppEnvironment.swift @@ -133,17 +133,29 @@ enum UITestHooks { /// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and /// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the /// queue the panel was opened for). - static func printPanelResult(forQueue queue: String) -> PrintPropertiesResult? { + static func printPanelResult( + forQueue queue: String, + trays: [PrinterTray] = [], + preferredTrayKey: String? = nil + ) -> PrintPropertiesResult? { switch env["ICCERY_TEST_PRINT_PANEL"] { case "ok": let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap { $0.isEmpty ? nil : $0 } + let trayToken = options.flatMap { + CupsParsers.extractTray( + fromOptionsString: $0, preferredKey: preferredTrayKey) + } + let paperSource = trays.first(where: { + $0.token == trayToken + }).map(\.id) return PrintPropertiesResult( selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap { $0.isEmpty ? nil : $0 } ?? queue, options: PrintOptions( + paperSource: paperSource, orientation: options.flatMap { CupsParsers.extractOrientation(fromOptionsString: $0) }, diff --git a/Sources/ICCery/Print/ColorSyncSuppressor.swift b/Sources/ICCery/Print/ColorSyncSuppressor.swift index 8378711..1546765 100644 --- a/Sources/ICCery/Print/ColorSyncSuppressor.swift +++ b/Sources/ICCery/Print/ColorSyncSuppressor.swift @@ -181,18 +181,24 @@ struct ColorSyncSuppressor { // MARK: - Layer ⑥ capture - /// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType)`. - /// The malloc'd C string is freed after copying. + /// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType, + /// tray)`. The malloc'd C string is freed after copying. `trayKey` + /// is the queue's detected tray enumeration key; if nil, the full + /// roster plus the capture-only `AP_D_InputSlot` alias is tried. func captureOptions( - from settings: PMPrintSettings - ) -> (cupsOptions: String?, mediaType: String?) { + from settings: PMPrintSettings, + trayKey: String? + ) -> (cupsOptions: String?, mediaType: String?, tray: String?) { guard let unfiltered = rawOptionsDump(from: settings) else { - return (nil, nil) + return (nil, nil, nil) } let filtered = CupsOptionsFilter.filter(unfiltered) return ( filtered.isEmpty ? nil : filtered, - CupsParsers.extractMediaType(fromOptionsString: unfiltered) + CupsParsers.extractMediaType(fromOptionsString: unfiltered), + CupsParsers.extractTray( + fromOptionsString: unfiltered, + preferredKey: trayKey) ) } diff --git a/Sources/ICCery/Print/PrintPanelService.swift b/Sources/ICCery/Print/PrintPanelService.swift index 1109fe9..4ea6d1c 100644 --- a/Sources/ICCery/Print/PrintPanelService.swift +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -21,6 +21,10 @@ struct PrintPanelInitialSelections { /// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written /// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1). var paperSize: String? + /// The queue's detected tray enumeration key, e.g. `EPIJ_FdSo`. + var trayKey: String? + /// The selected tray machine token. + var tray: String? /// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`. var qualityKey: String? /// The selected quality token. @@ -43,6 +47,10 @@ struct PrintPanelRequest { /// The queue's previously captured ticket — rehydrated before the /// Stage 2 writes so vendor PDE state survives a reopen (#217). let ticket: PrintTicket? + /// Roster of paper-source choices used to map a captured option + /// token back to a 1-based ID. Empty for queues with no tray key + /// (#218). + let trays: [PrinterTray] } /// Preferences → native `NSPrintPanel` bound to the selected CUPS @@ -72,11 +80,15 @@ struct PrintPanelService { cupsService: CupsService, initialSelections: PrintPanelInitialSelections = PrintPanelInitialSelections(), - ticket: PrintTicket? = nil + ticket: PrintTicket? = nil, + trays: [PrinterTray] = [] ) async throws -> PanelCaptureResult? { #if DEBUG if UITestHooks.printPanelStubbed { - return UITestHooks.printPanelResult(forQueue: queue).map { + return UITestHooks.printPanelResult( + forQueue: queue, trays: trays, + preferredTrayKey: initialSelections.trayKey + ).map { PanelCaptureResult(properties: $0, ticket: nil) } } @@ -92,7 +104,8 @@ struct PrintPanelService { PrintPanelRequest( queue: queue, displayName: display, optionKeys: optionKeys, - initialSelections: initialSelections, ticket: ticket)) + initialSelections: initialSelections, ticket: ticket, + trays: trays)) } // MARK: - Panel @@ -243,12 +256,15 @@ struct PrintPanelService { // (#183); the PDE may rewrite or drop them (R12). var cupsOptions: String? var mediaType: String? + var trayToken: String? if boundViaPM { debugDump(printInfo, when: "post-OK") let captured = suppressor.captureOptions( - from: PMTicketBridge.settings(printInfo)) + from: PMTicketBridge.settings(printInfo), + trayKey: request.initialSelections.trayKey) cupsOptions = captured.cupsOptions mediaType = captured.mediaType + trayToken = captured.tray } let capturedOptions = cupsOptions ?? "" let resolvedQueue = boundViaPM @@ -261,10 +277,14 @@ struct PrintPanelService { // failure must not lose the Stage 2 mirror above. let ticket = try? PMTicketBridge.serialise( printInfo, queue: resolvedQueue) + let paperSource = request.trays.first(where: { + $0.token == trayToken + }).map(\.id) return PanelCaptureResult( properties: PrintPropertiesResult( selectedPrinter: boundViaPM ? resolvedQueue : nil, options: PrintOptions( + paperSource: paperSource, orientation: CupsParsers.extractOrientation( fromOptionsString: capturedOptions), paperSize: CupsParsers.extractOption( @@ -278,10 +298,10 @@ struct PrintPanelService { ticket: ticket) } - /// Initial-selection `PMPrintSettings` writes — paper, quality, - /// media type, orientation. All warn-only: a driver that ignores - /// a key must not keep the panel from opening (R12 surfaces via - /// the capture echo instead). + /// Initial-selection `PMPrintSettings` writes — paper, tray, + /// quality, media type, orientation. All warn-only: a driver that + /// ignores a key must not keep the panel from opening (R12 surfaces + /// via the capture echo instead). private func applyInitialSelections( _ selections: PrintPanelInitialSelections, to settings: PMPrintSettings, @@ -292,6 +312,13 @@ struct PrintPanelService { paperSize, forKey: "PageSize", locked: false, in: settings, context: "Print panel") } + // Paper source via the queue's detected tray roster key (#218). + if let tray = selections.tray, !tray.isEmpty, + let trayKey = selections.trayKey { + PMTicketBridge.setValue( + tray, forKey: trayKey, locked: false, + in: settings, context: "Print panel") + } if let key = selections.qualityKey, let value = selections.quality { PMTicketBridge.setValue( value, forKey: key, locked: false, diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index f9855f2..f4a0dc4 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -24,7 +24,15 @@ final class PrintSessionViewModel: ObservableObject { } } @Published var printerCaps = PrinterCapabilities() - @Published var selectedTray: Int? + @Published var selectedTray: Int? { + didSet { + // A tray switch can invalidate the current quality pick + // using the tray-specific Epson constraint map (#218). + if selectedTray != oldValue { + clampQualityToMedia() + } + } + } @Published var selectedMediaType: String? { didSet { // A media switch can invalidate the current quality pick — @@ -128,8 +136,11 @@ final class PrintSessionViewModel: ObservableObject { if selectedMediaType == nil { selectedMediaType = printerCaps.mediaTypes.first?.id } - if selectedTray == nil { - selectedTray = printerCaps.trays.first?.id + if selectedTray == nil || !printerCaps.trays + .contains(where: { $0.id == selectedTray }) { + selectedTray = printerCaps.trayDefault.flatMap { token in + printerCaps.trays.first { $0.token == token }?.id + } ?? printerCaps.trays.first?.id } // Quality seeds inside the selected media's allowed set // (#214): driver default when valid there, else the first @@ -152,17 +163,26 @@ final class PrintSessionViewModel: ObservableObject { // MARK: - Paper / quality selection (#183) - /// The quality picker's source (#214): `printerCaps.qualities` - /// filtered to what the driver accepts for `selectedMediaType`. - /// Equals the full list whenever the queue exposes no per-media - /// quality matrix — and never empty while `qualities` is not. + /// The raw tray token for the currently selected source (#218). + var selectedTrayToken: String? { + guard let id = selectedTray else { return nil } + return printerCaps.trays.first { $0.id == id }?.token + } + + /// The quality picker's source (#214/#218): `printerCaps.qualities` + /// filtered to what the driver accepts for `selectedMediaType` and + /// the current tray token. Equals the full list whenever the queue + /// exposes no per-media/per-tray quality matrix — and never empty + /// while `qualities` is not. var availableQualities: [PrinterQuality] { - printerCaps.qualities(forMediaType: selectedMediaType) + printerCaps.qualities( + forMediaType: selectedMediaType, + trayToken: selectedTrayToken) } /// Keep `selectedQuality` inside the allowed set for the current - /// media: keep the pick when still valid, else the driver default - /// when valid, else the first allowed choice (#214). + /// media and tray: keep the pick when still valid, else the driver + /// default when valid, else the first allowed choice (#214/#218). private func clampQualityToMedia() { let allowed = availableQualities guard !allowed.isEmpty else { return } @@ -178,6 +198,15 @@ final class PrintSessionViewModel: ObservableObject { } } + /// The tray token for a captured option string, used both for + /// applying panel results and for synthesising unknown selections. + private func capturedTrayToken(from options: String?) -> String? { + guard let options else { return nil } + return CupsParsers.extractTray( + fromOptionsString: options, + preferredKey: printerCaps.trayKey) + } + /// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`: /// a capability whose name matches `pageSize.rawValue` → its id; /// `.custom` → the synthetic `Custom.x` entry (`id: 0`); @@ -227,6 +256,8 @@ final class PrintSessionViewModel: ObservableObject { let cups = environment.cupsService let selections = PrintPanelInitialSelections( paperSize: selectedPaperSizeToken, + trayKey: printerCaps.trayKey, + tray: selectedTrayToken, qualityKey: printerCaps.qualityKey, quality: selectedQuality, mediaType: selectedMediaType, @@ -239,7 +270,8 @@ final class PrintSessionViewModel: ObservableObject { queue: queue, displayName: displayName, cupsService: cups, initialSelections: selections, - ticket: ticket) + ticket: ticket, + trays: printerCaps.trays) else { printNotice = Notice( kind: .info, @@ -264,6 +296,28 @@ final class PrintSessionViewModel: ObservableObject { if let media = result.properties.options.mediaType { selectedMediaType = media } + // Capture-return paper source (#218): a dialog tray + // change updates Stage 2; unknown tokens are appended + // so a driver-only source remains selectable. + if let options = result.properties.options.cupsOptions, + let trayToken = capturedTrayToken(from: options), + !trayToken.isEmpty { + if let known = printerCaps.trays + .first(where: { $0.token == trayToken }) { + selectedTray = known.id + } else { + let nextID = (printerCaps.trays.map(\.id).max() ?? 0) + 1 + let synthetic = PrinterTray( + id: nextID, token: trayToken, name: trayToken) + printerCaps.trays.append(synthetic) + selectedTray = nextID + } + } else if let paperSource = result.properties.options.paperSource, + printerCaps.trays.contains(where: { + $0.id == paperSource + }) { + selectedTray = paperSource + } // Capture-return (#183/#186): a dialog paper/quality/ // orientation change updates the Stage 2 selections — // never `workflow.pageSize` (printtarg layout is @@ -412,7 +466,9 @@ final class PrintSessionViewModel: ObservableObject { // the last gate before the driver). var quality = selectedQuality if let q = quality, - !printerCaps.allowsQuality(q, forMediaType: selectedMediaType) { + !printerCaps.allowsQuality( + q, forMediaType: selectedMediaType, + trayToken: selectedTrayToken) { quality = availableQualities.first?.id ?? q } return TargetPrintRequest( @@ -431,6 +487,8 @@ final class PrintSessionViewModel: ObservableObject { mediaType: selectedMediaType, qualityKey: printerCaps.qualityKey, quality: quality, + trayKey: printerCaps.trayKey, + tray: selectedTrayToken, orientation: printOrientation), optionKeys: optionKeys) } diff --git a/Sources/ICCery/Print/TicketWriteResolver.swift b/Sources/ICCery/Print/TicketWriteResolver.swift index 7af2b53..e5fc172 100644 --- a/Sources/ICCery/Print/TicketWriteResolver.swift +++ b/Sources/ICCery/Print/TicketWriteResolver.swift @@ -8,6 +8,8 @@ struct TargetPrintOverrides: Equatable { var mediaType: String? var qualityKey: String? // PrinterCapabilities.qualityKey var quality: String? + var trayKey: String? // PrinterCapabilities.trayKey + var tray: String? // raw tray token (#218) var orientation: String? // "portrait" | "landscape" } @@ -42,10 +44,11 @@ enum TicketWriteResolver { /// 5 `com.apple.print.PrintSettings.PMColorMatchingMode` /// = APCustomColorMatching (unlocked) /// 6 `PageSize` = overrides.paperSize - /// 7 detectMediaTypeKey(optionKeys) = overrides.mediaType - /// 8 overrides.qualityKey = overrides.quality - /// 9 detectDriverColorBypass(optionKeys) (unlocked) - /// 10 `orientation-requested` = 3 | 4 + /// 7 overrides.trayKey = overrides.tray + /// 8 detectMediaTypeKey(optionKeys) = overrides.mediaType + /// 9 overrides.qualityKey = overrides.quality + /// 10 detectDriverColorBypass(optionKeys) (unlocked) + /// 11 `orientation-requested` = 3 | 4 /// /// Invariants: `raw` can never appear (there is no `lp`); nothing /// is suppressed because a captured key already exists (D6 — the @@ -87,7 +90,14 @@ enum TicketWriteResolver { key: "PageSize", value: paperSize, locked: false)) } - // 7 — media type via the queue's detected vendor key. + // 7 — paper source via the queue's detected tray roster key. + if let trayKey = overrides.trayKey, !trayKey.isEmpty, + let tray = overrides.tray, !tray.isEmpty { + writes.append(TicketWrite( + key: trayKey, value: tray, locked: false)) + } + + // 8 — media type via the queue's detected vendor key. if let mediaType = overrides.mediaType, let mediaKey = CupsParsers.detectMediaTypeKey( optionKeys: optionKeys) { @@ -95,7 +105,7 @@ enum TicketWriteResolver { key: mediaKey, value: mediaType, locked: false)) } - // 8 — print quality; the detected enumeration key travels in + // 9 — print quality; the detected enumeration key travels in // the overrides (`PrinterCapabilities.qualityKey`, #183). if let qualityKey = overrides.qualityKey, let quality = overrides.quality { @@ -103,7 +113,7 @@ enum TicketWriteResolver { key: qualityKey, value: quality, locked: false)) } - // 9 — driver "no colour adjustment" bypass. Never gated on + // 10 — driver "no colour adjustment" bypass. Never gated on // `ppdUncorrectedPassthrough` — macOS always bypasses. if let bypass = CupsParsers.detectDriverColorBypass( optionKeys: optionKeys) { @@ -111,7 +121,7 @@ enum TicketWriteResolver { key: bypass.key, value: bypass.value, locked: false)) } - // 10 — orientation, CUPS IPP codes portrait=3 / landscape=4. + // 11 — orientation, CUPS IPP codes portrait=3 / landscape=4. if let orientation = overrides.orientation { writes.append(TicketWrite( key: "orientation-requested", diff --git a/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift index b7d55e3..a9ba439 100644 --- a/Sources/ICCery/Stage2View.swift +++ b/Sources/ICCery/Stage2View.swift @@ -297,7 +297,7 @@ struct Stage2View: View { // ViewBuilder ≤10 children (R13). HStack(spacing: 14) { if !workflow.print.printerCaps.trays.isEmpty { - Picker("Tray", selection: $workflow.print.selectedTray) { + Picker("Paper source", selection: $workflow.print.selectedTray) { ForEach(workflow.print.printerCaps.trays, id: \.id) { Text($0.name).tag(Optional($0.id)) } @@ -322,16 +322,12 @@ struct Stage2View: View { if !workflow.print.printerCaps.qualities.isEmpty { qualityGroup } - HStack(spacing: 0) { - Button("Portrait") { workflow.print.printOrientation = "portrait" } - .buttonStyle(.bordered) - .tint(workflow.print.printOrientation == "portrait" ? .accentColor : .gray) - .accessibilityIdentifier("btnOrientPortrait") - Button("Landscape") { workflow.print.printOrientation = "landscape" } - .buttonStyle(.bordered) - .tint(workflow.print.printOrientation == "landscape" ? .accentColor : .gray) - .accessibilityIdentifier("btnOrientLandscape") + Picker("Orientation", selection: $workflow.print.printOrientation) { + Text("Portrait").tag("portrait") + Text("Landscape").tag("landscape") } + .frame(maxWidth: 200) + .accessibilityIdentifier("printerOrientationSelect") Spacer() } // Stage 1 owns the custom dimensions — the caption lives diff --git a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift index a14977b..d2863a8 100644 --- a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift +++ b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift @@ -54,6 +54,28 @@ final class CupsOptionsFilterTests: XCTestCase { XCTAssertNil(CupsParsers.extractMediaType( fromOptionsString: "PageSize=A4")) } + + /// #218 — vendor paper-source keys survive the filter, and an + /// empty `AP_D_InputSlot=` is still dropped. + func testKeepsTrayKeys() { + let raw = "InputSlot=Rear MediaSource=Front EPIJ_FdSo=Rear " + + "EPIJ_PSrc=Front CNIJMediaSupply=1 AP_D_InputSlot=Auto" + XCTAssertEqual(CupsOptionsFilter.filter(raw), raw) + + let emptyAlias = "AP_D_InputSlot= InputSlot=Rear" + XCTAssertEqual(CupsOptionsFilter.filter(emptyAlias), "InputSlot=Rear") + } + + func testExtractTray() { + XCTAssertEqual(CupsParsers.extractTray( + fromOptionsString: "EPIJ_FdSo=Rear EPIJ_PSrc=Front", + preferredKey: "EPIJ_FdSo"), "Rear") + XCTAssertEqual(CupsParsers.extractTray( + fromOptionsString: "AP_D_InputSlot=Auto InputSlot=Rear", + preferredKey: "InputSlot"), "Rear") + XCTAssertNil(CupsParsers.extractTray( + fromOptionsString: "PageSize=A4")) + } } /// Issue 14 — the dlsym attempt order and first-success semantics. diff --git a/Tests/ICCeryCoreTests/CupsParserTests.swift b/Tests/ICCeryCoreTests/CupsParserTests.swift index 2b58ad6..a5486dd 100644 --- a/Tests/ICCeryCoreTests/CupsParserTests.swift +++ b/Tests/ICCeryCoreTests/CupsParserTests.swift @@ -83,11 +83,13 @@ final class CupsParserTests: XCTestCase { let caps = service.capabilities(from: listings, ppd: nil) XCTAssertEqual(caps.trays, [ - PrinterTray(id: 1, name: "Auto"), - PrinterTray(id: 2, name: "Main"), - PrinterTray(id: 3, name: "Photo"), - PrinterTray(id: 4, name: "Rear"), + PrinterTray(id: 1, token: "Auto", name: "Auto"), + PrinterTray(id: 2, token: "Main", name: "Main"), + PrinterTray(id: 3, token: "Photo", name: "Photo"), + PrinterTray(id: 4, token: "Rear", name: "Rear"), ]) + XCTAssertEqual(caps.trayKey, "InputSlot") + XCTAssertEqual(caps.trayDefault, "Main") XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5")) XCTAssertEqual(caps.paperSizes.count, 10) XCTAssertEqual(caps.mediaTypes.map(\.id), [ @@ -283,6 +285,82 @@ final class CupsParserTests: XCTestCase { XCTAssertNil(pair(["PageSize"])) } + // MARK: - #218 paper-source roster + + func testTrayKeyPrecedence() { + // Generic keys first; `EPIJ_FdSo` preferred over `EPIJ_PSrc`. + XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [ + "InputSlot", "EPIJ_FdSo", "EPIJ_PSrc"]), "InputSlot") + XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [ + "MediaSource", "EPIJ_FdSo", "EPIJ_PSrc"]), "MediaSource") + XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [ + "EPIJ_FdSo", "EPIJ_PSrc"]), "EPIJ_FdSo") + XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [ + "EPIJ_PSrc", "CNIJMediaSupply"]), "EPIJ_PSrc") + XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [ + "CNIJMediaSupply"]), "CNIJMediaSupply") + XCTAssertNil(CupsParsers.detectTrayKey(optionKeys: ["PageSize"])) + } + + func testExtractTrayPreferredKeyAndRoster() { + let preferred = CupsParsers.extractTray( + fromOptionsString: "EPIJ_FdSo=Rear EPIJ_PSrc=Front", + preferredKey: "EPIJ_FdSo") + XCTAssertEqual(preferred, "Rear") + + let fallback = CupsParsers.extractTray( + fromOptionsString: "EPIJ_PSrc=Front", preferredKey: "EPIJ_FdSo") + XCTAssertEqual(fallback, "Front") + + let alias = CupsParsers.extractTray( + fromOptionsString: "AP_D_InputSlot=Auto") + XCTAssertEqual(alias, "Auto") + + XCTAssertNil(CupsParsers.extractTray( + fromOptionsString: "PageSize=A4")) + XCTAssertNil(CupsParsers.extractTray( + fromOptionsString: "AP_D_InputSlot=")) + } + + func testTrayExtractionCaseInsensitive() { + XCTAssertEqual(CupsParsers.extractTray( + fromOptionsString: "epij_fdso=Rear", preferredKey: "EPIJ_FdSo"), + "Rear") + } + + func testCapabilitiesTraysEpson() { + let service = CupsService() + let listings = CupsParsers.lpoptionsList(""" + EPIJ_PSrc/Page Setup: *Front Rear Cassette + EPIJ_FdSo/Paper Source: Auto *Rear Front + """) + let caps = service.capabilities(from: listings, ppd: nil) + + XCTAssertEqual(caps.trayKey, "EPIJ_FdSo") + XCTAssertEqual(caps.trayDefault, "Rear") + XCTAssertEqual(caps.trays.map(\.token), ["Auto", "Rear", "Front"]) + XCTAssertEqual(caps.trays.map(\.name), ["Auto", "Rear", "Front"]) + } + + func testCapabilitiesTraysCanon() { + let ppd = """ + *CNIJMediaSupply 1/Cassette (Upper): "" + *CNIJMediaSupply 2/Manual Feed: "" + *CNIJMediaSupply 4/Rear Tray: "" + """ + let service = CupsService() + let listings = CupsParsers.lpoptionsList(""" + CNIJMediaSupply/Media Supply: *1 2 4 + """) + let caps = service.capabilities(from: listings, ppd: ppd) + + XCTAssertEqual(caps.trayKey, "CNIJMediaSupply") + XCTAssertEqual(caps.trays.map(\.token), ["1", "2", "4"]) + XCTAssertEqual(caps.trays.map(\.name), [ + "Cassette (Upper)", "Manual Feed", "Rear Tray" + ]) + } + // MARK: - #181 Canon media locale precedence + PPD encoding /// The 18 Canon Pro9500 media types named in the issue — the ids diff --git a/Tests/ICCeryCoreTests/MediaQualityConstraintsTests.swift b/Tests/ICCeryCoreTests/MediaQualityConstraintsTests.swift index ceaf475..f7c47b4 100644 --- a/Tests/ICCeryCoreTests/MediaQualityConstraintsTests.swift +++ b/Tests/ICCeryCoreTests/MediaQualityConstraintsTests.swift @@ -194,6 +194,73 @@ final class MediaQualityConstraintsTests: XCTestCase { XCTAssertNil(map["13"]) } + // MARK: - #218 tray-aware quality constraints + + /// A conditional rule keyed by the selected tray/token must use the + /// supplied override rather than the queue's lpoptions default. + func testResolveWithTrayOverride() { + let dat = """ + *EPIJUIConstraint: *EPIJ_FdSo 3 *EPIJ_Medi 0|*EPIJ_Qual 305 + *EPIJUIConstraint: *EPIJ_FdSo 12 *EPIJ_Medi 0|*EPIJ_Qual 307 + """ + // The queue default is EPIJ_FdSo=2 (from `epsonListings`), so + // the default map has no forbidden qualities for media "0". + let defaultMap = MediaQualityConstraints.resolve( + listings: epsonListings, ppd: nil) + XCTAssertNil(defaultMap["0"]) + + // Override to tray "3" -> 305 forbidden; override to tray "12" + // -> 307 forbidden. + let ppd = """ + *EPIJDriverBasePath: "/drivers/epson" + *EPIJMachineBundleName: "EP14C0605W.data" + """ + let datURL = URL(fileURLWithPath: + "/drivers/epson/Machine/EP14C0605W.data/" + + "Contents/Resources/PDEData.dat") + let readFile: (URL) -> Data? = { + $0 == datURL ? dat.data(using: .utf8) : nil + } + let tray3 = MediaQualityConstraints.resolve( + listings: epsonListings, ppd: ppd, + optionOverrides: ["EPIJ_FdSo": "3"], readFile: readFile) + XCTAssertEqual(tray3["0"], epsonQualityIDs.subtracting(["305"])) + + let tray12 = MediaQualityConstraints.resolve( + listings: epsonListings, ppd: ppd, + optionOverrides: ["EPIJ_FdSo": "12"], readFile: readFile) + XCTAssertEqual(tray12["0"], epsonQualityIDs.subtracting(["307"])) + } + + func testResolveTrayOverrideCacheReadOnlyOnce() { + let dat = """ + *EPIJUIConstraint: *EPIJ_FdSo 3 *EPIJ_Medi 0|*EPIJ_Qual 305 + """ + let ppd = """ + *EPIJDriverBasePath: "/drivers/epson" + *EPIJMachineBundleName: "EP14C0605W.data" + """ + let datURL = URL(fileURLWithPath: + "/drivers/epson/Machine/EP14C0605W.data/" + + "Contents/Resources/PDEData.dat") + var reads = 0 + var cache: [URL: Data] = [:] + let readFile: (URL) -> Data? = { + if let cached = cache[$0] { return cached } + reads += 1 + let data = $0 == datURL ? dat.data(using: .utf8) : nil + cache[$0] = data + return data + } + _ = MediaQualityConstraints.resolve( + listings: epsonListings, ppd: ppd, + optionOverrides: ["EPIJ_FdSo": "3"], readFile: readFile) + _ = MediaQualityConstraints.resolve( + listings: epsonListings, ppd: ppd, + optionOverrides: ["EPIJ_FdSo": "12"], readFile: readFile) + XCTAssertEqual(reads, 1) + } + // MARK: - Canon cnb table /// Builds one 20-byte Canon record: `30 00 01 00 00 00 00 00`, @@ -308,4 +375,38 @@ final class MediaQualityConstraintsTests: XCTestCase { XCTAssertEqual(caps.qualities(forMediaType: "99").count, 7) XCTAssertTrue(caps.allowsQuality("305", forMediaType: nil)) } + + func testTraySpecificQualityMap() { + var caps = PrinterCapabilities( + mediaTypes: [ + PrinterMediaType(id: "0", name: "Plain"), + ], + qualities: ["301", "302", "303", "304", "305"] + .map { PrinterQuality(id: $0, name: "Q\($0)") }, + qualityIDsByMediaType: ["0": ["301", "302", "303"]]) + caps.qualityIDsByMediaTypeByTray = [ + "Rear": ["0": ["301", "302", "303"]], + "Front": ["0": ["303", "304", "305"]], + ] + + XCTAssertEqual( + caps.qualities(forMediaType: "0", trayToken: "Rear") + .map { $0.id }, ["301", "302", "303"]) + XCTAssertEqual( + caps.qualities(forMediaType: "0", trayToken: "Front") + .map { $0.id }, ["303", "304", "305"]) + // Unknown tray falls back to the default map. + XCTAssertEqual( + caps.qualities(forMediaType: "0", trayToken: "Unknown") + .map { $0.id }, ["301", "302", "303"]) + // No tray token also falls back. + XCTAssertEqual( + caps.qualities(forMediaType: "0").map { $0.id }, + ["301", "302", "303"]) + + XCTAssertTrue(caps.allowsQuality( + "304", forMediaType: "0", trayToken: "Front")) + XCTAssertFalse(caps.allowsQuality( + "304", forMediaType: "0", trayToken: "Rear")) + } } diff --git a/Tests/ICCeryCoreTests/PrintPanelTests.swift b/Tests/ICCeryCoreTests/PrintPanelTests.swift index 52738a2..534cec1 100644 --- a/Tests/ICCeryCoreTests/PrintPanelTests.swift +++ b/Tests/ICCeryCoreTests/PrintPanelTests.swift @@ -44,10 +44,16 @@ final class PrintPanelStubTests: XCTestCase { "ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear", "ICCERY_TEST_PANEL_PRINTER": "Other_Queue", ]) { - let result = UITestHooks.printPanelResult(forQueue: "q") + let trays = [ + PrinterTray(id: 1, token: "Auto", name: "Auto"), + PrinterTray(id: 2, token: "Rear", name: "Rear"), + ] + let result = UITestHooks.printPanelResult( + forQueue: "q", trays: trays, preferredTrayKey: "InputSlot") XCTAssertEqual(result?.selectedPrinter, "Other_Queue") XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear") XCTAssertEqual(result?.options.mediaType, "Photo") + XCTAssertEqual(result?.options.paperSource, 2) XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true) } } @@ -99,4 +105,60 @@ final class PrintPanelStubTests: XCTestCase { XCTAssertNil(result?.options.cupsOptions) } } + + /// #218 — vendor tray keys and the `AP_D_InputSlot` capture alias + /// map back through `paperSource` when the token matches the roster. + func testOkResultExtractsTrayToken() throws { + let trays = [ + PrinterTray(id: 1, token: "Auto", name: "Auto"), + PrinterTray(id: 2, token: "Rear", name: "Rear Tray"), + PrinterTray(id: 3, token: "Manual", name: "Manual Feed"), + ] + try withEnv([ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_PRINT_PANEL": "ok", + "ICCERY_TEST_PANEL_OPTIONS": "PageSize=A4 EPIJ_FdSo=Rear", + "ICCERY_TEST_PANEL_PRINTER": nil, + ]) { + let result = UITestHooks.printPanelResult( + forQueue: "q", trays: trays, + preferredTrayKey: "EPIJ_FdSo") + XCTAssertEqual(result?.options.paperSource, 2) + XCTAssertEqual(result?.options.cupsOptions, + "PageSize=A4 EPIJ_FdSo=Rear") + } + } + + func testOkResultExtractsAPDInputSlotAlias() throws { + let trays = [ + PrinterTray(id: 1, token: "Auto", name: "Auto"), + PrinterTray(id: 2, token: "Manual", name: "Manual Feed"), + ] + try withEnv([ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_PRINT_PANEL": "ok", + "ICCERY_TEST_PANEL_OPTIONS": "AP_D_InputSlot=Manual", + "ICCERY_TEST_PANEL_PRINTER": nil, + ]) { + let result = UITestHooks.printPanelResult( + forQueue: "q", trays: trays) + XCTAssertEqual(result?.options.paperSource, 2) + } + } + + func testOkResultUnknownTrayNoPaperSource() throws { + let trays = [ + PrinterTray(id: 1, token: "Auto", name: "Auto"), + ] + try withEnv([ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_PRINT_PANEL": "ok", + "ICCERY_TEST_PANEL_OPTIONS": "InputSlot=Unknown", + "ICCERY_TEST_PANEL_PRINTER": nil, + ]) { + let result = UITestHooks.printPanelResult( + forQueue: "q", trays: trays) + XCTAssertNil(result?.options.paperSource) + } + } } diff --git a/Tests/ICCeryCoreTests/PrintPanelTicketTests.swift b/Tests/ICCeryCoreTests/PrintPanelTicketTests.swift index 908eff3..3e5e8f1 100644 --- a/Tests/ICCeryCoreTests/PrintPanelTicketTests.swift +++ b/Tests/ICCeryCoreTests/PrintPanelTicketTests.swift @@ -2,6 +2,7 @@ import AppKit import Foundation import XCTest @testable import ICCery +@testable import ICCeryCore /// Issue #217 — the panel's pre-modal seam /// (`PrintPanelService.configureBoundPanel`) must rehydrate the @@ -40,11 +41,12 @@ final class PrintPanelTicketTests: XCTestCase { private func request( ticket: PrintTicket?, selections: PrintPanelInitialSelections = - PrintPanelInitialSelections() + PrintPanelInitialSelections(), + trays: [PrinterTray] = [] ) -> PrintPanelRequest { PrintPanelRequest( queue: "Panel_Q", displayName: nil, optionKeys: [], - initialSelections: selections, ticket: ticket) + initialSelections: selections, ticket: ticket, trays: trays) } /// RC1 — a captured vendor key is present in the configured @@ -109,6 +111,36 @@ final class PrintPanelTicketTests: XCTestCase { "999") } + /// #218 — the Stage 2 tray selection lands after `PageSize` and + /// before the media key in the configured `NSPrintInfo`. + func testStage2TrayWrittenPreModal() { + let printInfo = NSPrintInfo() + var selections = PrintPanelInitialSelections() + selections.paperSize = "A4" + selections.trayKey = "EPIJ_FdSo" + selections.tray = "Rear" + selections.mediaType = "Photo" + + var req = request(ticket: nil, selections: selections) + req = PrintPanelRequest( + queue: req.queue, displayName: req.displayName, + optionKeys: ["MediaType"], initialSelections: selections, + ticket: nil, trays: []) + service.configureBoundPanel( + printInfo, request: req, printer: nil) + + let settings = PMTicketBridge.settings(printInfo) + XCTAssertEqual( + PMTicketBridge.stringValue(forKey: "PageSize", in: settings), + "A4") + XCTAssertEqual( + PMTicketBridge.stringValue(forKey: "EPIJ_FdSo", in: settings), + "Rear") + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "MediaType", in: settings), "Photo") + } + /// First-ever open — no ticket, selections still land and the /// post-selections validate keeps them readable. func testNoTicketKeepsSelections() { diff --git a/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift b/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift index 70e705b..8d49deb 100644 --- a/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift +++ b/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift @@ -50,7 +50,7 @@ final class PrintSessionViewModelTests: XCTestCase { done if [ "$list" = "1" ]; then printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal Custom.WIDTHxHEIGHT\\n' - printf 'InputSlot/Media Source: Auto *Main Rear\\n' + printf 'InputSlot/Media Source: Auto Main *Rear\\n' printf 'MediaType/Media Type: *Stationery Glossy Matte\\n' printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\\n' exit 0 @@ -99,6 +99,10 @@ final class PrintSessionViewModelTests: XCTestCase { XCTAssertEqual(workflow.pageSize, .a4) XCTAssertEqual(workflow.print.selectedPaperSize, 3) + // Tray seeds from the `*` default on caps load, not the first + // list element (#218). + XCTAssertEqual(workflow.print.selectedTray, 3) + XCTAssertEqual(workflow.print.selectedTrayToken, "Rear") // Quality seeds from the `*` default on caps load. XCTAssertEqual(workflow.print.selectedQuality, "303") // All seven Epson codes enumerate in driver order (#180). @@ -209,6 +213,7 @@ final class PrintSessionViewModelTests: XCTestCase { let log = await waitForFile(spoolLogURL) XCTAssertTrue(log.contains("PageSize=Letter"), log) XCTAssertTrue(log.contains("EPIJ_Qual=301"), log) + XCTAssertTrue(log.contains("InputSlot=Rear"), log) } /// #201 — the stubbed panel never produces a `PrintTicket`, so a @@ -241,6 +246,7 @@ final class PrintSessionViewModelTests: XCTestCase { XCTAssertTrue(log.contains("PageSize=A4"), log) XCTAssertTrue(log.contains("EPIJ_Qual=303"), log) XCTAssertTrue(log.contains("MediaType=Stationery"), log) + XCTAssertTrue(log.contains("InputSlot=Rear"), log) XCTAssertTrue(log.contains("orientation-requested=3"), log) XCTAssertTrue(log.contains( "AP_ColorMatchingMode=AP_ApplicationColorMatching"), log) @@ -307,6 +313,52 @@ final class PrintSessionViewModelTests: XCTestCase { XCTAssertEqual(workflow.pageSize, .a4) } + /// #218 — a captured `InputSlot=` (or any roster key) maps back to + /// `selectedTray` and populates `PrintOptions.paperSource`. + func testPanelResultAppliesBackTray() async throws { + setenv("ICCERY_UI_TESTING", "1", 1) + setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1) + setenv("ICCERY_TEST_PANEL_OPTIONS", "InputSlot=Auto", 1) + defer { + unsetenv("ICCERY_UI_TESTING") + unsetenv("ICCERY_TEST_PRINT_PANEL") + unsetenv("ICCERY_TEST_PANEL_OPTIONS") + } + + let workflow = makeWorkflow() + workflow.pageSize = .a4 + await loadCaps(workflow.print) + XCTAssertEqual(workflow.print.selectedTray, 3) + + workflow.print.openPrinterPreferences() + await waitForNotice(workflow.print, containing: "Settings captured") + XCTAssertEqual(workflow.print.selectedTray, 1) + } + + /// #218 — a captured tray token absent from the roster is appended + /// as a synthetic entry and selected. + func testPanelResultUnknownTrayAppended() async throws { + setenv("ICCERY_UI_TESTING", "1", 1) + setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1) + setenv("ICCERY_TEST_PANEL_OPTIONS", "InputSlot=OnlyInDialog", 1) + defer { + unsetenv("ICCERY_UI_TESTING") + unsetenv("ICCERY_TEST_PRINT_PANEL") + unsetenv("ICCERY_TEST_PANEL_OPTIONS") + } + + let workflow = makeWorkflow() + workflow.pageSize = .a4 + await loadCaps(workflow.print) + let originalCount = workflow.print.printerCaps.trays.count + + workflow.print.openPrinterPreferences() + await waitForNotice(workflow.print, containing: "Settings captured") + XCTAssertEqual(workflow.print.printerCaps.trays.count, + originalCount + 1) + XCTAssertEqual(workflow.print.selectedTrayToken, "OnlyInDialog") + } + /// #186 — a captured `PageSize` token with no capability match /// leaves `selectedPaperSize` unchanged (never a guessed id). func testPanelResultUnknownPaperLeavesSelection() async throws { @@ -456,6 +508,35 @@ final class PrintSessionViewModelTests: XCTestCase { XCTAssertEqual(workflow.print.selectedQuality, "303") } + /// #218 — a per-tray quality map is selected when the current tray + /// token has an entry; otherwise it falls back to the default map. + func testTraySpecificQualityMapSelected() async { + let workflow = makeWorkflow() + await loadCaps(workflow.print) + var caps = workflow.print.printerCaps + caps.qualityIDsByMediaType = [ + "Stationery": ["301", "302", "303"], + "Glossy": ["305", "307"], + ] + caps.qualityIDsByMediaTypeByTray = [ + "Auto": ["Stationery": ["301", "302"]], + "Rear": ["Stationery": ["301", "302", "303", "304"]], + ] + workflow.print.printerCaps = caps + XCTAssertEqual(workflow.print.selectedTrayToken, "Rear") + // Non-empty tray map for Rear wins over the default media map. + XCTAssertEqual(workflow.print.availableQualities.map { $0.id }, + ["301", "302", "303", "304"]) + + workflow.print.selectedTray = 1 // Auto + XCTAssertEqual(workflow.print.availableQualities.map { $0.id }, + ["301", "302"]) + + workflow.print.selectedTray = 999 // invalid -> fallback + XCTAssertEqual(workflow.print.availableQualities.map { $0.id }, + ["301", "302", "303"]) + } + /// Seeding lands inside the allowed set even when the driver /// default quality is invalid for the first media — end to end /// through `CupsService.capabilities(for:)` + a fixture diff --git a/Tests/ICCeryCoreTests/TicketWriteResolverTests.swift b/Tests/ICCeryCoreTests/TicketWriteResolverTests.swift index ae42841..878a310 100644 --- a/Tests/ICCeryCoreTests/TicketWriteResolverTests.swift +++ b/Tests/ICCeryCoreTests/TicketWriteResolverTests.swift @@ -2,9 +2,9 @@ import XCTest @testable import ICCeryCore @testable import ICCery -/// Issue #201 Phase 4 — the resolved ticket-write list that replaces -/// the `lp` argv goldens: locked order 1–10, D6 (Stage 2 always -/// wins), `raw` can never appear. +/// Issue #201 Phase 4 / #218 — the resolved ticket-write list that +/// replaces the `lp` argv goldens: locked order 1–11, D6 (Stage 2 +/// always wins), `raw` can never appear. final class TicketWriteResolverTests: XCTestCase { private func resolve( @@ -63,6 +63,7 @@ final class TicketWriteResolverTests: XCTestCase { overrides: TargetPrintOverrides( paperSize: "raw", mediaType: "raw", qualityKey: "raw", quality: "raw", + trayKey: "raw", tray: "raw", orientation: "raw"), optionKeys: optionKeys) XCTAssertFalse(r.writes.contains { @@ -144,8 +145,8 @@ final class TicketWriteResolverTests: XCTestCase { XCTAssertNil(value("orientation-requested", in: resolve())) } - /// A `nil` override emits no write for that key; quality needs - /// **both** the detected key and the value. + /// A `nil` override emits no write for that key; quality and tray + /// need **both** the detected key and the value. func testNilOverridesEmitNoWrite() { let r = resolve(optionKeys: ["MediaType", "EPIJ_Qual"]) XCTAssertNil(value("PageSize", in: r)) @@ -168,14 +169,42 @@ final class TicketWriteResolverTests: XCTestCase { overrides: TargetPrintOverrides(paperSize: "")) XCTAssertNil(value("PageSize", in: emptyPaper)) XCTAssertNil(emptyPaper.paperToken) + + // Tray: key or value alone, or an empty value, emits no write. + let trayKeyOnly = resolve( + overrides: TargetPrintOverrides(trayKey: "EPIJ_FdSo"), + optionKeys: ["EPIJ_FdSo"]) + XCTAssertNil(value("EPIJ_FdSo", in: trayKeyOnly)) + let trayValueOnly = resolve( + overrides: TargetPrintOverrides(tray: "Rear"), + optionKeys: ["EPIJ_FdSo"]) + XCTAssertNil(value("EPIJ_FdSo", in: trayValueOnly)) + let emptyTray = resolve( + overrides: TargetPrintOverrides(trayKey: "EPIJ_FdSo", tray: ""), + optionKeys: ["EPIJ_FdSo"]) + XCTAssertNil(value("EPIJ_FdSo", in: emptyTray)) } - /// The write list equals the locked 1–10 order, exactly. + /// #218 paper source is emitted with its canonical key and the raw + /// token; the display label must never reach the ticket. + func testTrayWrite() { + let r = resolve( + overrides: TargetPrintOverrides( + trayKey: "EPIJ_FdSo", tray: "Rear"), + optionKeys: ["EPIJ_FdSo"]) + XCTAssertEqual(value("EPIJ_FdSo", in: r), "Rear") + XCTAssertEqual(r.writes.first { $0.key == "EPIJ_FdSo" }?.locked, + false) + XCTAssertNil(value("InputSlot", in: r)) + } + + /// The write list equals the locked 1–11 order, exactly. func testLockedWriteOrder() { let r = resolve( overrides: TargetPrintOverrides( paperSize: "A4", mediaType: "Photo", qualityKey: "EPIJ_Qual", quality: "305", + trayKey: "EPIJ_FdSo", tray: "Rear", orientation: "landscape"), optionKeys: ["MediaType", "EPIJ_Qual", "EPIJ_CMat"]) XCTAssertEqual(keys(r), [ @@ -185,6 +214,7 @@ final class TicketWriteResolverTests: XCTestCase { "PMCustomColorMatchingProfile", "com.apple.print.PrintSettings.PMColorMatchingMode", "PageSize", + "EPIJ_FdSo", "MediaType", "EPIJ_Qual", "EPIJ_CMat", diff --git a/Tests/ICCeryUITests/Fixtures/bin/lpoptions b/Tests/ICCeryUITests/Fixtures/bin/lpoptions index 5afe011..6499d2d 100755 --- a/Tests/ICCeryUITests/Fixtures/bin/lpoptions +++ b/Tests/ICCeryUITests/Fixtures/bin/lpoptions @@ -15,7 +15,7 @@ for arg in "$@"; do done if [ "$list" = "1" ]; then printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n' - printf 'InputSlot/Media Source: Auto *Main Rear\n' + printf 'EPIJ_FdSo/Paper Source: Auto *Main Rear\n' printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n' printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n' printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n' diff --git a/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift b/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift index ec3be7d..2eba3a4 100644 --- a/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift +++ b/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift @@ -124,8 +124,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase { XCTAssertTrue(element("printerSelect").exists) XCTAssertTrue(element("printerTraySelect").exists) XCTAssertTrue(element("printerMediaTypeSelect").exists) - XCTAssertTrue(element("btnOrientPortrait").exists) - XCTAssertTrue(element("btnOrientLandscape").exists) + XCTAssertTrue(element("printerOrientationSelect").exists) XCTAssertTrue(element("btnPrinterProperties").exists) } diff --git a/Tests/ICCeryUITests/Milestone3UITests.swift b/Tests/ICCeryUITests/Milestone3UITests.swift index d5d0e63..ff6c391 100644 --- a/Tests/ICCeryUITests/Milestone3UITests.swift +++ b/Tests/ICCeryUITests/Milestone3UITests.swift @@ -172,8 +172,7 @@ final class Milestone3UITests: XCTestCase { .waitForExistence(timeout: 10)) XCTAssertTrue(element("printerTraySelect").exists) XCTAssertTrue(element("printerMediaTypeSelect").exists) - XCTAssertTrue(element("btnOrientPortrait").exists) - XCTAssertTrue(element("btnOrientLandscape").exists) + XCTAssertTrue(element("printerOrientationSelect").exists) XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled) } @@ -202,7 +201,7 @@ final class Milestone3UITests: XCTestCase { func testPanelSelectionsApplyBackToSpoolWrites() throws { app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = - "InputSlot=Rear MediaType=PhotographicGlossy" + "EPIJ_FdSo=Rear MediaType=PhotographicGlossy" launchApp() reachPrintPanel() _ = waitFor("printerStatusBadge") @@ -220,6 +219,7 @@ final class Milestone3UITests: XCTestCase { XCTAssertTrue(log.contains( "AP.ColorMatchingMode=AP_ApplicationColorMatching"), log) XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log) + XCTAssertTrue(log.contains("EPIJ_FdSo=Rear"), log) // Detected bypass for the mock queue (EPIJ_CMat present in // lpoptions -l) is always written. XCTAssertTrue(log.contains("EPIJ_CMat=3"), log) diff --git a/docs/10-print-system.md b/docs/10-print-system.md index f7279bd..f9b5eed 100644 --- a/docs/10-print-system.md +++ b/docs/10-print-system.md @@ -43,8 +43,9 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor | 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`). | +| `id` | `u16` | Windows: Win32 bin ID from `DC_BINS`. Unix/macOS: 1-based index of the detected tray listing. | +| `token` | `String` | **v2** (#218): the exact CUPS/PPD machine value (`Auto`, `Rear`, `1`, ...). | +| `name` | `String` | Windows: 24-WCHAR `DC_BINNAMES` string, or `"Tray {id}"` if empty. Unix/macOS: PPD-enriched human label; if no PPD label is available, the raw token is used for display. | ### `PrinterPaperSize` (mod.rs:22–25) @@ -78,7 +79,7 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor | Field | Type | Windows | Linux | macOS | |-------|------|---------|-------|-------| -| `paper_source` | `Option` | `dmDefaultSource` (tray id) | **ignored** (unix `build_lp_args` never emits `InputSlot`) | **ignored** unless present in captured `cups_options` | +| `paper_source` | `Option` | `dmDefaultSource` (tray id) | **ignored** (unix `build_lp_args` never emits `InputSlot`) | v2 (#218): 1-based tray id driven by the Stage 2 picker; written to the detected tray key (`InputSlot`/`MediaSource`/`EPIJ_FdSo`/`EPIJ_PSrc`/`CNIJMediaSupply`) on the native ticket. | | `orientation` | `Option` | `"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` | **ignored** | `-o PageSize=` | same, skipped if `pagesize` already added | | `media_type` | `Option` | parsed as `u32` → `dmMediaType` | `-o MediaType=` (generic key only) | uses `detect_media_type_key` (`CNIJMediaType` / `EPIJ_Medi` / `StpMediaType` / `MediaType`); skipped if any of those keys already in `cups_options` | diff --git a/docs/11-print-macos.md b/docs/11-print-macos.md index 7d246c7..cbac2a5 100644 --- a/docs/11-print-macos.md +++ b/docs/11-print-macos.md @@ -236,7 +236,8 @@ After OK: 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 } }`. +7. `extract_tray_from_options` → the selected tray token and a 1-based `paper_source` id when it matches the roster. +8. Return `PrintPropertiesResult { selected_printer, options: PrintOptions { media_type, cups_options, paper_source, ppd_uncorrected_passthrough: Some(true), ..Default } }`. Failure of `PMPrintSettingsToOptions` is a hard `Err`. @@ -264,7 +265,7 @@ 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 +Tray: InputSlot, MediaSource, AP_D_InputSlot, EPIJ_FdSo, EPIJ_PSrc, CNIJMediaSupply Size: PageSize Color: CNIJIntent2, CNIJIntent, EPIJ_CMat, EPIJ_CCor, EPIJ_OSColMat, ColorCorrection, StpColorCorrection, EpsonColorMode, ColorModel @@ -306,8 +307,9 @@ 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** +`PageSize` → the detected tray key → 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` @@ -407,8 +409,9 @@ AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPan ### PPD keys (detection) ``` -Media: CNIJMediaType, EPIJ_Medi, StpMediaType, MediaType, MediaSource, InputSlot, PageSize, MediaSize -Color: CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, EpsonColorMode +Media: CNIJMediaType, EPIJ_Medi, StpMediaType, MediaType, PageSize, MediaSize +Tray: InputSlot, MediaSource, EPIJ_FdSo, EPIJ_PSrc, CNIJMediaSupply (EPIJ_FdSo preferred over EPIJ_PSrc when both present) +Color: CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, EpsonColorMode ``` --- diff --git a/docs/21-ui-reference.md b/docs/21-ui-reference.md index 55e3f86..4d21c43 100644 --- a/docs/21-ui-reference.md +++ b/docs/21-ui-reference.md @@ -44,7 +44,7 @@ Collision: `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calR ## Stage 2 ids -`cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `paperSizeGroup`, `printerPaperSizeSelect`, `qualityGroup`, `printerQualitySelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`. +`cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect` (label "Paper source"), `mediaTypeGroup`, `printerMediaTypeSelect`, `paperSizeGroup`, `printerPaperSizeSelect`, `qualityGroup`, `printerQualitySelect`, `printerOrientationSelect`, `btnPrintAll`, `btnAdvanceToStage3`. `#cupsOptionsGroup` is **hidden on Windows** (#48). `#chkPpdFallback` is Linux-only ColorModel=Gray + cm-calibration. @@ -86,4 +86,4 @@ Tauri v2 has **no** `window.__TAURI__.dialog`. Use invoke wrappers (`select_*`). ## Complete `id=` roster (358) -`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalReturn`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `paperSizeGroup`, `printerPaperSizeSelect`, `qualityGroup`, `printerQualitySelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `settingsDeltaEGood`, `settingsDeltaEWarning`, `settingsCalStaleDays`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`, `btnSpotRead`, `spotReadView`, `btnCloseSpotRead`, `spotSidecarMissing`, `btnSpotDetectInstruments`, `spotDetectError`, `spotInstrumentSelect`, `spotDefaultMissing`, `spotSetDefault`, `spotXYHint`, `spotPrompt`, `spotLastError`, `spotLogContainer`, `spotLog`, `btnSpotStart`, `btnSpotCalibrate`, `btnSpotTrigger`, `btnSpotStop`, `spotLastSample`, `spotLastEmpty`, `spotLabL`, `spotLabA`, `spotLabB`, `spotXYZ`, `spotSwatch`, `spotDeltaE`, `spotLastInstrument`, `spotLabImplausible`, `spotHistoryTable`, `spotHistoryEmpty`, `spotHistoryRow-{uuid}`, `btnSpotCopyLab`, `btnSpotExportCsv`, `btnViewGamut`, `gamutView`, `gamutStatusText`, `gamutNoticeText`, `btnResetGamutCamera`, `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable`, `menuProjectNew`, `menuProjectOpen`, `menuProjectRecents`, `projectRecent-{id}`, `menuProjectRecentsClear`, `menuProjectSave`, `menuProjectSaveAs`, `menuProjectReport`, `menuProjectClose`, `projectChip`, `projectChipName`, `projectChipPath`, `projectChipStale`, `btnProjectOpen`, `btnProjectSave`, `btnProjectReveal`, `projectNewAlert`, `btnProjectNewCancel`, `btnProjectNewConfirm`, `btnProjectDirtySave`, `btnProjectDirtyDiscard`, `btnProjectDirtyCancel`, `projectRelocateSheet`, `btnProjectRelocate`, `btnProjectRelocateCancel`. +`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalReturn`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `paperSizeGroup`, `printerPaperSizeSelect`, `qualityGroup`, `printerQualitySelect`, `printerOrientationSelect`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `settingsDeltaEGood`, `settingsDeltaEWarning`, `settingsCalStaleDays`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`, `btnSpotRead`, `spotReadView`, `btnCloseSpotRead`, `spotSidecarMissing`, `btnSpotDetectInstruments`, `spotDetectError`, `spotInstrumentSelect`, `spotDefaultMissing`, `spotSetDefault`, `spotXYHint`, `spotPrompt`, `spotLastError`, `spotLogContainer`, `spotLog`, `btnSpotStart`, `btnSpotCalibrate`, `btnSpotTrigger`, `btnSpotStop`, `spotLastSample`, `spotLastEmpty`, `spotLabL`, `spotLabA`, `spotLabB`, `spotXYZ`, `spotSwatch`, `spotDeltaE`, `spotLastInstrument`, `spotLabImplausible`, `spotHistoryTable`, `spotHistoryEmpty`, `spotHistoryRow-{uuid}`, `btnSpotCopyLab`, `btnSpotExportCsv`, `btnViewGamut`, `gamutView`, `gamutStatusText`, `gamutNoticeText`, `btnResetGamutCamera`, `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable`, `menuProjectNew`, `menuProjectOpen`, `menuProjectRecents`, `projectRecent-{id}`, `menuProjectRecentsClear`, `menuProjectSave`, `menuProjectSaveAs`, `menuProjectReport`, `menuProjectClose`, `projectChip`, `projectChipName`, `projectChipPath`, `projectChipStale`, `btnProjectOpen`, `btnProjectSave`, `btnProjectReveal`, `projectNewAlert`, `btnProjectNewCancel`, `btnProjectNewConfirm`, `btnProjectDirtySave`, `btnProjectDirtyDiscard`, `btnProjectDirtyCancel`, `projectRelocateSheet`, `btnProjectRelocate`, `btnProjectRelocateCancel`. diff --git a/docs/24-issues-invariants.md b/docs/24-issues-invariants.md index 82edb4d..aa99b2d 100644 --- a/docs/24-issues-invariants.md +++ b/docs/24-issues-invariants.md @@ -130,7 +130,7 @@ Grouped by subsystem. Issue numbers are always cited. **Sister-product traps (ICCery-CPU `#1`, `#2`, `#5`) that also apply to any CUPS rewrite:** - PPD `ChoiceName/TranslationString` — UI must show the title, send the code (`MediaType=13` not `MediaType=Epson Premium Glossy`). -- Tray keywords are vendor-specific (`InputSlot` vs Epson `EPIJ_FdSo` vs Canon `CNIJMediaSupply`). Empty arrays are not `nil` — coalescing `?? ["Auto"]` does not fire. +- Tray keywords are vendor-specific (`InputSlot`/`MediaSource` vs Epson `EPIJ_FdSo`/`EPIJ_PSrc` vs Canon `CNIJMediaSupply`). When both Epson keys are present, `EPIJ_FdSo` is the feed-source control and takes precedence over `EPIJ_PSrc`. Empty arrays are not `nil` — coalescing `?? ["Auto"]` does not fire. - Canon Super Fine is three coordinated keys (`CNIJPrintQuality=0`, `CNIJPrintMode2=5`, `CNIJPQualitySlider=5`), not `cupsPrintQuality=High`. --- -- 2.39.5 From efeefb8cea8ee2b26969bedc1abea96a0046c38c Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 22 Sep 2026 19:40:28 +0100 Subject: [PATCH 3/3] chore: bump MARKETING_VERSION to 2.0.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the project version ahead of the develop→main merge so the upcoming release tag can match project.yml. --- project.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project.yml b/project.yml index ded120b..721a4b6 100644 --- a/project.yml +++ b/project.yml @@ -61,7 +61,7 @@ targets: INFOPLIST_KEY_LSMinimumSystemVersion: "12.0" INFOPLIST_KEY_NSPrincipalClass: NSApplication INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3." - MARKETING_VERSION: "2.0.3" + MARKETING_VERSION: "2.0.4" CURRENT_PROJECT_VERSION: "1" # Stamped by scripts/version.sh at packaging/CI time (release tag or # `git describe` string); About shows it next to the marketing version. -- 2.39.5