From 0513b272cb15a376858ccf03b9a06f80f9613cc7 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 17 Sep 2026 10:34:50 +0100 Subject: [PATCH 01/14] docs(agents): add issue ticket style and dependency tracking guidelines --- AGENTS.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 00c003b..de52d9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,27 @@ bump `project.yml` on `develop` before tagging. CI needs `fetch-depth: 0`. `develop` ← `milestone/mN-` ← `feat/-`. PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`. +## Issue ticket style +- Title: `[Kind/Priority] short description` — e.g. `[Bug/Critical] …`, `[Feature/Medium] …`. +- Labels: `Kind/Bug` or `Kind/Feature` (also `Kind/Testing` for test work), + one `Bug/` or `Feature/` (Architecture/Backend/UI/DevOps), + one `Priority/*`, plus `Project/ICCery-v2`. Set the milestone when the work + belongs to an active `mN` milestone. +- Bug bodies: `## Summary` → `## Root Cause Analysis` (file:line evidence; + note checked-and-dismissed hypotheses) → `## Proposed Fix` (options or + deterministic plan) → `## Acceptance Criteria` (checkbox list) → + `## Dependencies` → `## References`. +- Feature bodies: same skeleton minus Root Cause; lead with Summary and a + concrete implementation plan. +- Dependencies/blockers must **always** be recorded via the gitea MCP + `issue_write` methods (`add_dependency`, `block_issue`; reads via + `issue_read` `list_dependencies` / `list_blocks` — see "Gitea issue + dependencies"), not just mentioned in the body. This is + mandatory when issues share a milestone with an implementation order: + wire up `add_dependency` (blocked-by) and `block_issue` (blocks) links so + the order is machine-readable. The `## Dependencies` body section may + still summarise them for readability, but the MCP links are authoritative. + ## Verify ``` xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)" -- 2.39.5 From ee5da4a637f99df1ad5742e8bf7b8ca4b116eeb8 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 17 Sep 2026 12:15:27 +0100 Subject: [PATCH 02/14] refactor(print): extract PMTicketBridge from PrintPanelService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralise every Core Printing (PM*) call in one @MainActor bridge so the upcoming ticket serialise/restore path and native spooler share a single documented ownership policy. Pure move — no behaviour change. --- Sources/ICCery/Print/PMTicketBridge.swift | 230 +++++++++++++++++++ Sources/ICCery/Print/PrintPanelService.swift | 185 +++------------ 2 files changed, 259 insertions(+), 156 deletions(-) create mode 100644 Sources/ICCery/Print/PMTicketBridge.swift diff --git a/Sources/ICCery/Print/PMTicketBridge.swift b/Sources/ICCery/Print/PMTicketBridge.swift new file mode 100644 index 0000000..ed36022 --- /dev/null +++ b/Sources/ICCery/Print/PMTicketBridge.swift @@ -0,0 +1,230 @@ +import AppKit +import ApplicationServices +import ICCeryCore + +/// Errors raised by Core Printing bridge operations. +enum PMTicketError: LocalizedError, Equatable { + case printerUnknown(queue: String) + case sessionBindingFailed(OSStatus) + case serialiseFailed(stage: String, status: OSStatus) + case restoreFailed(stage: String, status: OSStatus) + + var errorDescription: String? { + switch self { + case .printerUnknown(let queue): + return "No printer found for queue '\(queue)'." + case .sessionBindingFailed(let status): + return "Could not bind the print session to the queue (OSStatus \(status))." + case .serialiseFailed(let stage, let status): + return "Print ticket capture failed at \(stage) (OSStatus \(status))." + case .restoreFailed(let stage, let status): + return "Print ticket restore failed at \(stage) (OSStatus \(status))." + } + } +} + +/// Every Core Printing (`PM*`) call in ICCery lives here (#201 D12). +/// +/// Ownership — the ONLY rules, do not improvise: +/// * `PMPrinterCreateFromPrinterID`, `PMCreatePageFormatWithPMPaper`, +/// `PMPrintSettingsCreateWithDataRepresentation`, +/// `PMPageFormatCreateWithDataRepresentation` return **+1** → +/// `PMRelease(object(_:))` on every path (`defer`). +/// * `PM*CreateDataRepresentation` writes a **+1 CFData** → +/// `takeRetainedValue()` (the headers say "the caller is responsible +/// for releasing"; the out-param is not `CF_RETURNS_RETAINED`-annotated, +/// so Swift hands back an unbalanced `Unmanaged`). +/// * `PMPrinterGetPaperList`, `PMPaperGetID`, `PMPrinterGetID`, +/// `PMPrintSettingsGetValue` are **borrowed** → `takeUnretainedValue()`, +/// never released. +/// * `printInfo.pmPrintSession()/pmPrintSettings()/pmPageFormat()` are +/// borrowed from the `NSPrintInfo` → never released. +@MainActor +enum PMTicketBridge { + + // MARK: Handles + + /// Borrowed `PMPrintSession` from the `NSPrintInfo` — never released. + static func session(_ printInfo: NSPrintInfo) -> PMPrintSession { + unsafeBitCast(printInfo.pmPrintSession(), to: PMPrintSession.self) + } + + /// Borrowed `PMPrintSettings` from the `NSPrintInfo` — never released. + static func settings(_ printInfo: NSPrintInfo) -> PMPrintSettings { + unsafeBitCast(printInfo.pmPrintSettings(), to: PMPrintSettings.self) + } + + /// Borrowed `PMPageFormat` from the `NSPrintInfo` — never released. + static func pageFormat(_ printInfo: NSPrintInfo) -> PMPageFormat { + unsafeBitCast(printInfo.pmPageFormat(), to: PMPageFormat.self) + } + + /// Any `PM*` handle → `PMObject` for `PMRelease` — the Carbon API + /// wants `UnsafeRawPointer`, Swift imports handles as `OpaquePointer`. + static func object(_ handle: Handle) -> PMObject { + unsafeBitCast(handle, to: PMObject.self) + } + + // MARK: Binding (layer ①) + + /// `PMPrinterCreateFromPrinterID` — **+1**, caller must + /// `PMTicketBridge.release` on every path. + static func makePrinter(queue: String) -> PMPrinter? { + PMPrinterCreateFromPrinterID(queue as CFString) + } + + /// Balances `makePrinter(queue:)` / any +1 PM handle. + static func release(_ printer: PMPrinter) { + PMRelease(object(printer)) + } + + /// `PMSessionSetCurrentPMPrinter` + session defaults (warn-only). + /// Throws `PMTicketError.sessionBindingFailed` when the bind itself + /// fails — the caller still owns `printer` on every path. + static func bind(printer: PMPrinter, to printInfo: NSPrintInfo) throws { + let session = session(printInfo) + let status = PMSessionSetCurrentPMPrinter(session, printer) + guard status == 0 else { + throw PMTicketError.sessionBindingFailed(status) + } + // Warn-only: defaults keep the panel consistent with the + // queue but are not fatal when they fail. + _ = PMSessionDefaultPrintSettings(session, settings(printInfo)) + _ = PMSessionDefaultPageFormat(session, pageFormat(printInfo)) + } + + /// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String. + static func currentPrinterID( + session: PMPrintSession, + fallback: String + ) -> String { + var current: PMPrinter? + guard PMSessionGetCurrentPrinter(session, ¤t) == 0, + let printer = current + else { return fallback } + defer { PMRelease(object(printer)) } + guard let id = PMPrinterGetID(printer) + else { return fallback } + return id.takeUnretainedValue() as String + } + + // MARK: Values + + /// Warn-only `PMPrintSettingsSetValue`: a driver that rejects a key + /// must not abort the caller's flow. Returns `true` on success. + @discardableResult + static func setValue( + _ value: String, + forKey key: String, + locked: Bool, + in settings: PMPrintSettings, + context: String + ) -> Bool { + let status = PMPrintSettingsSetValue( + settings, key as CFString, value as CFString, locked) + if status != 0 { + AppLogger.shared.warn( + "\(context): PMPrintSettingsSetValue(\(key)) " + + "rejected (\(status))") + return false + } + return true + } + + /// `PMPrintSettingsGetValue` — borrowed value, never released. + /// `nil` for absent keys, non-string values, or lookup errors. + static func stringValue( + forKey key: String, + in settings: PMPrintSettings + ) -> String? { + var value: Unmanaged? + guard PMPrintSettingsGetValue( + settings, key as CFString, &value) == 0, + let ref = value?.takeUnretainedValue() + else { return nil } + return ref as? String + } + + // MARK: Paper + + /// The `PMPageFormat` half of paper preselect (#186 E1): the + /// panel's paper dropdown reflects the page format's `PMPaper`, + /// not `PMPrintSettings`. Match the Stage 2 `PageSize` token to a + /// paper from `PMPrinterGetPaperList`, rebuild the page format + /// around it, and copy it into the printInfo's format (TN2248: + /// `PMCreatePageFormatWithPMPaper` → `PMSessionValidatePageFormat` + /// → `PMCopyPageFormat` → `updateFromPMPageFormat`). + /// `Custom.x` tokens (already points) have no `PMPaper` — + /// set the Cocoa `paperSize` directly. Warn-only throughout: a + /// missed match must not keep the panel from opening. + static func applyPaper( + token: String, + printer: PMPrinter, + session: PMPrintSession, + printInfo: NSPrintInfo + ) { + if let custom = customPaperDimensions(from: token) { + printInfo.paperSize = NSSize( + width: custom.width, height: custom.height) + return + } + var paperList: Unmanaged? + guard PMPrinterGetPaperList(printer, &paperList) == 0, + let papers = paperList?.takeUnretainedValue() + else { + AppLogger.shared.warn( + "Print panel: PMPrinterGetPaperList failed — " + + "paper preselect skipped") + return + } + // The list (and its elements) is owned by the printer — + // borrowed, never released. + var match: PMPaper? + for index in 0..? + guard PMPaperGetID(paper, &idRef) == 0, + let paperID = idRef?.takeUnretainedValue() as String? + else { continue } + if paperID == token { + match = paper + break + } + } + guard let paper = match else { + AppLogger.shared.warn( + "Print panel: no PMPaper id matches '\(token)'") + return + } + var created: PMPageFormat? + guard PMCreatePageFormatWithPMPaper(&created, paper) == 0, + let newFormat = created + else { + AppLogger.shared.warn( + "Print panel: PMCreatePageFormatWithPMPaper failed " + + "for '\(token)'") + return + } + defer { PMRelease(object(newFormat)) } + _ = PMSessionValidatePageFormat(session, newFormat, nil) + _ = PMCopyPageFormat(newFormat, pageFormat(printInfo)) + printInfo.updateFromPMPageFormat() + } + + /// `Custom.x` → dimensions in points (the token builder + /// emits integer points, mm × 72/25.4). `nil` for non-custom or + /// malformed tokens — a malformed `Custom.*` then misses the + /// `PMPaper` match and logs instead of guessing a size. + static func customPaperDimensions( + from token: String + ) -> (width: Double, height: Double)? { + guard token.hasPrefix("Custom.") else { return nil } + let dims = token.dropFirst("Custom.".count).split(separator: "x") + guard dims.count == 2, + let width = Double(dims[0]), let height = Double(dims[1]), + width > 0, height > 0 + else { return nil } + return (width, height) + } +} diff --git a/Sources/ICCery/Print/PrintPanelService.swift b/Sources/ICCery/Print/PrintPanelService.swift index d20c4a4..67adfac 100644 --- a/Sources/ICCery/Print/PrintPanelService.swift +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -4,13 +4,10 @@ import ICCeryCore /// Errors raised while preparing the bound print panel. enum PrintPanelError: LocalizedError { - case sessionBindingFailed(OSStatus) case noPrinterFound(String) var errorDescription: String? { switch self { - case .sessionBindingFailed(let status): - return "Could not bind the print session to the queue (OSStatus \(status))." case .noPrinterFound(let name): return "No printer found for '\(name)'." } @@ -93,24 +90,17 @@ struct PrintPanelService { var boundViaPM = false // ① Bind the session to the selected CUPS queue (docs/11). - if let printer = PMPrinterCreateFromPrinterID(queue as CFString) { + if let printer = PMTicketBridge.makePrinter(queue: queue) { pmPrinter = printer - let session = unsafeBitCast( - printInfo.pmPrintSession(), to: PMPrintSession.self) - let settings = unsafeBitCast( - printInfo.pmPrintSettings(), to: PMPrintSettings.self) - let pageFormat = unsafeBitCast( - printInfo.pmPageFormat(), to: PMPageFormat.self) + let session = PMTicketBridge.session(printInfo) + let settings = PMTicketBridge.settings(printInfo) - let status = PMSessionSetCurrentPMPrinter(session, printer) - if status != 0 { - PMRelease(Self.pmObject(printer)) - throw PrintPanelError.sessionBindingFailed(status) + do { + try PMTicketBridge.bind(printer: printer, to: printInfo) + } catch { + PMTicketBridge.release(printer) + throw error } - // Warn-only: defaults keep the panel consistent with the - // queue but are not fatal when they fail. - _ = PMSessionDefaultPrintSettings(session, settings) - _ = PMSessionDefaultPageFormat(session, pageFormat) // Initial selections — after `PMSessionDefault*`, before // ColorSync suppression ②–⑤ (locked write order, // #183/#186). Paper is TWO writes (E1): the `PageSize` @@ -119,9 +109,9 @@ struct PrintPanelService { applyInitialSelections( initialSelections, to: settings, optionKeys: optionKeys) if let paperToken = initialSelections.paperSize { - applyPaperPageFormat( - paperToken, printer: printer, session: session, - printInfo: printInfo) + PMTicketBridge.applyPaper( + token: paperToken, printer: printer, + session: session, printInfo: printInfo) } boundViaPM = true } else { @@ -142,19 +132,17 @@ struct PrintPanelService { } defer { if let printer = pmPrinter { - PMRelease(Self.pmObject(printer)) + 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 = unsafeBitCast( - printInfo.pmPrintSettings(), to: PMPrintSettings.self) + var settings = PMTicketBridge.settings(printInfo) var driverBypass: (key: String, value: String)? if boundViaPM { - let session = unsafeBitCast( - printInfo.pmPrintSession(), to: PMPrintSession.self) + let session = PMTicketBridge.session(printInfo) suppressor.applySPIMode(to: session) // ② suppressor.applyLockedKeys(to: settings) // ③ driverBypass = suppressor.applyDriverBypass( // ④ @@ -183,8 +171,7 @@ struct PrintPanelService { var cupsOptions: String? var mediaType: String? if boundViaPM { - settings = unsafeBitCast( - printInfo.pmPrintSettings(), to: PMPrintSettings.self) + settings = PMTicketBridge.settings(printInfo) let captured = suppressor.captureOptions(from: settings) cupsOptions = captured.cupsOptions mediaType = captured.mediaType @@ -192,9 +179,8 @@ struct PrintPanelService { let capturedOptions = cupsOptions ?? "" return PrintPropertiesResult( selectedPrinter: boundViaPM - ? Self.currentPrinterID( - session: unsafeBitCast( - printInfo.pmPrintSession(), to: PMPrintSession.self), + ? PMTicketBridge.currentPrinterID( + session: PMTicketBridge.session(printInfo), fallback: queue) : nil, options: PrintOptions( @@ -219,143 +205,30 @@ struct PrintPanelService { optionKeys: Set ) { if let paperSize = selections.paperSize { - warnOnFailure(PMPrintSettingsSetValue( - settings, "PageSize" as CFString, - paperSize as CFString, false), key: "PageSize") + PMTicketBridge.setValue( + paperSize, forKey: "PageSize", locked: false, + in: settings, context: "Print panel") } if let key = selections.qualityKey, let value = selections.quality { - warnOnFailure(PMPrintSettingsSetValue( - settings, key as CFString, - value as CFString, false), key: key) + PMTicketBridge.setValue( + value, forKey: key, locked: false, + in: settings, context: "Print panel") } // Media type via the queue's detected vendor key (#186). if let mediaType = selections.mediaType, let mediaKey = CupsParsers.detectMediaTypeKey( optionKeys: optionKeys) { - warnOnFailure(PMPrintSettingsSetValue( - settings, mediaKey as CFString, - mediaType as CFString, false), key: mediaKey) + PMTicketBridge.setValue( + mediaType, forKey: mediaKey, locked: false, + in: settings, context: "Print panel") } // Orientation — portrait=3, landscape=4 (CUPS IPP codes). if let orientation = selections.orientation { let code = orientation == "landscape" ? "4" : "3" - warnOnFailure(PMPrintSettingsSetValue( - settings, "orientation-requested" as CFString, - code as CFString, false), key: "orientation-requested") + PMTicketBridge.setValue( + code, forKey: "orientation-requested", locked: false, + in: settings, context: "Print panel") } } - /// The `PMPageFormat` half of paper preselect (#186 E1): the - /// panel's paper dropdown reflects the page format's `PMPaper`, - /// not `PMPrintSettings`. Match the Stage 2 `PageSize` token to a - /// paper from `PMPrinterGetPaperList`, rebuild the page format - /// around it, and copy it into the printInfo's format (TN2248: - /// `PMCreatePageFormatWithPMPaper` → `PMSessionValidatePageFormat` - /// → `PMCopyPageFormat` → `updateFromPMPageFormat`). - /// `Custom.x` tokens (already points) have no `PMPaper` — - /// set the Cocoa `paperSize` directly. Warn-only throughout: a - /// missed match must not keep the panel from opening. - private func applyPaperPageFormat( - _ token: String, - printer: PMPrinter, - session: PMPrintSession, - printInfo: NSPrintInfo - ) { - if let custom = Self.customPaperDimensions(from: token) { - printInfo.paperSize = NSSize( - width: custom.width, height: custom.height) - return - } - var paperList: Unmanaged? - guard PMPrinterGetPaperList(printer, &paperList) == 0, - let papers = paperList?.takeUnretainedValue() - else { - AppLogger.shared.warn( - "Print panel: PMPrinterGetPaperList failed — " - + "paper preselect skipped") - return - } - // The list (and its elements) is owned by the printer — - // borrowed, never released. - var match: PMPaper? - for index in 0..? - guard PMPaperGetID(paper, &idRef) == 0, - let paperID = idRef?.takeUnretainedValue() as String? - else { continue } - if paperID == token { - match = paper - break - } - } - guard let paper = match else { - AppLogger.shared.warn( - "Print panel: no PMPaper id matches '\(token)'") - return - } - var created: PMPageFormat? - guard PMCreatePageFormatWithPMPaper(&created, paper) == 0, - let newFormat = created - else { - AppLogger.shared.warn( - "Print panel: PMCreatePageFormatWithPMPaper failed " - + "for '\(token)'") - return - } - defer { PMRelease(unsafeBitCast(newFormat, to: PMObject.self)) } - _ = PMSessionValidatePageFormat(session, newFormat, nil) - let destination = unsafeBitCast( - printInfo.pmPageFormat(), to: PMPageFormat.self) - _ = PMCopyPageFormat(newFormat, destination) - printInfo.updateFromPMPageFormat() - } - - /// `Custom.x` → dimensions in points (the token builder - /// emits integer points, mm × 72/25.4). `nil` for non-custom or - /// malformed tokens — a malformed `Custom.*` then misses the - /// `PMPaper` match and logs instead of guessing a size. - static func customPaperDimensions( - from token: String - ) -> (width: Double, height: Double)? { - guard token.hasPrefix("Custom.") else { return nil } - let dims = token.dropFirst("Custom.".count).split(separator: "x") - guard dims.count == 2, - let width = Double(dims[0]), let height = Double(dims[1]), - width > 0, height > 0 - else { return nil } - return (width, height) - } - - private func warnOnFailure(_ status: OSStatus, key: String) { - if status != 0 { - AppLogger.shared.warn( - "Print panel: PMPrintSettingsSetValue(\(key)) " - + "rejected (\(status))") - } - } - - // MARK: - PM helpers - - /// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants - /// `UnsafeRawPointer`, Swift imports `PMPrinter` as `OpaquePointer`. - static func pmObject(_ printer: PMPrinter) -> PMObject { - unsafeBitCast(printer, to: PMObject.self) - } - - /// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String. - private static func currentPrinterID( - session: PMPrintSession, - fallback: String - ) -> String { - var current: PMPrinter? - guard PMSessionGetCurrentPrinter(session, ¤t) == 0, - let printer = current - else { return fallback } - defer { PMRelease(pmObject(printer)) } - guard let id = PMPrinterGetID(printer) - else { return fallback } - return id.takeUnretainedValue() as String - } } -- 2.39.5 From fc1530ab2ed134f7bcbebcc7766827cd8f2fd3f8 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 17 Sep 2026 13:43:43 +0100 Subject: [PATCH 03/14] feat(print): capture native print ticket on panel OK (#201 Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serialise PMPrintSettings/PMPageFormat to XML data plus a plist-safe NSPrintInfo dictionary snapshot (PrintTicket), restore via PM*CreateWithDataRepresentation -> PMCopy* -> PMSessionValidate* -> updateFromPM*, and refuse cross-queue replay (R3). PrintPanelService.showProperties now returns PanelCaptureResult; the view model stores tickets in capturedTickets keyed by queue. Also fixes a latent over-release: PMSessionGetCurrentPrinter hands back the session's own printer (borrowed) — the extracted currentPrinterID was PMRelease-ing it, which dangled the session and crashed AppKit's _printerInPrintSession path and session teardown. --- Sources/ICCery/Print/PMTicketBridge.swift | 163 +++++++++++++++++- Sources/ICCery/Print/PrintPanelService.swift | 49 ++++-- .../ICCery/Print/PrintSessionViewModel.swift | 19 +- Sources/ICCery/Print/PrintTicket.swift | 30 ++++ Tests/ICCeryCoreTests/PrintTicketTests.swift | 121 +++++++++++++ 5 files changed, 355 insertions(+), 27 deletions(-) create mode 100644 Sources/ICCery/Print/PrintTicket.swift create mode 100644 Tests/ICCeryCoreTests/PrintTicketTests.swift diff --git a/Sources/ICCery/Print/PMTicketBridge.swift b/Sources/ICCery/Print/PMTicketBridge.swift index ed36022..522a2f4 100644 --- a/Sources/ICCery/Print/PMTicketBridge.swift +++ b/Sources/ICCery/Print/PMTicketBridge.swift @@ -36,7 +36,10 @@ enum PMTicketError: LocalizedError, Equatable { /// so Swift hands back an unbalanced `Unmanaged`). /// * `PMPrinterGetPaperList`, `PMPaperGetID`, `PMPrinterGetID`, /// `PMPrintSettingsGetValue` are **borrowed** → `takeUnretainedValue()`, -/// never released. +/// never released. `PMSessionGetCurrentPrinter` hands back the +/// session's own printer — also borrowed, never `PMRelease`d (an +/// over-release here dangles the session and crashes AppKit's +/// `_printerInPrintSession` / session teardown). /// * `printInfo.pmPrintSession()/pmPrintSettings()/pmPageFormat()` are /// borrowed from the `NSPrintInfo` → never released. @MainActor @@ -102,12 +105,168 @@ enum PMTicketBridge { guard PMSessionGetCurrentPrinter(session, ¤t) == 0, let printer = current else { return fallback } - defer { PMRelease(object(printer)) } + // Borrowed from the session — never released (see header doc). guard let id = PMPrinterGetID(printer) else { return fallback } return id.takeUnretainedValue() as String } + // MARK: Ticket (#201 D3) + + /// Capture the live `PMPrintSettings` + `PMPageFormat` as XML + /// `Data` plus a plist-safe `NSPrintInfo.dictionary()` fallback. + /// The CFData out-params are **+1** by header contract + /// ("the caller is responsible for releasing") → + /// `takeRetainedValue()`. + static func serialise( + _ printInfo: NSPrintInfo, + queue: String + ) throws -> PrintTicket { + var settingsRef: Unmanaged? + let settingsStatus = PMPrintSettingsCreateDataRepresentation( + settings(printInfo), &settingsRef, kPMDataFormatXMLDefault) + guard settingsStatus == noErr, let settingsRef else { + throw PMTicketError.serialiseFailed( + stage: "printSettings", status: settingsStatus) + } + let settingsData = settingsRef.takeRetainedValue() as Data + + var pageFormatRef: Unmanaged? + let pageFormatStatus = PMPageFormatCreateDataRepresentation( + pageFormat(printInfo), &pageFormatRef, kPMDataFormatXMLDefault) + guard pageFormatStatus == noErr, let pageFormatRef else { + throw PMTicketError.serialiseFailed( + stage: "pageFormat", status: pageFormatStatus) + } + let pageFormatData = pageFormatRef.takeRetainedValue() as Data + + // Cocoa-level fallback — warn-only, never fatal: filter the + // dictionary to plist-safe values so one exotic attribute + // cannot fail the whole snapshot. + let plist = try? PropertyListSerialization.data( + fromPropertyList: plistSafe(printInfo.dictionary()) ?? [:], + format: .binary, options: 0) + + return PrintTicket( + queue: queue, + printSettings: settingsData, + pageFormat: pageFormatData, + printInfoPlist: plist, + capturedAt: Date()) + } + + /// Rehydrate a ticket into `printInfo`'s live PM objects. Order is + /// load-bearing: create → copy → session-validate → Cocoa update. + /// Cross-queue replay is refused (R3) — the destination's bound + /// queue is the session's current printer; a destination with no + /// bound printer accepts the ticket (the spooler always binds + /// first, so production replay is always guarded). + /// A page-format failure is warn-only — paper is re-derived + /// upstream by the spooler's S6/S7. + static func restore( + _ ticket: PrintTicket, + into printInfo: NSPrintInfo + ) throws { + let bound = currentPrinterID( + session: session(printInfo), + fallback: ticket.queue) + guard ticket.queue == bound else { + AppLogger.shared.warn( + "PrintTicket: refusing to replay a ticket captured " + + "for '\(ticket.queue)' onto '\(bound)'") + return + } + + var srcSettings: PMPrintSettings? + let createStatus = PMPrintSettingsCreateWithDataRepresentation( + ticket.printSettings as CFData, &srcSettings) + defer { + if let srcSettings { PMRelease(object(srcSettings)) } + } + guard createStatus == noErr, let srcSettings else { + throw PMTicketError.restoreFailed( + stage: "printSettings", status: createStatus) + } + let copyStatus = PMCopyPrintSettings( + srcSettings, settings(printInfo)) + guard copyStatus == noErr else { + throw PMTicketError.restoreFailed( + stage: "printSettings", status: copyStatus) + } + var changed = DarwinBoolean(false) + _ = PMSessionValidatePrintSettings( + session(printInfo), settings(printInfo), &changed) + if changed.boolValue { + AppLogger.shared.info( + "PrintTicket: driver adjusted the restored ticket") + } + printInfo.updateFromPMPrintSettings() + + // Page format — warn-only. + var srcFormat: PMPageFormat? + let formatStatus = PMPageFormatCreateWithDataRepresentation( + ticket.pageFormat as CFData, &srcFormat) + defer { + if let srcFormat { PMRelease(object(srcFormat)) } + } + guard formatStatus == noErr, let srcFormat else { + AppLogger.shared.warn( + "PrintTicket: page format restore failed " + + "(\(formatStatus)) — paper re-derived upstream") + return + } + guard PMCopyPageFormat(srcFormat, pageFormat(printInfo)) == noErr + else { + AppLogger.shared.warn( + "PrintTicket: page format copy failed — " + + "paper re-derived upstream") + return + } + var formatChanged = DarwinBoolean(false) + _ = PMSessionValidatePageFormat( + session(printInfo), pageFormat(printInfo), &formatChanged) + printInfo.updateFromPMPageFormat() + } + + /// Recursive plist-safety filter for `NSPrintInfo.dictionary()`: + /// keeps String / NSNumber / Bool / Date / Data / URL (→ + /// absoluteString) / Array / Dictionary, drops everything else, so + /// one non-plist attribute cannot fail the whole snapshot. + private static func plistSafe(_ value: Any) -> Any? { + switch value { + case let string as String: + return string + case let number as NSNumber: + return number + case let date as Date: + return date + case let data as Data: + return data + case let url as URL: + return url.absoluteString + case let array as [Any]: + return array.compactMap(plistSafe) + case let dictionary as [String: Any]: + var safe: [String: Any] = [:] + for (key, element) in dictionary { + if let filtered = plistSafe(element) { + safe[key] = filtered + } + } + return safe + case let dictionary as [NSPrintInfo.AttributeKey: Any]: + var safe: [String: Any] = [:] + for (key, element) in dictionary { + if let filtered = plistSafe(element) { + safe[key.rawValue] = filtered + } + } + return safe + default: + return nil + } + } + // MARK: Values /// Warn-only `PMPrintSettingsSetValue`: a driver that rejects a key diff --git a/Sources/ICCery/Print/PrintPanelService.swift b/Sources/ICCery/Print/PrintPanelService.swift index 67adfac..22ed51c 100644 --- a/Sources/ICCery/Print/PrintPanelService.swift +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -59,10 +59,12 @@ struct PrintPanelService { cupsService: CupsService, initialSelections: PrintPanelInitialSelections = PrintPanelInitialSelections() - ) async throws -> PrintPropertiesResult? { + ) async throws -> PanelCaptureResult? { #if DEBUG if UITestHooks.printPanelStubbed { - return UITestHooks.printPanelResult(forQueue: queue) + return UITestHooks.printPanelResult(forQueue: queue).map { + PanelCaptureResult(properties: $0, ticket: nil) + } } #endif // `??` rhs is a non-async @autoclosure — fetch first. @@ -84,7 +86,7 @@ struct PrintPanelService { displayName: String?, optionKeys: Set, initialSelections: PrintPanelInitialSelections - ) throws -> PrintPropertiesResult? { + ) throws -> PanelCaptureResult? { let printInfo = NSPrintInfo() var pmPrinter: PMPrinter? var boundViaPM = false @@ -177,22 +179,31 @@ struct PrintPanelService { mediaType = captured.mediaType } let capturedOptions = cupsOptions ?? "" - return PrintPropertiesResult( - selectedPrinter: boundViaPM - ? PMTicketBridge.currentPrinterID( - session: PMTicketBridge.session(printInfo), - fallback: queue) - : nil, - options: PrintOptions( - orientation: CupsParsers.extractOrientation( - fromOptionsString: capturedOptions), - paperSize: CupsParsers.extractOption( - named: "PageSize", fromOptionsString: capturedOptions), - mediaType: mediaType, - quality: CupsParsers.extractQuality( - fromOptionsString: capturedOptions), - ppdUncorrectedPassthrough: true, - cupsOptions: cupsOptions)) + let resolvedQueue = boundViaPM + ? PMTicketBridge.currentPrinterID( + session: PMTicketBridge.session(printInfo), + fallback: queue) + : 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. + let ticket = try? PMTicketBridge.serialise( + printInfo, queue: resolvedQueue) + return PanelCaptureResult( + properties: PrintPropertiesResult( + selectedPrinter: boundViaPM ? resolvedQueue : nil, + options: PrintOptions( + orientation: CupsParsers.extractOrientation( + fromOptionsString: capturedOptions), + paperSize: CupsParsers.extractOption( + named: "PageSize", + fromOptionsString: capturedOptions), + mediaType: mediaType, + quality: CupsParsers.extractQuality( + fromOptionsString: capturedOptions), + ppdUncorrectedPassthrough: true, + cupsOptions: cupsOptions)), + ticket: ticket) } /// Initial-selection `PMPrintSettings` writes — paper, quality, diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index 7f684af..1c992d2 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -23,6 +23,9 @@ final class PrintSessionViewModel: ObservableObject { @Published var selectedQuality: String? @Published var printOrientation = "portrait" @Published var capturedCupsOptions: [String: String] = [:] + /// Native print tickets per queue — the spool payload (#201). + /// Session-only; cleared whenever the queue's mirror is. + @Published var capturedTickets: [String: PrintTicket] = [:] @Published var printNotice: Notice? @Published var isPrinting = false private var printTask: Task? @@ -164,31 +167,35 @@ final class PrintSessionViewModel: ObservableObject { ) return } - if let selected = result.selectedPrinter, + if let selected = result.properties.selectedPrinter, printers.contains(where: { $0.name == selected }), selected != queue { selectedPrinter = selected await reloadSelectedCapabilities() } - if let captured = result.options.cupsOptions { + if let captured = result.properties.options.cupsOptions { capturedCupsOptions[selectedPrinter] = captured } - if let media = result.options.mediaType { + // The native ticket rides alongside the mirror (#201). + if let ticket = result.ticket { + capturedTickets[ticket.queue] = ticket + } + if let media = result.properties.options.mediaType { selectedMediaType = media } // Capture-return (#183/#186): a dialog paper/quality/ // orientation change updates the Stage 2 selections — // never `workflow.pageSize` (printtarg layout is // sacred). - if let paper = result.options.paperSize, + if let paper = result.properties.options.paperSize, let match = printerCaps.paperSizes .first(where: { $0.name == paper }) { selectedPaperSize = match.id } - if let quality = result.options.quality { + if let quality = result.properties.options.quality { selectedQuality = quality } - if let orientation = result.options.orientation { + if let orientation = result.properties.options.orientation { printOrientation = orientation } printNotice = Notice( diff --git a/Sources/ICCery/Print/PrintTicket.swift b/Sources/ICCery/Print/PrintTicket.swift new file mode 100644 index 0000000..2568271 --- /dev/null +++ b/Sources/ICCery/Print/PrintTicket.swift @@ -0,0 +1,30 @@ +import Foundation +import ICCeryCore + +/// A captured native print ticket (#201) — the payload `lp -o` could +/// never carry. Vendor PDE state, including opaque binary blobs, is +/// preserved verbatim inside `printSettings`. +/// +/// In-memory, session-only. Never written to `settings.json` or an +/// `.icceryproj` (a ticket is queue- and driver-version-specific). +struct PrintTicket: Equatable, Sendable { + /// CUPS queue id this ticket was captured for. Replay onto any other + /// queue is refused (R3). + let queue: String + /// `PMPrintSettingsCreateDataRepresentation(…, kPMDataFormatXMLDefault)`. + let printSettings: Data + /// `PMPageFormatCreateDataRepresentation(…, kPMDataFormatXMLDefault)`. + let pageFormat: Data + /// Binary-plist snapshot of `NSPrintInfo.dictionary()`, plist-filtered. + /// Cocoa-level fallback only — never the primary restore path. + let printInfoPlist: Data? + let capturedAt: Date +} + +/// App-level panel outcome: the ICCeryCore `PrintPropertiesResult` +/// (Stage 2 mirror values, #183/#186) **plus** the native ticket the +/// spooler replays (#201). +struct PanelCaptureResult { + var properties: PrintPropertiesResult + var ticket: PrintTicket? +} diff --git a/Tests/ICCeryCoreTests/PrintTicketTests.swift b/Tests/ICCeryCoreTests/PrintTicketTests.swift new file mode 100644 index 0000000..fc5b064 --- /dev/null +++ b/Tests/ICCeryCoreTests/PrintTicketTests.swift @@ -0,0 +1,121 @@ +import AppKit +import Foundation +import XCTest +@testable import ICCery + +/// Issue #201 Phase 2 — `PMTicketBridge.serialise`/`restore` byte +/// round-trip, cross-queue refusal (R3), plist fallback, and the +/// `takeRetainedValue` ownership canary. +@MainActor +final class PrintTicketTests: XCTestCase { + + /// The queue a destination `NSPrintInfo` will report as bound — + /// the session's current printer, else the Cocoa printer name, + /// else a synthetic token for a queue-less environment. + private func boundQueue(of printInfo: NSPrintInfo) -> String { + PMTicketBridge.currentPrinterID( + session: PMTicketBridge.session(printInfo), + fallback: "UnboundQueue") + } + + func testRoundTripRetainsVendorValue() throws { + let source = NSPrintInfo() + XCTAssertTrue( + PMTicketBridge.setValue( + "305", forKey: "EPIJ_Qual", locked: false, + in: PMTicketBridge.settings(source), + context: "PrintTicketTests")) + + let target = NSPrintInfo() + let ticket = try PMTicketBridge.serialise( + source, queue: boundQueue(of: target)) + try PMTicketBridge.restore(ticket, into: target) + + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "EPIJ_Qual", + in: PMTicketBridge.settings(target)), + "305") + } + + func testPrintSettingsDataIsXmlPlist() throws { + let ticket = try PMTicketBridge.serialise( + NSPrintInfo(), queue: "AnyQueue") + XCTAssertFalse(ticket.printSettings.isEmpty) + XCTAssertFalse(ticket.pageFormat.isEmpty) + XCTAssertEqual( + String(decoding: ticket.printSettings.prefix(5), + as: UTF8.self), + " Date: Thu, 17 Sep 2026 14:57:24 +0100 Subject: [PATCH 04/14] feat(print): 1:1 TIFF raster loader and headless page canvas (#201 Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TargetRasterLoader decodes printtarg TIFFs via ImageIO and re-tags into DeviceGray/DeviceRGB/DeviceCMYK around the decoder's own dataProvider — no resample, no bit-depth change (D10). Skip-only alpha padding (noneSkipLast/First, which ImageIO emits for opaque RGB TIFFs) is accepted; real coverage alpha, indexed, float and non-1/3/4-component layouts are rejected as unsupportedLayout. DPI metadata drives pointSize (px/dpi*72) with a 72 dpi fallback + warn; manifestDrift reports >0.5 mm disagreement with the printtarg manifest (D9). TargetPageCanvasView is the headless flipped canvas NSPrintOperation paginates: rectForPage stacks pages top-down, destinationRect anchors 1:1 at the page's top-left snapped to 0.001 pt, and draw() forces interpolation/antialias/font-smoothing off per page with a drawProbe seam for tests. PDF harness (NativeSpoolPDFTests, skippable via ICCERY_SKIP_PRINT_PDF=1) ran on this host: run() -> true, 2 pages at A4 media box, and the fixture's 72 pt patch verified 1:1 at the painted top-left (printer unprintable-margin offset tolerated per R10). --- .../ICCery/Print/TargetPageCanvasView.swift | 86 +++++++ Sources/ICCery/Print/TargetRaster.swift | 221 ++++++++++++++++ .../ICCeryCoreTests/NativeSpoolPDFTests.swift | 164 ++++++++++++ .../TargetCanvasGeometryTests.swift | 170 ++++++++++++ .../TargetRasterFixtures.swift | 126 +++++++++ Tests/ICCeryCoreTests/TargetRasterTests.swift | 241 ++++++++++++++++++ 6 files changed, 1008 insertions(+) create mode 100644 Sources/ICCery/Print/TargetPageCanvasView.swift create mode 100644 Sources/ICCery/Print/TargetRaster.swift create mode 100644 Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift create mode 100644 Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift create mode 100644 Tests/ICCeryCoreTests/TargetRasterFixtures.swift create mode 100644 Tests/ICCeryCoreTests/TargetRasterTests.swift diff --git a/Sources/ICCery/Print/TargetPageCanvasView.swift b/Sources/ICCery/Print/TargetPageCanvasView.swift new file mode 100644 index 0000000..52d638c --- /dev/null +++ b/Sources/ICCery/Print/TargetPageCanvasView.swift @@ -0,0 +1,86 @@ +import AppKit + +/// Headless print canvas (#201, docs/14 §6). Never installed in a +/// window; `NSPrintOperation` is its only client. +@MainActor +final class TargetPageCanvasView: NSView { + + private let pages: [TargetPageRaster] + private let paperSize: NSSize + + /// Test probe — invoked once per drawn page with the live context + /// so the interpolation/antialias contract is assertable without a + /// printer. + var drawProbe: ((NSGraphicsContext, Int, NSRect) -> Void)? + + init(pages: [TargetPageRaster], paperSize: NSSize) { + self.pages = pages + self.paperSize = paperSize + super.init(frame: NSRect( + x: 0, y: 0, + width: paperSize.width, + height: paperSize.height * CGFloat(pages.count))) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("TargetPageCanvasView is code-only") + } + + /// Flipped: the CoreGraphics coordinate space is correctly oriented + /// top-down, so page 1 is the TOP rect. + override var isFlipped: Bool { true } + override var isOpaque: Bool { true } + + override func knowsPageRange(_ range: NSRangePointer) -> Bool { + range.pointee = NSRange(location: 1, length: pages.count) + return true + } + + /// 1-based page → its paper-sized rect, stacked top-down. + override func rectForPage(_ page: Int) -> NSRect { + NSRect(x: 0, + y: CGFloat(page - 1) * paperSize.height, + width: paperSize.width, + height: paperSize.height) + } + + /// Where page `page`'s image lands: top-left anchored inside + /// `rectForPage`, sized `raster.pointSize`, origin snapped to + /// 0.001 pt (docs/14 §6) so a fractional origin cannot trigger + /// device resampling. `internal` for unit tests. + func destinationRect(forPage page: Int) -> NSRect { + let pageRect = rectForPage(page) + let size = pages[page - 1].pointSize + return NSRect(x: Self.snap(pageRect.minX), + y: Self.snap(pageRect.minY), + width: size.width, + height: size.height) + } + + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current else { return } + ctx.imageInterpolation = .none + ctx.shouldAntialias = false + let cg = ctx.cgContext + cg.interpolationQuality = .none + cg.setShouldAntialias(false) + cg.setAllowsAntialiasing(false) + cg.setShouldSmoothFonts(false) + for index in pages.indices { + let pageRect = rectForPage(index + 1) + guard pageRect.intersects(dirtyRect) else { continue } + cg.saveGState() // page N never inherits N-1's CTM + NSColor.white.setFill() + pageRect.fill() + cg.draw(pages[index].cgImage, + in: destinationRect(forPage: index + 1)) + cg.restoreGState() + drawProbe?(ctx, index + 1, destinationRect(forPage: index + 1)) + } + } + + private static func snap(_ value: CGFloat) -> CGFloat { + (value * 1000).rounded() / 1000 + } +} diff --git a/Sources/ICCery/Print/TargetRaster.swift b/Sources/ICCery/Print/TargetRaster.swift new file mode 100644 index 0000000..5c3d32f --- /dev/null +++ b/Sources/ICCery/Print/TargetRaster.swift @@ -0,0 +1,221 @@ +import CoreGraphics +import Foundation +import ICCeryCore +import ImageIO + +/// One decoded target page ready to draw 1:1 (#201, docs/14 §6). +struct TargetPageRaster: Equatable { + let sourceURL: URL + /// Re-tagged into a **device** colour space — the image Quartz draws. + let cgImage: CGImage + let pixelWidth: Int + let pixelHeight: Int + let dpiX: Double + let dpiY: Double + let componentCount: Int + let bitsPerComponent: Int + /// Expected physical size from the printtarg manifest, when known. + let expectedWidthMm: Double? + let expectedHeightMm: Double? + + /// `pixels / dpi * 72`. The ONLY size the canvas ever draws at. + /// 72 pt = 1 in. **Never** `backingScaleFactor` (docs/14 §6). + var pointSize: CGSize { + CGSize(width: Double(pixelWidth) / dpiX * 72, + height: Double(pixelHeight) / dpiY * 72) + } + + /// `pixels / dpi * 25.4` — the physical size the DPI implies. + var physicalMm: CGSize { + CGSize(width: Double(pixelWidth) / dpiX * 25.4, + height: Double(pixelHeight) / dpiY * 25.4) + } + + /// `nil` when within 0.5 mm of the manifest, else the drift + /// message (D9 — warn + spool, never rescale). + var manifestDrift: String? { + var parts: [String] = [] + if let expectedWidthMm, + abs(physicalMm.width - expectedWidthMm) > 0.5 { + parts.append(String( + format: "width %.2f mm vs manifest %.2f mm", + physicalMm.width, expectedWidthMm)) + } + if let expectedHeightMm, + abs(physicalMm.height - expectedHeightMm) > 0.5 { + parts.append(String( + format: "height %.2f mm vs manifest %.2f mm", + physicalMm.height, expectedHeightMm)) + } + guard !parts.isEmpty else { return nil } + return "TIFF DPI disagrees with the printtarg manifest — " + + parts.joined(separator: "; ") + } + + static func == (lhs: TargetPageRaster, rhs: TargetPageRaster) -> Bool { + lhs.sourceURL == rhs.sourceURL + && lhs.cgImage === rhs.cgImage + && lhs.pixelWidth == rhs.pixelWidth + && lhs.pixelHeight == rhs.pixelHeight + && lhs.dpiX == rhs.dpiX + && lhs.dpiY == rhs.dpiY + && lhs.componentCount == rhs.componentCount + && lhs.bitsPerComponent == rhs.bitsPerComponent + && lhs.expectedWidthMm == rhs.expectedWidthMm + && lhs.expectedHeightMm == rhs.expectedHeightMm + } +} + +enum TargetRasterError: LocalizedError, Equatable { + case tiffMissing(String) + case undecodable(String) + case unsupportedLayout(path: String, components: Int, + bitsPerComponent: Int, hasAlpha: Bool) + + var errorDescription: String? { + switch self { + case .tiffMissing(let path): + return "Target TIFF is missing: \(path)" + case .undecodable(let path): + return "Target TIFF could not be decoded: \(path)" + case .unsupportedLayout(let path, let components, + let bitsPerComponent, let hasAlpha): + return "Target TIFF has an unsupported layout " + + "(\(components) components, \(bitsPerComponent) bpc" + + "\(hasAlpha ? ", alpha" : "")): \(path)" + } + } +} + +enum TargetRasterLoader { + /// Absent DPI metadata defaults to 72 (docs/14 §6) — and logs `warn`. + static let fallbackDPI: Double = 72 + + static func load( + tiff url: URL, + expectedWidthMm: Double? = nil, + expectedHeightMm: Double? = nil + ) throws -> TargetPageRaster { + guard FileManager.default.fileExists(atPath: url.path) else { + throw TargetRasterError.tiffMissing(url.path) + } + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) + else { + throw TargetRasterError.undecodable(url.path) + } + let options: [CFString: Any] = [ + kCGImageSourceShouldCache: true, + kCGImageSourceShouldAllowFloat: false, + ] + guard let image = CGImageSourceCreateImageAtIndex( + source, 0, options as CFDictionary) + else { + throw TargetRasterError.undecodable(url.path) + } + + // DPI lives in the container metadata, not the CGImage. + var dpiX = fallbackDPI + var dpiY = fallbackDPI + var foundX = false + var foundY = false + if let properties = CGImageSourceCopyPropertiesAtIndex( + source, 0, nil) as? [CFString: Any] { + if let x = (properties[kCGImagePropertyDPIWidth] + as? NSNumber)?.doubleValue, x > 0 { + dpiX = x + foundX = true + } + if let y = (properties[kCGImagePropertyDPIHeight] + as? NSNumber)?.doubleValue, y > 0 { + dpiY = y + foundY = true + } + } + if !foundX || !foundY { + AppLogger.shared.warn( + "TargetRaster: '\(url.lastPathComponent)' carries no " + + "usable DPI metadata — assuming \(fallbackDPI) dpi") + } + + let components = image.colorSpace?.numberOfComponents ?? 0 + guard !Self.hasRealAlpha(image), let tagged = deviceTagged(image) + else { + throw TargetRasterError.unsupportedLayout( + path: url.path, + components: components, + bitsPerComponent: image.bitsPerComponent, + hasAlpha: Self.hasRealAlpha(image)) + } + + return TargetPageRaster( + sourceURL: url, + cgImage: tagged, + pixelWidth: image.width, + pixelHeight: image.height, + dpiX: dpiX, + dpiY: dpiY, + componentCount: components, + bitsPerComponent: image.bitsPerComponent, + expectedWidthMm: expectedWidthMm, + expectedHeightMm: expectedHeightMm) + } + + /// Re-tag into `DeviceGray` (1 comp) / `DeviceRGB` (3) / + /// `DeviceCMYK` (4) by reusing the decoded image's own + /// `dataProvider`, `bitsPerComponent`, `bitsPerPixel`, `bytesPerRow` + /// and `bitmapInfo` — **no resample, no bit-depth change, no + /// `CGImageCreateCopyWithColorSpace` into a calibrated space** + /// (docs/14 §6.3). `shouldInterpolate: false`, + /// `intent: .defaultIntent`. + /// + /// Returns `nil` (caller throws `.unsupportedLayout`) for alpha, + /// indexed, float, or >4-component images — a profiling target + /// never has those, and guessing would corrupt patches. + static func deviceTagged(_ image: CGImage) -> CGImage? { + guard !hasRealAlpha(image), + !image.bitmapInfo.contains(.floatComponents), + let colorSpace = image.colorSpace, + colorSpace.model != .indexed, + colorSpace.model != .pattern, + let space = deviceSpace(for: colorSpace.numberOfComponents), + let provider = image.dataProvider + else { return nil } + return CGImage( + width: image.width, + height: image.height, + bitsPerComponent: image.bitsPerComponent, + bitsPerPixel: image.bitsPerPixel, + bytesPerRow: image.bytesPerRow, + space: space, + bitmapInfo: image.bitmapInfo, + provider: provider, + decode: image.decode, + shouldInterpolate: false, + intent: .defaultIntent) + } + + /// Skip-only padding (`noneSkipLast`/`noneSkipFirst`) is **not** + /// alpha — ImageIO pads opaque RGB TIFFs to 32 bpp that way, so + /// accepting it is required for real printtarg files. True coverage + /// alpha (`last`/`first`/`premultiplied*`/`only`) is rejected. + static func hasRealAlpha(_ image: CGImage) -> Bool { + switch image.alphaInfo { + case .none, .noneSkipLast, .noneSkipFirst: + return false + default: + return true + } + } + + /// The device colour space for a component count — `nil` for + /// layouts a profiling target can never legally be (2 channels, + /// DeviceN, >4 components). + static func deviceSpace(for componentCount: Int) -> CGColorSpace? { + switch componentCount { + case 1: return CGColorSpaceCreateDeviceGray() + case 3: return CGColorSpaceCreateDeviceRGB() + case 4: return CGColorSpaceCreateDeviceCMYK() + default: return nil + } + } +} diff --git a/Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift b/Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift new file mode 100644 index 0000000..51984ae --- /dev/null +++ b/Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift @@ -0,0 +1,164 @@ +import AppKit +import CoreGraphics +import Foundation +import XCTest +@testable import ICCery + +/// Issue #201 Phase 3 — real `NSPrintOperation` with +/// `jobDisposition = .save`: proves the canvas paginates at paper size, +/// draws the raster 1:1 at the top-left, and survives end-to-end +/// without a printer. Skippable via `ICCERY_SKIP_PRINT_PDF=1` for +/// hosts where the print system cannot run (R8). +@MainActor +final class NativeSpoolPDFTests: XCTestCase { + + private var tempRoot: URL! + + /// A4 in points. + private let paperSize = NSSize(width: 595.28, height: 841.89) + /// Known top-left patch colour — solid fill across the fixture. + private let patchBytes: [UInt8] = [230, 40, 50] + + override func setUpWithError() throws { + tempRoot = try TargetTestFixtures.makeTempDirectory() + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempRoot) + } + + func testSavePDFDrawsPagesOneToOne() throws { + try XCTSkipIf( + ProcessInfo.processInfo.environment["ICCERY_SKIP_PRINT_PDF"] + == "1", + "ICCERY_SKIP_PRINT_PDF=1 — print system unavailable") + + // 72×72 px @ 72 dpi → a 72×72 pt solid block at the top-left. + let tiff = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 72, height: 72), dpi: 72, + components: 3, bitsPerComponent: 8, + pixelBytes: patchBytes, in: tempRoot) + let raster = try TargetRasterLoader.load(tiff: tiff) + + let info = NSPrintInfo() + info.paperSize = paperSize + info.orientation = .portrait + info.topMargin = 0 + info.bottomMargin = 0 + info.leftMargin = 0 + info.rightMargin = 0 + info.horizontalPagination = .clip + info.verticalPagination = .clip + info.scalingFactor = 1.0 + info.isHorizontallyCentered = false + info.isVerticallyCentered = false + info.jobDisposition = .save + let pdfURL = tempRoot.appendingPathComponent("spool.pdf") + info.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = pdfURL + + let canvas = TargetPageCanvasView( + pages: [raster, raster], paperSize: paperSize) + let operation = NSPrintOperation(view: canvas, printInfo: info) + operation.showsPrintPanel = false + operation.showsProgressPanel = false + operation.canSpawnSeparateThread = false + + XCTAssertTrue(operation.run(), + "NSPrintOperation.save failed") + XCTAssertTrue( + FileManager.default.fileExists(atPath: pdfURL.path)) + + let document = try XCTUnwrap( + CGPDFDocument(pdfURL as CFURL)) + XCTAssertEqual(document.numberOfPages, 2) + + let page = try XCTUnwrap(document.page(at: 1)) + let mediaBox = page.getBoxRect(.mediaBox) + XCTAssertEqual(mediaBox.width, paperSize.width, accuracy: 0.5) + XCTAssertEqual(mediaBox.height, paperSize.height, accuracy: 0.5) + + try assertTopLeftPatch(on: page, mediaBox: mediaBox) + } + + /// Render the page at 1 px/pt and check the fixture's solid block + /// landed at the top-left at its point size — the coarse 1:1 + + /// no-scaling check. + private func assertTopLeftPatch( + on page: CGPDFPage, mediaBox: CGRect + ) throws { + let width = Int(ceil(mediaBox.width)) + let height = Int(ceil(mediaBox.height)) + let rep = try XCTUnwrap(NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: width, + pixelsHigh: height, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0)) + let context = try XCTUnwrap( + NSGraphicsContext(bitmapImageRep: rep)) + let cg = context.cgContext + let transform = page.getDrawingTransform( + .mediaBox, + rect: CGRect(x: 0, y: 0, width: width, height: height), + rotate: 0, preserveAspectRatio: true) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + cg.concatenate(transform) + cg.drawPDFPage(page) + NSGraphicsContext.restoreGraphicsState() + + func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) { + let color = try XCTUnwrap( + rep.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB)) + return (color.redComponent, + color.greenComponent, + color.blueComponent) + } + func isPatch(_ p: (Double, Double, Double)) -> Bool { + abs(p.0 - Double(patchBytes[0]) / 255) < 0.04 + && abs(p.1 - Double(patchBytes[1]) / 255) < 0.04 + && abs(p.2 - Double(patchBytes[2]) / 255) < 0.04 + } + + // The print system insets the view by the queue's unprintable + // margin (R10) — host-dependent, ~18 pt on this machine — so + // the block's anchor is the painted region's top-left, not + // (0,0). Find the painted origin by the first opaque pixel. + var originX = width, originY = height + for y in 0.. 0.5 { + originX = min(originX, x) + originY = min(originY, y) + } + } + XCTAssertLessThan(originX, width, "PDF page rendered empty") + XCTAssertLessThan(originY, height, "PDF page rendered empty") + + // 10 px inside the 72 pt block on each axis → patch colour. + XCTAssertTrue(try isPatch(pixel(originX + 10, originY + 10)), + "top-left block is not the fixture colour") + + // Measure the block's painted width on a row through it: + // 72 pt at 1 px/pt — the 1:1 / no-scaling assertion. + var blockWidth = 0 + var x = originX + while x < width, + isPatch(try pixel(x, originY + 10)) { + blockWidth += 1 + x += 1 + } + XCTAssertEqual(blockWidth, 72, accuracy: 2) + + // Past the block edge the canvas's white page fill shows. + let outside = try pixel(originX + 80, originY + 80) + XCTAssertEqual(outside.0, 1, accuracy: 0.04) + XCTAssertEqual(outside.1, 1, accuracy: 0.04) + XCTAssertEqual(outside.2, 1, accuracy: 0.04) + } +} diff --git a/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift b/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift new file mode 100644 index 0000000..ce58e6f --- /dev/null +++ b/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift @@ -0,0 +1,170 @@ +import AppKit +import CoreGraphics +import Foundation +import XCTest +@testable import ICCery + +/// Issue #201 Phase 3 — `TargetPageCanvasView` top-down page stacking, +/// snapped top-left anchoring, and the draw-time interpolation / +/// antialias contract asserted via `drawProbe`. +@MainActor +final class TargetCanvasGeometryTests: XCTestCase { + + /// A4-ish paper, points. + private let paperSize = NSSize(width: 595.28, height: 841.89) + + private func raster( + pixelWidth: Int = 2480, + pixelHeight: Int = 3508, + dpi: Double = 300 + ) -> TargetPageRaster { + let image = TargetTestFixtures.makeImage( + px: CGSize(width: 8, height: 8), + components: 3, bitsPerComponent: 8)! + return TargetPageRaster( + sourceURL: URL(fileURLWithPath: "/fixture.tiff"), + cgImage: image, + pixelWidth: pixelWidth, + pixelHeight: pixelHeight, + dpiX: dpi, dpiY: dpi, + componentCount: 3, + bitsPerComponent: 8, + expectedWidthMm: nil, + expectedHeightMm: nil) + } + + // MARK: - Page stacking + + func testRectForPageStacksTopDown() { + let view = TargetPageCanvasView( + pages: [raster(), raster(), raster()], + paperSize: paperSize) + XCTAssertTrue(view.isFlipped) + + let page1 = view.rectForPage(1) + let page3 = view.rectForPage(3) + XCTAssertEqual(page1.minY, 0, accuracy: 0.001) + XCTAssertEqual(page3.minY, 2 * paperSize.height, accuracy: 0.001) + XCTAssertEqual(page1.size, paperSize) + XCTAssertEqual(page3.size, paperSize) + } + + func testKnowsPageRangeAndFrame() { + let view = TargetPageCanvasView( + pages: [raster(), raster()], + paperSize: paperSize) + var range = NSRange() + XCTAssertTrue(view.knowsPageRange(&range)) + XCTAssertEqual(range, NSRange(location: 1, length: 2)) + XCTAssertEqual( + view.frame.height, 2 * paperSize.height, accuracy: 0.001) + XCTAssertEqual(view.frame.width, paperSize.width, accuracy: 0.001) + } + + // MARK: - Destination rect + + func testDestinationRectAnchorsTopLeftAndSnaps() { + let view = TargetPageCanvasView( + pages: [raster(), raster()], + paperSize: paperSize) + + let first = view.destinationRect(forPage: 1) + XCTAssertEqual(first.origin.x, 0, accuracy: 0.001) + XCTAssertEqual(first.origin.y, 0, accuracy: 0.001) + XCTAssertEqual(first.width, 595.2, accuracy: 0.01) + XCTAssertEqual(first.height, 841.92, accuracy: 0.01) + + let second = view.destinationRect(forPage: 2) + XCTAssertEqual( + second.origin.y, paperSize.height, accuracy: 0.001) + XCTAssertEqual(second.size, first.size) + + for rect in [first, second] { + XCTAssertEqual( + rect.origin.x * 1000, + (rect.origin.x * 1000).rounded(), + accuracy: 1e-9) + XCTAssertEqual( + rect.origin.y * 1000, + (rect.origin.y * 1000).rounded(), + accuracy: 1e-9) + } + } + + // MARK: - Draw flags + + /// Render the canvas into an offscreen bitmap context and assert — + /// from inside `drawProbe` — that interpolation and antialiasing + /// are off and the drawn rect is `destinationRect(forPage:)`. + func testDrawDisablesInterpolationAndAntialias() throws { + let pages = [raster(), raster()] + let view = TargetPageCanvasView( + pages: pages, paperSize: paperSize) + + let rep = try XCTUnwrap(NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(ceil(view.frame.width)), + pixelsHigh: Int(ceil(view.frame.height)), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0)) + let context = try XCTUnwrap( + NSGraphicsContext(bitmapImageRep: rep)) + + var probed: [(page: Int, rect: NSRect)] = [] + view.drawProbe = { ctx, page, rect in + XCTAssertEqual(ctx.imageInterpolation, .none, + "page \(page): interpolation not .none") + XCTAssertFalse(ctx.shouldAntialias, + "page \(page): antialiasing still on") + probed.append((page, rect)) + } + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + view.draw(view.bounds) + NSGraphicsContext.restoreGraphicsState() + + XCTAssertEqual(probed.count, pages.count) + for (page, rect) in probed { + XCTAssertEqual( + rect, view.destinationRect(forPage: page), + "page \(page): probe rect mismatch") + } + } + + /// A dirty rect covering only page 2's band must draw only page 2. + func testDrawSkipsPagesOutsideDirtyRect() throws { + let view = TargetPageCanvasView( + pages: [raster(), raster(), raster()], + paperSize: paperSize) + + let rep = try XCTUnwrap(NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(ceil(view.frame.width)), + pixelsHigh: Int(ceil(view.frame.height)), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0)) + let context = try XCTUnwrap( + NSGraphicsContext(bitmapImageRep: rep)) + + var probedPages: [Int] = [] + view.drawProbe = { _, page, _ in probedPages.append(page) } + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + view.draw(view.rectForPage(2)) + NSGraphicsContext.restoreGraphicsState() + + XCTAssertEqual(probedPages, [2]) + } +} diff --git a/Tests/ICCeryCoreTests/TargetRasterFixtures.swift b/Tests/ICCeryCoreTests/TargetRasterFixtures.swift new file mode 100644 index 0000000..00d97f8 --- /dev/null +++ b/Tests/ICCeryCoreTests/TargetRasterFixtures.swift @@ -0,0 +1,126 @@ +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// Shared TIFF fixture builder for the #201 raster / canvas / PDF +/// harness tests. Writes real TIFFs via `CGImageDestination` so the +/// loader exercises the same decode path as production. +enum TargetTestFixtures { + + enum FixtureError: Error { + case imageNotCreated + case destinationNotCreated + case finalizeFailed + } + + /// Fresh per-test directory under the temp root. + static func makeTempDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-raster-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: url, withIntermediateDirectories: true) + return url + } + + /// A solid-fill (or repeating-pattern) `CGImage` with a device + /// colour space for 1/3/4 components at 8 or 16 bpc. `alpha: true` + /// appends one alpha sample per pixel (`last`). `pixelBytes` is the + /// repeating per-pixel pattern (big-endian for 16 bpc). + static func makeImage( + px: CGSize, + components: Int, + bitsPerComponent: Int, + alpha: Bool = false, + pixelBytes: [UInt8]? = nil + ) -> CGImage? { + let width = Int(px.width) + let height = Int(px.height) + let bytesPerComponent = bitsPerComponent / 8 + let samples = components + (alpha ? 1 : 0) + let bytesPerPixel = samples * bytesPerComponent + let bytesPerRow = width * bytesPerPixel + let pattern = pixelBytes + ?? Array(0.. URL { + guard let image = makeImage( + px: px, components: components, + bitsPerComponent: bitsPerComponent, + alpha: alpha, pixelBytes: pixelBytes) + else { throw FixtureError.imageNotCreated } + let url = directory.appendingPathComponent( + "fixture-\(UUID().uuidString).tiff") + guard let destination = CGImageDestinationCreateWithURL( + url as CFURL, UTType.tiff.identifier as CFString, 1, nil) + else { throw FixtureError.destinationNotCreated } + var properties: [CFString: Any] = [:] + if let dpi { + properties[kCGImagePropertyDPIWidth] = dpi + properties[kCGImagePropertyDPIHeight] = dpi + properties[kCGImagePropertyTIFFDictionary] = [ + kCGImagePropertyTIFFXResolution: dpi, + kCGImagePropertyTIFFYResolution: dpi, + kCGImagePropertyTIFFResolutionUnit: 2, // inches + ] + } + CGImageDestinationAddImage( + destination, image, properties as CFDictionary) + guard CGImageDestinationFinalize(destination) + else { throw FixtureError.finalizeFailed } + return url + } +} diff --git a/Tests/ICCeryCoreTests/TargetRasterTests.swift b/Tests/ICCeryCoreTests/TargetRasterTests.swift new file mode 100644 index 0000000..7bbbd3c --- /dev/null +++ b/Tests/ICCeryCoreTests/TargetRasterTests.swift @@ -0,0 +1,241 @@ +import CoreGraphics +import Foundation +import ImageIO +import XCTest +@testable import ICCery + +/// Issue #201 Phase 3 — `TargetRasterLoader` DPI handling, device +/// re-tag (D10), unsupported-layout rejection, and manifest drift (D9). +final class TargetRasterTests: XCTestCase { + + private var tempRoot: URL! + + override func setUpWithError() throws { + tempRoot = try TargetTestFixtures.makeTempDirectory() + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempRoot) + } + + private func load(_ url: URL) throws -> TargetPageRaster { + try TargetRasterLoader.load(tiff: url) + } + + /// Decode index 0 the same way the loader does, for provider-byte + /// comparisons that prove the re-tag did not resample. + private func decoded(_ url: URL) throws -> CGImage { + let source = try XCTUnwrap( + CGImageSourceCreateWithURL(url as CFURL, nil)) + return try XCTUnwrap( + CGImageSourceCreateImageAtIndex(source, 0, nil)) + } + + // MARK: - pointSize / physicalMm + + func testPointSizeFromDPI() throws { + let url = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 2480, height: 3508), dpi: 300, + in: tempRoot) + let raster = try load(url) + XCTAssertEqual(raster.pointSize.width, 595.2, accuracy: 0.01) + XCTAssertEqual(raster.pointSize.height, 841.92, accuracy: 0.01) + XCTAssertEqual(raster.physicalMm.width, 209.97, accuracy: 0.01) + XCTAssertEqual(raster.physicalMm.height, 297.01, accuracy: 0.01) + } + + func testPointSizeAt600dpi() throws { + let url = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 1200, height: 1800), dpi: 600, + in: tempRoot) + let raster = try load(url) + XCTAssertEqual(raster.pointSize.width, 144, accuracy: 0.01) + XCTAssertEqual(raster.pointSize.height, 216, accuracy: 0.01) + } + + func testMissingDPIFallsBackTo72() throws { + let url = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 100, height: 80), dpi: nil, + in: tempRoot) + let raster = try load(url) + XCTAssertEqual(raster.dpiX, TargetRasterLoader.fallbackDPI) + XCTAssertEqual(raster.dpiY, TargetRasterLoader.fallbackDPI) + XCTAssertEqual(raster.pointSize.width, 100, accuracy: 0.01) + XCTAssertEqual(raster.pointSize.height, 80, accuracy: 0.01) + } + + func testMissingFileThrows() { + let url = tempRoot.appendingPathComponent("nope.tiff") + XCTAssertThrowsError(try load(url)) { error in + XCTAssertEqual( + error as? TargetRasterError, + .tiffMissing(url.path)) + } + } + + func testGarbageFileThrowsUndecodable() throws { + let url = tempRoot.appendingPathComponent("junk.tiff") + try Data([0x00, 0x01, 0x02, 0x03]).write(to: url) + XCTAssertThrowsError(try load(url)) { error in + XCTAssertEqual( + error as? TargetRasterError, + .undecodable(url.path)) + } + } + + // MARK: - deviceTagged (D10) + + func testDeviceTaggedRGB8() throws { + let source = try XCTUnwrap(TargetTestFixtures.makeImage( + px: CGSize(width: 16, height: 16), + components: 3, bitsPerComponent: 8)) + let tagged = try XCTUnwrap( + TargetRasterLoader.deviceTagged(source)) + XCTAssertEqual(tagged.colorSpace?.model, .rgb) + XCTAssertEqual(tagged.bitsPerComponent, 8) + XCTAssertFalse(tagged.shouldInterpolate) + XCTAssertEqual( + tagged.dataProvider?.data as? Data, + source.dataProvider?.data as? Data) + } + + func testDeviceTaggedCMYK8() throws { + let source = try XCTUnwrap(TargetTestFixtures.makeImage( + px: CGSize(width: 16, height: 16), + components: 4, bitsPerComponent: 8)) + let tagged = try XCTUnwrap( + TargetRasterLoader.deviceTagged(source)) + XCTAssertEqual(tagged.colorSpace?.model, .cmyk) + XCTAssertEqual(tagged.bitsPerComponent, 8) + XCTAssertEqual( + tagged.dataProvider?.data as? Data, + source.dataProvider?.data as? Data) + } + + func testDeviceTaggedGray8() throws { + let source = try XCTUnwrap(TargetTestFixtures.makeImage( + px: CGSize(width: 16, height: 16), + components: 1, bitsPerComponent: 8)) + let tagged = try XCTUnwrap( + TargetRasterLoader.deviceTagged(source)) + XCTAssertEqual(tagged.colorSpace?.model, .monochrome) + XCTAssertEqual(tagged.bitsPerComponent, 8) + XCTAssertEqual( + tagged.dataProvider?.data as? Data, + source.dataProvider?.data as? Data) + } + + func testDeviceTaggedRGB16KeepsBitDepth() throws { + let source = try XCTUnwrap(TargetTestFixtures.makeImage( + px: CGSize(width: 16, height: 16), + components: 3, bitsPerComponent: 16)) + let tagged = try XCTUnwrap( + TargetRasterLoader.deviceTagged(source)) + XCTAssertEqual(tagged.colorSpace?.model, .rgb) + XCTAssertEqual(tagged.bitsPerComponent, 16) + XCTAssertEqual( + tagged.dataProvider?.data as? Data, + source.dataProvider?.data as? Data) + } + + /// The loaded raster reuses the decoder's provider verbatim — byte + /// equality is the no-resample guarantee (R5). + func testLoadedRasterSharesDecodedBytes() throws { + let url = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 32, height: 24), dpi: 300, + in: tempRoot) + let raster = try load(url) + let decoded = try decoded(url) + XCTAssertEqual( + raster.cgImage.dataProvider?.data as? Data, + decoded.dataProvider?.data as? Data) + XCTAssertEqual(raster.cgImage.colorSpace?.model, .rgb) + } + + // MARK: - Rejections + + func testAlphaTIFFThrowsUnsupportedLayout() throws { + let url = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 16, height: 16), dpi: 300, + components: 3, alpha: true, in: tempRoot) + XCTAssertThrowsError(try load(url)) { error in + guard case .unsupportedLayout(_, _, _, let hasAlpha) = + error as? TargetRasterError + else { + return XCTFail("expected unsupportedLayout, got \(error)") + } + XCTAssertTrue(hasAlpha) + } + } + + func testDeviceTaggedRejectsFloat() throws { + var info = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) + info.insert(.floatComponents) + info.insert(.byteOrder32Little) + var bytes = [Float](repeating: 0.5, count: 4 * 4 * 3) + let floatImage = bytes.withUnsafeMutableBytes { buffer -> CGImage? in + CGImage( + width: 4, height: 4, + bitsPerComponent: 32, bitsPerPixel: 96, + bytesPerRow: 4 * 3 * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: info, + provider: CGDataProvider(data: Data(buffer) as CFData)!, + decode: nil, shouldInterpolate: false, + intent: .defaultIntent) + } + bytes = [] + let image = try XCTUnwrap(floatImage) + XCTAssertNil(TargetRasterLoader.deviceTagged(image)) + } + + func testDeviceTaggedRejectsIndexed() throws { + var table = [UInt8](repeating: 0, count: 256 * 3) + let indexed = try XCTUnwrap(CGColorSpace( + indexedBaseSpace: CGColorSpaceCreateDeviceRGB(), + last: 255, colorTable: &table)) + let image = try XCTUnwrap(CGImage( + width: 8, height: 8, + bitsPerComponent: 8, bitsPerPixel: 8, + bytesPerRow: 8, + space: indexed, + bitmapInfo: CGBitmapInfo(), + provider: CGDataProvider( + data: Data(repeating: 7, count: 64) as CFData)!, + decode: nil, shouldInterpolate: false, + intent: .defaultIntent)) + XCTAssertNil(TargetRasterLoader.deviceTagged(image)) + } + + /// No 2-component `CGImage` is fabricatable through public API — + /// the rejection lives in `deviceSpace(for:)`, asserted here. + func testDeviceSpaceRejectsTwoAndFiveComponents() { + XCTAssertNil(TargetRasterLoader.deviceSpace(for: 2)) + XCTAssertNil(TargetRasterLoader.deviceSpace(for: 0)) + XCTAssertNil(TargetRasterLoader.deviceSpace(for: 5)) + XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 1)) + XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 3)) + XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 4)) + } + + // MARK: - Manifest drift (D9) + + func testManifestDriftWhenMismatch() throws { + let url = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 2480, height: 3508), dpi: 300, + in: tempRoot) + let raster = try TargetRasterLoader.load( + tiff: url, expectedWidthMm: 200, expectedHeightMm: 297) + XCTAssertNotNil(raster.manifestDrift) + } + + func testManifestDriftNilWhenMatching() throws { + let url = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 2480, height: 3508), dpi: 300, + in: tempRoot) + // 2480 px @300 dpi = 209.97 mm — inside the 0.5 mm band. + let raster = try TargetRasterLoader.load( + tiff: url, expectedWidthMm: 210, expectedHeightMm: 297) + XCTAssertNil(raster.manifestDrift) + } +} -- 2.39.5 From 4f3caf335ddd20de884505d7ece29b40b3b4176f Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 17 Sep 2026 16:30:27 +0100 Subject: [PATCH 05/14] feat(print): native NSPrintOperation spooler, eradicate lp (#201 Phase 4) Replace the LpArgs argv builder with the deterministic TicketWriteResolver (locked 1-10 write order, Stage 2 overrides win unconditionally, no raw, nil overrides emit no write) and spool via a headless NSPrintOperation over TargetPageCanvasView instead of the CUPS lp binary. RecordingTargetSpooler (DEBUG) logs deterministic request lines to ICCERY_TEST_SPOOL_LOG for tests. Single spool job toggle switches printAllPages between per-page requests and one multi-page job. CupsService keeps lpstat/lpoptions enumeration but loses printTarget; ProcessID.lp* renamed cupsLp*; LpArgs, its tests and the lp fixture deleted. --- .../Print/ColorMatchingAttempts.swift | 19 ++ .../ICCeryCore/Print/CupsService.swift | 38 +-- .../Sources/ICCeryCore/Print/LpArgs.swift | 136 -------- .../ICCeryCore/Print/PrinterModels.swift | 18 +- .../ICCeryCore/Process/ProcessID.swift | 8 +- Sources/ICCery/AppEnvironment.swift | 8 +- .../ICCery/Print/ColorSyncSuppressor.swift | 55 ++- .../ICCery/Print/NativeTargetSpooler.swift | 317 ++++++++++++++++++ Sources/ICCery/Print/PrintPanelService.swift | 1 + .../ICCery/Print/PrintSessionViewModel.swift | 133 ++++++-- .../ICCery/Print/TicketWriteResolver.swift | 127 +++++++ Sources/ICCery/Stage2View.swift | 59 ++-- .../CupsOptionsFilterTests.swift | 3 +- Tests/ICCeryCoreTests/LpArgsTests.swift | 173 ---------- .../PrintSessionViewModelTests.swift | 81 +++-- .../TicketWriteResolverTests.swift | 196 +++++++++++ Tests/ICCeryUITests/Fixtures/bin/lp | 14 - Tests/ICCeryUITests/Fixtures/bin/printtarg | 17 +- .../Milestone11PrintSettingsUITests.swift | 82 ++++- Tests/ICCeryUITests/Milestone3UITests.swift | 73 ++-- 20 files changed, 1064 insertions(+), 494 deletions(-) delete mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift create mode 100644 Sources/ICCery/Print/NativeTargetSpooler.swift create mode 100644 Sources/ICCery/Print/TicketWriteResolver.swift delete mode 100644 Tests/ICCeryCoreTests/LpArgsTests.swift create mode 100644 Tests/ICCeryCoreTests/TicketWriteResolverTests.swift delete mode 100755 Tests/ICCeryUITests/Fixtures/bin/lp diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift index 5e535a9..267620a 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift @@ -46,4 +46,23 @@ public enum ColorMatchingAttempts { "AP_ColorMatchingMode", "AP.ColorMatchingMode", ] + + /// The Quartz/`NSPrintOperation` colour-matching vocabulary + /// (docs/14 §7). With the `lp` path removed there is a single + /// spool path and it carries **both** dictionaries — these keys + /// are written alongside the AP_* pair (#201 D2). + /// + /// `PMColorMatchingMode=APCustomColorMatching` plus an empty + /// `PMCustomColorMatchingProfile` tell Quartz the application + /// supplies device colour; the `com.apple.print.PrintSettings.*` + /// legacy spelling covers drivers that read the flattened + /// dictionary. + public static let quartzModeKey = "PMColorMatchingMode" + public static let quartzCustomMatching = "APCustomColorMatching" + public static let quartzProfileKey = "PMCustomColorMatchingProfile" + public static let quartzLegacyModeKey = + "com.apple.print.PrintSettings.PMColorMatchingMode" + /// The nested sub-dictionary inside `NSPrintInfo.dictionary()` the + /// colour keys are mirrored into. + public static let quartzNestedDictKey = "com.apple.print.printSettings" } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift index b70b52f..3a82483 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift @@ -3,7 +3,6 @@ import Foundation /// Errors from CUPS tool invocations. public enum CupsError: LocalizedError, Equatable { case toolFailed(tool: String, code: Int32, stderr: String) - case tiffMissing(String) case noPrinterSelected public var errorDescription: String? { @@ -13,8 +12,6 @@ public enum CupsError: LocalizedError, Equatable { return detail.isEmpty ? "\(tool) failed with exit code \(code)" : "\(tool) failed (\(code)): \(detail)" - case .tiffMissing(let path): - return "Target TIFF does not exist: \(path)" case .noPrinterSelected: return "No printer selected." } @@ -27,9 +24,11 @@ public enum CupsError: LocalizedError, Equatable { /// `ProcessManager.runCaptured` so spawns are logged, get killAll /// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are /// injectable so tests use fixture scripts and never touch real CUPS. +/// Target spooling is **not** here — it is the app-target +/// `NativeTargetSpooler` (`NSPrintOperation`, #201 D1/D4). public struct CupsService: Sendable { public let processManager: ProcessManager - /// Directory containing `lpstat`/`lpoptions`/`lp` — `/usr/bin` in + /// Directory containing `lpstat`/`lpoptions` — `/usr/bin` in /// production, a fixture dir under test. public let binaryDir: URL /// `/etc/cups/ppd` in production. @@ -53,11 +52,11 @@ public struct CupsService: Sendable { // lpstat exits non-zero when no destinations exist — an empty // queue list is a valid result, not a failure (issue 12). let destinationsOut = try await run( - "lpstat", ["-e"], id: ProcessID.lpstat("e"), tolerateFailure: true) + "lpstat", ["-e"], id: ProcessID.cupsLpstat("e"), tolerateFailure: true) let statusOut = try await run( - "lpstat", ["-p"], id: ProcessID.lpstat("p"), tolerateFailure: true) + "lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true) let defaultOut = try await run( - "lpstat", ["-d"], id: ProcessID.lpstat("d"), tolerateFailure: true) + "lpstat", ["-d"], id: ProcessID.cupsLpstat("d"), tolerateFailure: true) let names = CupsParsers.lpstatDestinations(destinationsOut.stdout) let statuses = CupsParsers.lpstatStatuses(statusOut.stdout) @@ -80,7 +79,7 @@ public struct CupsService: Sendable { /// display name, docs/11 §binding). public func displayName(for queue: String) async throws -> String? { let result = try await run( - "lpoptions", ["-p", queue], id: ProcessID.lpoptions(queue)) + "lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue)) return CupsParsers.lpoptionsDisplayName(result.stdout) } @@ -90,7 +89,7 @@ public struct CupsService: Sendable { /// to media-key and colour-bypass detection (docs/11 layer ④). public func optionListings(for queue: String) async throws -> [CupsOptionListing] { let result = try await run( - "lpoptions", ["-p", queue, "-l"], id: ProcessID.lpoptions("\(queue)-l")) + "lpoptions", ["-p", queue, "-l"], id: ProcessID.cupsLpoptions("\(queue)-l")) return CupsParsers.lpoptionsList(result.stdout) } @@ -162,27 +161,6 @@ public struct CupsService: Sendable { Set(try await optionListings(for: queue).map(\.key)) } - // MARK: - Spool (issue 15) - - /// `lp -d … ` — spool one target page unmanaged. - /// Never uses `-o raw` (#92). `page` disambiguates the process id - /// when several pages are spooled in sequence. - public func printTarget( - queue: String, - tiffPath: String, - options: PrintOptions, - page: Int = 0 - ) async throws { - guard FileManager.default.fileExists(atPath: tiffPath) else { - throw CupsError.tiffMissing(tiffPath) - } - let optionKeys = (try? await self.optionKeys(for: queue)) ?? [] - let argv = try LpArgs.build( - queue: queue, tiffPath: tiffPath, - options: options, optionKeys: optionKeys) - try await run("lp", argv, id: ProcessID.lp(queue, page: page)) - } - // MARK: - PPD private func loadPPD(for queue: String) -> String? { diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift deleted file mode 100644 index 0cc354c..0000000 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift +++ /dev/null @@ -1,136 +0,0 @@ -import Foundation - -/// Errors from `buildLpArgs`. -public enum LpArgsError: LocalizedError, Equatable { - case unsanitisedOption(String) - - public var errorDescription: String? { - switch self { - case .unsanitisedOption(let option): - return "Captured CUPS option contains unsafe characters: \(option)" - } - } -} - -/// `lp` argv builder — issue 15, docs/11 `build_lp_args`. -/// -/// ``` -/// lp -d -t "ICCery Target - " -/// -o AP_ColorMatchingMode=AP_ApplicationColorMatching -/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching -/// -/// -/// (#183) -/// -/// -/// -/// -/// ``` -/// -/// - **Never `-o raw`** — `raw` skips the raster filter that honours -/// `AP_ApplicationColorMatching` (#92). -/// - Captured options **win** over explicit fields: any key already -/// present (case-insensitive) suppresses the derived `-o`. -/// - Captured keys/values are sanitised — `;`, newlines, or shell -/// metacharacters throw `unsanitisedOption`; args are passed as a -/// `Process` argv array, never through a shell. -/// - The TIFF path is always the **last** argument. -public enum LpArgs { - - /// `options` = the captured `PrintOptions`; `optionKeys` = the - /// queue's `lpoptions -l` key set (for media-key/bypass detection). - public static func build( - queue: String, - tiffPath: String, - options: PrintOptions, - optionKeys: Set - ) throws -> [String] { - var argv: [String] = [ - "-d", queue, - "-t", "ICCery Target - \((tiffPath as NSString).lastPathComponent)", - "-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching", - "-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching", - ] - var addedKeys: Set = [ - "ap_colormatchingmode", "ap.colormatchingmode", - ] - - // Captured CUPS options — sanitised, lowercased-key dedup. - if let captured = options.cupsOptions, !captured.isEmpty { - // Newlines can't survive the tokeniser — check the raw - // string so embedded line breaks are still rejected. - if captured.contains("\n") || captured.contains("\r") { - throw LpArgsError.unsanitisedOption(captured) - } - for pair in CupsParsers.lpoptions(captured) { - try sanitize(pair.key, pair.value) - let lowered = pair.key.lowercased() - // Defence in depth: never let a captured `raw` reach - // argv — `-o raw` skips the raster filter that honours - // AP_ApplicationColorMatching (#92). - if lowered == "raw" { continue } - guard !addedKeys.contains(lowered) else { continue } - addedKeys.insert(lowered) - argv += ["-o", "\(pair.key)=\(pair.value)"] - } - } - - // Media type — only when the captured options didn't carry one. - if let mediaType = options.mediaType, - let mediaKey = CupsParsers.detectMediaTypeKey(optionKeys: optionKeys), - !addedKeys.contains(mediaKey.lowercased()) { - addedKeys.insert(mediaKey.lowercased()) - argv += ["-o", "\(mediaKey)=\(mediaType)"] - } - - // Print quality — after media, before the driver bypass; the - // detected queue key is skipped when already captured (#183). - if let quality = options.quality, - let qualityKey = CupsParsers.detectQualityKey(optionKeys: optionKeys), - !addedKeys.contains(qualityKey.lowercased()) { - addedKeys.insert(qualityKey.lowercased()) - argv += ["-o", "\(qualityKey)=\(quality)"] - } - - // Driver colour bypass — when no bypass key was captured. NOT - // gated on ppdUncorrectedPassthrough (macOS always bypasses). - let capturedKeys = Set( - CupsParsers.lpoptions(options.cupsOptions ?? "") - .map { $0.key }) - if capturedKeys.isDisjoint(with: CupsParsers.bypassKeys), - let bypass = CupsParsers.detectDriverColorBypass(optionKeys: optionKeys), - !addedKeys.contains(bypass.key.lowercased()) { - addedKeys.insert(bypass.key.lowercased()) - argv += ["-o", "\(bypass.key)=\(bypass.value)"] - } - - // Orientation — portrait=3, landscape=4. - if let orientation = options.orientation, - !addedKeys.contains("orientation-requested") { - let value = orientation == "landscape" ? "4" : "3" - addedKeys.insert("orientation-requested") - argv += ["-o", "orientation-requested=\(value)"] - } - - // PageSize — the printtarg layout page size. - if let paperSize = options.paperSize, !paperSize.isEmpty, - !addedKeys.contains("pagesize") { - argv += ["-o", "PageSize=\(paperSize)"] - } - - argv.append(tiffPath) - return argv - } - - /// Reject shell/metachar injection — args go to `Process` as an - /// argv array, but a hostile captured string must not smuggle a - /// second option or command. - static func sanitize(_ key: String, _ value: String) throws { - let forbidden = CharacterSet(charactersIn: ";\n\r`|$&<>\\\"'") - if key.rangeOfCharacter(from: forbidden) != nil - || value.rangeOfCharacter(from: forbidden) != nil - || key.isEmpty { - throw LpArgsError.unsanitisedOption("\(key)=\(value)") - } - } -} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift index fd5b614..3549610 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift @@ -111,24 +111,24 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable { } } -/// Options carried into `lp` (docs/10 §PrintOptions). On macOS -/// `paperSource` is ignored unless already present inside captured -/// `cupsOptions`; `ppdUncorrectedPassthrough` is stored (the panel sets -/// it on OK) but never gates the argv — macOS always bypasses driver -/// colour management. +/// The Stage 2 mirror — panel selections and the captured `k=v` +/// string (docs/10 §PrintOptions). Since #201 removed the `lp` path +/// these fields feed `TargetPrintOverrides` (Stage 2 always wins, D6) +/// and the mirror apply-back; the opaque vendor state now travels +/// inside the `PrintTicket`, not a flattened option string. public struct PrintOptions: Codable, Equatable, Sendable { public var paperSource: Int? /// `"portrait"` / `"landscape"` → `orientation-requested=3|4`. public var orientation: String? - /// Stage 2 paper token → `PageSize=` (skipped if captured, #183). + /// Stage 2 paper token → `PageSize=` ticket write + `PMPaper`. public var paperSize: String? public var mediaType: String? - /// Print-quality token → `-o =` (skipped if - /// captured, #183). + /// Print-quality token → `=` ticket write. public var quality: String? public var ppdUncorrectedPassthrough: Bool? /// Space-separated `key=value` captured from - /// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥). + /// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) — + /// the Stage 2 mirror only, never a spool payload. public var cupsOptions: String? public init( diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift index 8c2d562..7dd7e6c 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift @@ -18,8 +18,8 @@ public enum ProcessID { public static func applycal(_ stem: String) -> String { "applycal_\(stem)" } /// CUPS system tools (`/usr/bin/…`) — captured one-shots, not - /// streaming Argyll children. - public static func lpstat(_ mode: String) -> String { "lpstat_\(mode)" } - public static func lpoptions(_ queue: String) -> String { "lpoptions_\(queue)" } - public static func lp(_ queue: String, page: Int) -> String { "lp_\(queue)_\(page)" } + /// streaming Argyll children. Named `cups*` so no `lp*`-prefixed + /// identifier survives the target-print removal (#201). + public static func cupsLpstat(_ mode: String) -> String { "lpstat_\(mode)" } + public static func cupsLpoptions(_ queue: String) -> String { "lpoptions_\(queue)" } } diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift index a3ccbcd..c9b794c 100644 --- a/Sources/ICCery/AppEnvironment.swift +++ b/Sources/ICCery/AppEnvironment.swift @@ -106,12 +106,14 @@ enum UITestHooks { // MARK: - Print panel / CUPS stubs (issue 13/17) - /// Directory of mock `lp`/`lpstat`/`lpoptions` fixture scripts — + /// Directory of mock `lpstat`/`lpoptions` fixture scripts — /// `CupsService.binaryDir` under UI tests. static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") } - /// Path the mock `lp` script appends its argv to, for assertions. - static var lpArgvOutURL: URL? { url("ICCERY_TEST_LP_ARGV") } + /// Path the `RecordingTargetSpooler` appends resolved ticket + /// lines to, for assertions (#201 D8 — replaces the `lp` argv + /// log). + static var spoolLogURL: URL? { url("ICCERY_TEST_SPOOL_LOG") } /// Whether the `NSPrintPanel` should be stubbed under UI testing — /// separate from the stub's *result* so "cancel" (`nil`) does not diff --git a/Sources/ICCery/Print/ColorSyncSuppressor.swift b/Sources/ICCery/Print/ColorSyncSuppressor.swift index 28862b6..2764b43 100644 --- a/Sources/ICCery/Print/ColorSyncSuppressor.swift +++ b/Sources/ICCery/Print/ColorSyncSuppressor.swift @@ -84,6 +84,35 @@ struct ColorSyncSuppressor { return applied } + // MARK: - Layer ⑤′ Quartz vocabulary (#201 D2) + + /// With `lp` gone there is one print path and it carries **both** + /// dictionaries: write keys 3–5 of the resolver order into + /// `PMPrintSettings` so the PDE and the spool job see them + /// (docs/14 §7). Warn-only — a rejected key never aborts. + @discardableResult + func applyQuartzMode(to settings: PMPrintSettings) -> Int { + var applied = 0 + let pairs: [(key: String, value: String, locked: Bool)] = [ + (ColorMatchingAttempts.quartzModeKey, + ColorMatchingAttempts.quartzCustomMatching, true), + (ColorMatchingAttempts.quartzProfileKey, "", false), + (ColorMatchingAttempts.quartzLegacyModeKey, + ColorMatchingAttempts.quartzCustomMatching, false), + ] + for pair in pairs { + let status = PMPrintSettingsSetValue( + settings, pair.key as CFString, pair.value as CFString, + pair.locked) + if status == 0 { applied += 1 } + } + if applied == 0 { + log("ColorSync: PMPrintSettingsSetValue rejected the " + + "Quartz colour-matching keys") + } + return applied + } + // MARK: - Layer ④ driver bypass /// Pre-select the driver "no colour adjustment" option, unlocked — @@ -112,7 +141,11 @@ struct ColorSyncSuppressor { // MARK: - Layer ⑤ NSPrintInfo mirror /// Mirror the applied keys into `printSettings` so the PDE pick - /// sees them. + /// sees them. Also populates the nested + /// `com.apple.print.printSettings` sub-dictionary of + /// `printInfo.dictionary()` with the AP_* **and** Quartz keys — + /// the single remaining path carries both vocabularies (#201 D2, + /// docs/14 §7). func mirror( into printInfo: NSPrintInfo, driverBypass: (key: String, value: String)? @@ -121,9 +154,29 @@ struct ColorSyncSuppressor { for key in ColorMatchingAttempts.printSettingsKeys { settings[key as NSString] = ColorMatchingAttempts.applicationMatchingValue as NSString } + settings[ColorMatchingAttempts.quartzModeKey as NSString] = + ColorMatchingAttempts.quartzCustomMatching as NSString + settings[ColorMatchingAttempts.quartzProfileKey as NSString] = + "" as NSString + settings[ColorMatchingAttempts.quartzLegacyModeKey as NSString] = + ColorMatchingAttempts.quartzCustomMatching as NSString if let driverBypass { settings[driverBypass.key as NSString] = driverBypass.value as NSString } + + // Nested mirror — drivers that read the flattened dictionary. + let nestedKey = ColorMatchingAttempts.quartzNestedDictKey as NSString + let nested = (settings[nestedKey] as? NSMutableDictionary) + ?? NSMutableDictionary() + for key in ColorMatchingAttempts.printSettingsKeys { + nested[key] = ColorMatchingAttempts.applicationMatchingValue + } + nested[ColorMatchingAttempts.quartzModeKey] = + ColorMatchingAttempts.quartzCustomMatching + nested[ColorMatchingAttempts.quartzProfileKey] = "" + nested[ColorMatchingAttempts.quartzLegacyModeKey] = + ColorMatchingAttempts.quartzCustomMatching + settings[nestedKey] = nested } // MARK: - Layer ⑥ capture diff --git a/Sources/ICCery/Print/NativeTargetSpooler.swift b/Sources/ICCery/Print/NativeTargetSpooler.swift new file mode 100644 index 0000000..127904a --- /dev/null +++ b/Sources/ICCery/Print/NativeTargetSpooler.swift @@ -0,0 +1,317 @@ +import AppKit +import ApplicationServices +import ICCeryCore + +/// One page to draw — the TIFF plus its printtarg manifest size for +/// the drift check (#201 D9). +struct TargetPrintPage: Equatable { + let url: URL + let expectedWidthMm: Double? + let expectedHeightMm: Double? +} + +/// One spool job — a single-element `pages` in per-page mode, all +/// pages when `singleJobForAllPages` is on (#201 D5). +struct TargetPrintRequest { + let queue: String + let displayName: String? + let pages: [TargetPrintPage] + let jobTitle: String // "ICCery Target - " + /// nil → bind + PM defaults + suppression only (the ticket carries + /// the captured PDE state; without it the job is Stage 2 writes + /// over queue defaults). + let ticket: PrintTicket? + let overrides: TargetPrintOverrides + /// The queue's `lpoptions -l` key roster — feeds vendor media-key + /// and driver-bypass detection. + let optionKeys: Set + /// Test-only: `.save` to this URL instead of `.spool` (#201 D8). + var savePDFTo: URL? +} + +enum TargetSpoolError: LocalizedError, Equatable { + case noPages + case printerUnresolved(queue: String) + case operationFailed(queue: String, jobTitle: String) + + var errorDescription: String? { + switch self { + case .noPages: + return "Nothing to print." + case .printerUnresolved(let queue): + return "The print queue '\(queue)' could not be resolved." + case .operationFailed(_, let jobTitle): + return "The print operation for '\(jobTitle)' failed." + } + } +} + +@MainActor +protocol TargetSpooling { + /// Warn-only diagnostics (manifest drift, oversize page, rejected + /// key) are delivered here; the throw path is reserved for real + /// failures. + var diagnostics: ((Notice) -> Void)? { get set } + func spool(_ request: TargetPrintRequest) throws +} + +/// Production spooler — rehydrates the captured ticket, applies the +/// resolved Stage 2 writes, and draws each TIFF 1:1 through a silent +/// `NSPrintOperation` (#201, the S1–S14 trace in the M12 megaplan). +@MainActor +final class NativeTargetSpooler: TargetSpooling { + var suppressor = ColorSyncSuppressor() + var diagnostics: ((Notice) -> Void)? + var log: (LogLevel, String) -> Void = { AppLogger.shared.log($0, $1) } + + func spool(_ request: TargetPrintRequest) throws { + guard !request.pages.isEmpty else { throw TargetSpoolError.noPages } + + // The PM printer handle is held for the whole operation and + // released on every path (S14). + let printer = PMTicketBridge.makePrinter(queue: request.queue) + defer { if let printer { PMTicketBridge.release(printer) } } + + let (printInfo, _) = try makePrintInfo(for: request, boundTo: printer) + + // S10 — decode every page 1:1. Warn-only findings ride the + // `diagnostics` seam, never the throw path (D9). + var rasters: [TargetPageRaster] = [] + rasters.reserveCapacity(request.pages.count) + for page in request.pages { + let raster = try TargetRasterLoader.load( + tiff: page.url, + expectedWidthMm: page.expectedWidthMm, + expectedHeightMm: page.expectedHeightMm) + if let drift = raster.manifestDrift { + diagnostics?(Notice(kind: .warning, text: drift)) + } + if raster.pointSize.width > printInfo.paperSize.width + 0.5 + || raster.pointSize.height + > printInfo.paperSize.height + 0.5 { + diagnostics?(Notice( + kind: .warning, + text: "\(page.url.lastPathComponent) exceeds the " + + "paper size — it will be clipped, not scaled.")) + } + rasters.append(raster) + } + + // S11–S13 — headless canvas + silent operation (D7). + let canvas = TargetPageCanvasView( + pages: rasters, paperSize: printInfo.paperSize) + let operation = NSPrintOperation(view: canvas, printInfo: printInfo) + operation.showsPrintPanel = false + operation.showsProgressPanel = false + operation.canSpawnSeparateThread = false + operation.jobTitle = request.jobTitle + guard operation.run() else { + throw TargetSpoolError.operationFailed( + queue: request.queue, jobTitle: request.jobTitle) + } + } + + /// S1–S9 — a fully configured `NSPrintInfo` plus the resolved + /// write list. Extracted for the PDF harness + unit tests; + /// `spool(_:)` is the production entry point (it keeps the PM + /// printer handle alive through `run()`). + func makePrintInfo( + for request: TargetPrintRequest + ) throws -> (NSPrintInfo, ResolvedTicketWrites) { + let printer = PMTicketBridge.makePrinter(queue: request.queue) + defer { if let printer { PMTicketBridge.release(printer) } } + return try makePrintInfo(for: request, boundTo: printer) + } + + private func makePrintInfo( + for request: TargetPrintRequest, + boundTo printer: PMPrinter? + ) throws -> (NSPrintInfo, ResolvedTicketWrites) { + // S1 + let printInfo = NSPrintInfo() + + // S2 — Cocoa-level printer first: it runs before binding and + // ticket restore, so a printer-driven settings reset cannot + // clobber the ticket (R9). Best effort; PM binding is + // authoritative. + var resolvedNSPrinter = false + if let nsPrinter = NSPrinter(name: request.queue) + ?? request.displayName.flatMap({ NSPrinter(name: $0) }) { + printInfo.printer = nsPrinter + resolvedNSPrinter = true + } + + // S3 — PM binding + session defaults. A failed bind degrades + // to the NSPrinter fallback; with neither, the job would land + // on the default queue — a wrong answer, so it throws. + var boundViaPM = false + if let printer { + do { + try PMTicketBridge.bind(printer: printer, to: printInfo) + boundViaPM = true + } catch { + log(.warn, "Spool: PM bind failed for " + + "'\(request.queue)' — continuing on the " + + "NSPrinter fallback") + } + } + guard boundViaPM || resolvedNSPrinter else { + throw TargetSpoolError.printerUnresolved(queue: request.queue) + } + + // S4 — rehydrate the captured ticket. `restore` itself refuses + // a cross-queue replay (R3). + if let ticket = request.ticket { + try PMTicketBridge.restore(ticket, into: printInfo) + } + + // S5 — Stage 2 writes (AP_* + Quartz vocabularies + media / + // quality / bypass, D2/D6) onto the live PMPrintSettings. + let resolved = TicketWriteResolver.resolve( + overrides: request.overrides, optionKeys: request.optionKeys) + let settings = PMTicketBridge.settings(printInfo) + for write in resolved.writes { + // R4 — write only when the value differs, and log old→new + // so a vendor companion-key desync is diagnosable. + let current = PMTicketBridge.stringValue( + forKey: write.key, in: settings) + guard current != write.value else { continue } + if let current { + log(.info, "Spool: \(write.key): '\(current)' " + + "→ '\(write.value)'") + } + PMTicketBridge.setValue( + write.value, forKey: write.key, locked: write.locked, + in: settings, context: "Spool") + } + printInfo.updateFromPMPrintSettings() + mirror(resolved.writes, into: printInfo) + + // S6 — paper override: `PMPaper` match on the bound printer; + // `Custom.x` tokens (already points) set the Cocoa paper + // size directly. + if let token = resolved.paperToken { + if let printer, boundViaPM { + PMTicketBridge.applyPaper( + token: token, printer: printer, + session: PMTicketBridge.session(printInfo), + printInfo: printInfo) + } else if let custom = PMTicketBridge + .customPaperDimensions(from: token) { + printInfo.paperSize = NSSize( + width: custom.width, height: custom.height) + } + } + + // S7 — orientation on the Cocoa page format; the + // `orientation-requested` write above is the driver half. + if let orientation = resolved.orientation { + printInfo.orientation = orientation == "landscape" + ? .landscape : .portrait + } + + // S8 — private SPI on the live session; only meaningful with + // a bound printer (same constraint as the panel path). + if boundViaPM { + suppressor.applySPIMode(to: PMTicketBridge.session(printInfo)) + } + + // S9 — Cocoa geometry: margins 0, `.clip` pagination, 1:1 + // scale, no centring; `.spool`, or `.save` + jobSavingURL + // under the PDF harness (D8/D9). + printInfo.topMargin = 0 + printInfo.bottomMargin = 0 + printInfo.leftMargin = 0 + printInfo.rightMargin = 0 + printInfo.horizontalPagination = .clip + printInfo.verticalPagination = .clip + printInfo.scalingFactor = 1.0 + printInfo.isHorizontallyCentered = false + printInfo.isVerticallyCentered = false + if let saveURL = request.savePDFTo { + printInfo.jobDisposition = .save + printInfo.dictionary()[ + NSPrintInfo.AttributeKey.jobSavingURL] = saveURL + } else { + printInfo.jobDisposition = .spool + } + + return (printInfo, resolved) + } + + /// The resolved writes also land in `NSPrintInfo.printSettings` so + /// the driver sees them through Cocoa — the + /// `com.apple.print.printSettings` sub-dictionary carries the + /// colour keys (D2 / docs/14 §7). + private func mirror( + _ writes: [TicketWrite], into printInfo: NSPrintInfo + ) { + let settings = printInfo.printSettings + for write in writes { + settings[write.key as NSString] = write.value as NSString + } + let nestedKey = ColorMatchingAttempts.quartzNestedDictKey as NSString + let nested = (settings[nestedKey] as? NSMutableDictionary) + ?? NSMutableDictionary() + for write in writes where Self.isColourKey(write.key) { + nested[write.key] = write.value + } + settings[nestedKey] = nested + } + + private static func isColourKey(_ key: String) -> Bool { + ColorMatchingAttempts.printSettingsKeys.contains(key) + || key == ColorMatchingAttempts.quartzModeKey + || key == ColorMatchingAttempts.quartzProfileKey + || key == ColorMatchingAttempts.quartzLegacyModeKey + } +} + +#if DEBUG +/// UI-test seam (#201 D8). Runs the same `TicketWriteResolver` the +/// native spooler runs, appends one deterministic line per request to +/// `ICCERY_TEST_SPOOL_LOG`, and never touches the print system — so +/// UI tests need no queue, no driver and no PM binding. +/// +/// `ICCERY_TEST_SPOOL_FAIL=1` injects an `operationFailed` error for +/// the failure-notice tests. +@MainActor +final class RecordingTargetSpooler: TargetSpooling { + let logURL: URL + var diagnostics: ((Notice) -> Void)? + + init(logURL: URL) { + self.logURL = logURL + } + + /// `queue= title= page= pages= paper= + /// orientation= keys=` — keys sorted alphabetically + /// so assertions are order-free. + func spool(_ request: TargetPrintRequest) throws { + guard !request.pages.isEmpty else { throw TargetSpoolError.noPages } + if ProcessInfo.processInfo.environment["ICCERY_TEST_SPOOL_FAIL"] + == "1" { + throw TargetSpoolError.operationFailed( + queue: request.queue, jobTitle: request.jobTitle) + } + let resolved = TicketWriteResolver.resolve( + overrides: request.overrides, optionKeys: request.optionKeys) + let keys = resolved.writes + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + let line = "queue=\(request.queue)" + + " title=\(request.jobTitle)" + + " page=\(request.pages[0].url.lastPathComponent)" + + " pages=\(request.pages.count)" + + " paper=\(resolved.paperToken ?? "-")" + + " orientation=\(resolved.orientation ?? "-")" + + " keys=\(keys)\n" + // Atomic append — existing content + one line via the + // `.tmp`-then-rename convention (#213). + let existing = (try? String(contentsOf: logURL, encoding: .utf8)) + ?? "" + try AtomicFileWriter.write(existing + line, to: logURL) + } +} +#endif diff --git a/Sources/ICCery/Print/PrintPanelService.swift b/Sources/ICCery/Print/PrintPanelService.swift index 22ed51c..613fea0 100644 --- a/Sources/ICCery/Print/PrintPanelService.swift +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -147,6 +147,7 @@ struct PrintPanelService { 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) // ⑤ diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index 1c992d2..d6cb2cc 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -2,7 +2,8 @@ import Combine import Foundation import ICCeryCore -/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85). +/// CUPS queue selection, bound print panel, and native headless spool +/// (issues 12–15, 17 / #85, #201). @MainActor final class PrintSessionViewModel: ObservableObject { let wizard: WizardViewModel @@ -13,7 +14,15 @@ final class PrintSessionViewModel: ObservableObject { weak var workflow: TargetWorkflowViewModel? @Published var printers: [Printer] = [] - @Published var selectedPrinter = "" + @Published var selectedPrinter = "" { + didSet { + // Tickets are queue- and driver-version-specific — a stale + // ticket must never replay onto a re-selected queue (R3). + if selectedPrinter != oldValue { + capturedTickets[oldValue] = nil + } + } + } @Published var printerCaps = PrinterCapabilities() @Published var selectedTray: Int? @Published var selectedMediaType: String? @@ -28,11 +37,39 @@ final class PrintSessionViewModel: ObservableObject { @Published var capturedTickets: [String: PrintTicket] = [:] @Published var printNotice: Notice? @Published var isPrinting = false + /// Session-only job granularity (#201 D5): off → one job per page + /// (per-page notices + error attribution); on → a single job. + @Published var singleJobForAllPages = false + /// The spool backend — the `ICCERY_TEST_SPOOL_LOG` recording seam + /// under UI testing, `NSPrintOperation` otherwise (#201 D8). + /// `internal` so unit tests inject. + var spooler: TargetSpooling private var printTask: Task? init(wizard: WizardViewModel, environment: AppEnvironment) { self.wizard = wizard self.environment = environment + #if DEBUG + if UITestHooks.isEnabled, let logURL = UITestHooks.spoolLogURL { + spooler = RecordingTargetSpooler(logURL: logURL) + } else { + spooler = NativeTargetSpooler() + } + #else + spooler = NativeTargetSpooler() + #endif + attachDiagnostics() + } + + /// Warn-only spooler diagnostics (manifest drift, oversize page) + /// surface as the in-panel notice. Re-attached per request so an + /// injected spooler picks it up too — and so `spooler` needs no + /// `didSet` (mutating `diagnostics` through the existential would + /// re-fire the observer and recurse). + private func attachDiagnostics() { + spooler.diagnostics = { [weak self] notice in + self?.printNotice = notice + } } private var printerEnumTask: Task<[Printer]?, Never>? @@ -134,7 +171,8 @@ final class PrintSessionViewModel: ObservableObject { /// The CUPS `PageSize` token for the current Stage 2 pick — live /// `Custom.x` for the synthetic entry, else the capability - /// name. This is what `lp -o PageSize=` sees. + /// name. Resolved into the `PageSize` ticket write and the + /// `PMPaper` match on the native spool path (#201). var selectedPaperSizeToken: String? { guard let id = selectedPaperSize else { return nil } if id == 0 { return customPaperToken() } @@ -216,10 +254,31 @@ final class PrintSessionViewModel: ObservableObject { guard let self else { return } // `defer` cannot mutate isolated state under Swift 5.7 // (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113). + if singleJobForAllPages { + // D5 — one job carries every page; a failure names the + // job, not a page (R14). + do { + try await spoolAll(result) + printNotice = Notice( + kind: .info, + text: "Sent \(result.pages.count) page(s) in " + + "one job to \(selectedPrinter).", + autoHideAfter: nil + ) + } catch { + printNotice = Notice( + kind: .error, + text: "Print failed: \(error.localizedDescription)" + ) + } + isPrinting = false + self.printTask = nil + return + } var printed = 0 for page in result.pages { do { - try await spool(page, index: page.index) + try await spool(page) printed += 1 } catch { printNotice = Notice( @@ -249,7 +308,7 @@ final class PrintSessionViewModel: ObservableObject { let task = Task { @MainActor [weak self] in guard let self else { return } do { - try await spool(page, index: page.index) + try await spool(page) printNotice = Notice( kind: .info, text: "Sent \(page.page.filename) to \(selectedPrinter).", @@ -267,25 +326,55 @@ final class PrintSessionViewModel: ObservableObject { printTask = task } - private func spool(_ page: GalleryPage, index: Int) async throws { + private func spool(_ page: GalleryPage) async throws { + let request = try await makeRequest( + pages: [page], title: page.page.filename) + try spooler.spool(request) + wizard.printerName = selectedPrinter + } + + private func spoolAll(_ result: PrinttargResult) async throws { + let request = try await makeRequest( + pages: result.pages, + title: result.ti2URL.deletingPathExtension() + .lastPathComponent) + try spooler.spool(request) + wizard.printerName = selectedPrinter + } + + /// Assemble the deterministic spool request: the captured ticket + /// (when the panel produced one) plus the Stage 2 overrides and + /// the queue's option-key roster for vendor-key detection (#201). + /// The Stage 2 paper token feeds the `PageSize` write; + /// `workflow.pageSize` remains the printtarg layout input only + /// (#183). + private func makeRequest( + pages: [GalleryPage], title: String + ) async throws -> TargetPrintRequest { guard !selectedPrinter.isEmpty else { throw CupsError.noPrinterSelected } - // The Stage 2 paper token is what `lp -o PageSize=` sees; - // `workflow.pageSize` remains the printtarg layout input only - // (#183). - let options = PrintOptions( - orientation: printOrientation, - paperSize: selectedPaperSizeToken, - mediaType: selectedMediaType, - quality: selectedQuality, - ppdUncorrectedPassthrough: true, - cupsOptions: capturedCupsOptions[selectedPrinter]) - try await environment.cupsService.printTarget( - queue: selectedPrinter, - tiffPath: page.fileURL.path, - options: options, - page: index) - wizard.printerName = selectedPrinter + let queue = selectedPrinter + let optionKeys = (try? await environment.cupsService + .optionKeys(for: queue)) ?? [] + attachDiagnostics() + return TargetPrintRequest( + queue: queue, + displayName: printers.first { $0.name == queue }?.displayName, + pages: pages.map { + TargetPrintPage( + url: $0.fileURL, + expectedWidthMm: $0.page.widthMm, + expectedHeightMm: $0.page.heightMm) + }, + jobTitle: "ICCery Target - \(title)", + ticket: capturedTickets[queue], + overrides: TargetPrintOverrides( + paperSize: selectedPaperSizeToken, + mediaType: selectedMediaType, + qualityKey: printerCaps.qualityKey, + quality: selectedQuality, + orientation: printOrientation), + optionKeys: optionKeys) } } diff --git a/Sources/ICCery/Print/TicketWriteResolver.swift b/Sources/ICCery/Print/TicketWriteResolver.swift new file mode 100644 index 0000000..7af2b53 --- /dev/null +++ b/Sources/ICCery/Print/TicketWriteResolver.swift @@ -0,0 +1,127 @@ +import Foundation +import ICCeryCore + +/// Stage 2 inline overrides applied on top of a rehydrated ticket +/// (#201 AC3 / D6 — Stage 2 always wins). +struct TargetPrintOverrides: Equatable { + var paperSize: String? // CUPS `PageSize` token or `Custom.x` + var mediaType: String? + var qualityKey: String? // PrinterCapabilities.qualityKey + var quality: String? + var orientation: String? // "portrait" | "landscape" +} + +/// One resolved ticket write. +struct TicketWrite: Equatable { + let key: String + let value: String + let locked: Bool +} + +/// The fully-resolved write list for one job — a **pure value**, +/// computed with zero Core Printing calls so it is unit- and +/// UI-testable without a live queue. Replaces the old `lp` argv +/// builder (#201 D4/D8). +struct ResolvedTicketWrites: Equatable { + var writes: [TicketWrite] + var paperToken: String? + var orientation: String? +} + +enum TicketWriteResolver { + + /// Quartz vocabulary constants (docs/14 §7) live on + /// `ColorMatchingAttempts` next to the AP_* half — written + /// alongside because there is no `lp` path left (D2). + /// + /// Order is locked and is the contract the tests assert: + /// 1 `AP_ColorMatchingMode` = AP_ApplicationColorMatching (locked) + /// 2 `AP.ColorMatchingMode` = AP_ApplicationColorMatching (locked) + /// 3 `PMColorMatchingMode` = APCustomColorMatching (locked) + /// 4 `PMCustomColorMatchingProfile` = "" (unlocked) + /// 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 + /// + /// Invariants: `raw` can never appear (there is no `lp`); nothing + /// is suppressed because a captured key already exists (D6 — the + /// ticket carries the captured keys and Stage 2 writes land on + /// top unconditionally); an override with a `nil` value emits no + /// write. + static func resolve( + overrides: TargetPrintOverrides, + optionKeys: Set + ) -> ResolvedTicketWrites { + var writes: [TicketWrite] = [] + + // 1–2 — locked AP_* pair (layer ③). + for key in ColorMatchingAttempts.printSettingsKeys { + writes.append(TicketWrite( + key: key, + value: ColorMatchingAttempts.applicationMatchingValue, + locked: true)) + } + // 3–5 — Quartz/`NSPrintOperation` vocabulary (D2). + writes.append(TicketWrite( + key: ColorMatchingAttempts.quartzModeKey, + value: ColorMatchingAttempts.quartzCustomMatching, + locked: true)) + writes.append(TicketWrite( + key: ColorMatchingAttempts.quartzProfileKey, + value: "", + locked: false)) + writes.append(TicketWrite( + key: ColorMatchingAttempts.quartzLegacyModeKey, + value: ColorMatchingAttempts.quartzCustomMatching, + locked: false)) + + // 6 — the Stage 2 paper token. + var paperToken: String? + if let paperSize = overrides.paperSize, !paperSize.isEmpty { + paperToken = paperSize + writes.append(TicketWrite( + key: "PageSize", value: paperSize, locked: false)) + } + + // 7 — media type via the queue's detected vendor key. + if let mediaType = overrides.mediaType, + let mediaKey = CupsParsers.detectMediaTypeKey( + optionKeys: optionKeys) { + writes.append(TicketWrite( + key: mediaKey, value: mediaType, locked: false)) + } + + // 8 — print quality; the detected enumeration key travels in + // the overrides (`PrinterCapabilities.qualityKey`, #183). + if let qualityKey = overrides.qualityKey, + let quality = overrides.quality { + writes.append(TicketWrite( + key: qualityKey, value: quality, locked: false)) + } + + // 9 — driver "no colour adjustment" bypass. Never gated on + // `ppdUncorrectedPassthrough` — macOS always bypasses. + if let bypass = CupsParsers.detectDriverColorBypass( + optionKeys: optionKeys) { + writes.append(TicketWrite( + key: bypass.key, value: bypass.value, locked: false)) + } + + // 10 — orientation, CUPS IPP codes portrait=3 / landscape=4. + if let orientation = overrides.orientation { + writes.append(TicketWrite( + key: "orientation-requested", + value: orientation == "landscape" ? "4" : "3", + locked: false)) + } + + return ResolvedTicketWrites( + writes: writes.filter { $0.key.lowercased() != "raw" }, + paperToken: paperToken, + orientation: overrides.orientation) + } +} diff --git a/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift index 115015f..3461d06 100644 --- a/Sources/ICCery/Stage2View.swift +++ b/Sources/ICCery/Stage2View.swift @@ -218,7 +218,7 @@ struct Stage2View: View { } } - // MARK: - Raw print panel (#rawPrintPanel) — unmanaged lp path + // MARK: - Raw print panel (#rawPrintPanel) — native spool path (#201) private var printPanel: some View { VStack(alignment: .leading, spacing: 10) { @@ -267,6 +267,11 @@ struct Stage2View: View { .clipShape(Capsule()) .accessibilityIdentifier("printerStatusBadge") } + // Placeholder — Phase 5 (AirPrint detection, M12) + // replaces this with the live unmanaged-colour + // warning badge for AirPrint queues. + EmptyView() + .accessibilityIdentifier("airPrintWarningBadge") Button(action: workflow.print.refreshPrinters) { Image(systemName: "arrow.clockwise") } @@ -325,26 +330,7 @@ struct Stage2View: View { // Stage 1 owns the custom dimensions — the caption lives // inside `paperSizeGroup` (#183). - HStack(spacing: 8) { - Button(action: { - if let result = workflow.printtargResult { - workflow.print.printAllPages(from: result) - } - }) { - Label(workflow.print.isPrinting ? "Printing…" : "Print All", - systemImage: "printer") - } - .controlSize(.large) - .disabled(workflow.print.isPrinting - || workflow.printtargResult == nil - || workflow.print.selectedPrinter.isEmpty) - .accessibilityIdentifier("btnPrintAll") - Spacer() - Button("Advance to Stage 3") { workflow.advanceToStage3() } - .accessibilityIdentifier("btnAdvanceToStage3") - .disabled(workflow.printtargResult == nil - || !workflow.wizard.isUnlocked(.measure)) - } + printAllRow } .padding(12) .background(Theme.panel) @@ -361,6 +347,37 @@ struct Stage2View: View { } } + /// Print All + single-job granularity (#201 D5) + advance. + /// Extracted so every ViewBuilder body stays ≤10 children (R13 — + /// Xcode 14.2 has no `buildPartialBlock`). + private var printAllRow: some View { + HStack(spacing: 8) { + Button(action: { + if let result = workflow.printtargResult { + workflow.print.printAllPages(from: result) + } + }) { + Label(workflow.print.isPrinting ? "Printing…" : "Print All", + systemImage: "printer") + } + .controlSize(.large) + .disabled(workflow.print.isPrinting + || workflow.printtargResult == nil + || workflow.print.selectedPrinter.isEmpty) + .accessibilityIdentifier("btnPrintAll") + Toggle("Single spool job", + isOn: $workflow.print.singleJobForAllPages) + .help("Send all pages as one print job instead of " + + "one job per page") + .accessibilityIdentifier("chkSingleSpoolJob") + Spacer() + Button("Advance to Stage 3") { workflow.advanceToStage3() } + .accessibilityIdentifier("btnAdvanceToStage3") + .disabled(workflow.printtargResult == nil + || !workflow.wizard.isUnlocked(.measure)) + } + } + /// Paper picker + custom-size caption under the `paperSizeGroup` /// container (#183). `caps.paperSizes` plus the synthetic custom /// entry (`id: 0`, shown as `Custom (W×H mm)`). diff --git a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift index 3acb4e2..a14977b 100644 --- a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift +++ b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift @@ -25,8 +25,7 @@ final class CupsOptionsFilterTests: XCTestCase { } /// #180 — a captured `EPIJ_Qual` (and the other canonical quality - /// keys) survives the filter so it wins over the Stage 2 explicit - /// quality in `LpArgs`. + /// keys) survives the filter so it reaches the Stage 2 mirror. func testKeepsQualityKeys() { let raw = "EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 " + "cupsPrintQuality=High Quality=Best " diff --git a/Tests/ICCeryCoreTests/LpArgsTests.swift b/Tests/ICCeryCoreTests/LpArgsTests.swift deleted file mode 100644 index b8bddd5..0000000 --- a/Tests/ICCeryCoreTests/LpArgsTests.swift +++ /dev/null @@ -1,173 +0,0 @@ -import XCTest -import Foundation -@testable import ICCeryCore - -/// Issue 15 — `lp` argv goldens (docs/11 `build_lp_args`). -/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover -/// flag order, captured-option precedence, and sanitisation. -final class LpArgsTests: XCTestCase { - - private let tiff = "/tmp/work/target_001.tif" - private let queue = "EPSON_XP_55_Series" - - private func build( - options: PrintOptions = PrintOptions(), - optionKeys: Set = [] - ) throws -> [String] { - try LpArgs.build( - queue: queue, tiffPath: tiff, - options: options, optionKeys: optionKeys) - } - - func testHeader() throws { - let argv = try build() - XCTAssertEqual(Array(argv[0...1]), ["-d", queue]) - XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"]) - XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"]) - XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"]) - XCTAssertEqual(argv.last, tiff) - XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" }) - } - - func testNeverRaw() throws { - let argv = try build(options: PrintOptions( - cupsOptions: "raw=true MediaType=Photo")) - for (i, arg) in argv.enumerated() where arg == "-o" { - XCTAssertNotEqual(argv[i + 1], "raw") - XCTAssertNotEqual(argv[i + 1], "raw=true") - } - XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") }) - XCTAssertTrue(argv.contains("MediaType=Photo")) - } - - func testCapturedReplay() throws { - let argv = try build(options: PrintOptions( - cupsOptions: "InputSlot=Rear MediaType=Photo")) - let rear = argv.firstIndex(of: "InputSlot=Rear")! - let apFirst = argv.firstIndex(of: - "AP_ColorMatchingMode=AP_ApplicationColorMatching")! - XCTAssertTrue(rear > apFirst) - } - - func testCapturedWinsMedia() throws { - let argv = try build( - options: PrintOptions( - mediaType: "Plain", - cupsOptions: "MediaType=Glossy"), - optionKeys: ["MediaType"]) - XCTAssertTrue(argv.contains("MediaType=Glossy")) - XCTAssertFalse(argv.contains("MediaType=Plain")) - } - - func testMediaDerived() throws { - let argv = try build( - options: PrintOptions(mediaType: "SemiGloss"), - optionKeys: ["CNIJMediaType", "MediaType"]) - // CNIJMediaType wins over MediaType in detection order. - XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss")) - XCTAssertFalse(argv.contains("MediaType=SemiGloss")) - } - - func testBypassRules() throws { - let withBypass = try build( - optionKeys: ["EPIJ_CMat"]) - XCTAssertTrue(withBypass.contains("EPIJ_CMat=3")) - - let captured = try build( - options: PrintOptions(cupsOptions: "EPIJ_CMat=1"), - optionKeys: ["EPIJ_CMat"]) - // Captured value kept, detection not re-applied. - XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"]) - } - - func testOrientation() throws { - XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait")) - .contains("orientation-requested=3")) - XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape")) - .contains("orientation-requested=4")) - let capturedOrients = try build(options: PrintOptions( - orientation: "landscape", - cupsOptions: "orientation-requested=5")) - XCTAssertFalse(capturedOrients.contains("orientation-requested=4")) - XCTAssertTrue(capturedOrients.contains("orientation-requested=5")) - } - - func testPageSize() throws { - XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4")) - .contains("PageSize=A4")) - let capturedSize = try build(options: PrintOptions( - paperSize: "A4", cupsOptions: "PageSize=Letter")) - XCTAssertFalse(capturedSize.contains("PageSize=A4")) - XCTAssertTrue(capturedSize.contains("PageSize=Letter")) - } - - // MARK: - #183 print quality - - func testQualityDerived() throws { - let argv = try build( - options: PrintOptions( - orientation: "portrait", mediaType: "Photo", quality: "305"), - optionKeys: ["EPIJ_Qual", "MediaType"]) - XCTAssertTrue(argv.contains("EPIJ_Qual=305")) - // Emit order: after the media option, before orientation. - let media = argv.firstIndex(of: "MediaType=Photo")! - let quality = argv.firstIndex(of: "EPIJ_Qual=305")! - let orient = argv.firstIndex(of: "orientation-requested=3")! - XCTAssertTrue(media < quality && quality < orient) - } - - func testQualityDetectedKey() throws { - // The detected queue key is used, not a hardcoded one. - let argv = try build( - options: PrintOptions(quality: "High"), - optionKeys: ["cupsPrintQuality"]) - XCTAssertTrue(argv.contains("cupsPrintQuality=High")) - } - - func testCapturedWinsQuality() throws { - let argv = try build( - options: PrintOptions( - quality: "303", - cupsOptions: "EPIJ_Qual=308"), - optionKeys: ["EPIJ_Qual"]) - XCTAssertTrue(argv.contains("EPIJ_Qual=308")) - XCTAssertFalse(argv.contains("EPIJ_Qual=303")) - } - - func testCapturedQualityCaseInsensitiveDedup() throws { - let argv = try build( - options: PrintOptions( - quality: "303", - cupsOptions: "epij_qual=308"), - optionKeys: ["EPIJ_Qual"]) - XCTAssertFalse(argv.contains("EPIJ_Qual=303")) - XCTAssertTrue(argv.contains("epij_qual=308")) - } - - func testQualityNilNoEmit() throws { - let argv = try build( - options: PrintOptions(mediaType: "Photo"), - optionKeys: ["EPIJ_Qual", "MediaType"]) - XCTAssertFalse(argv.contains { $0.hasPrefix("EPIJ_Qual=") }) - // No quality key on the queue → no emit either. - let noKey = try build( - options: PrintOptions(quality: "303"), - optionKeys: ["MediaType"]) - XCTAssertFalse(noKey.contains { $0.hasPrefix("EPIJ_Qual=") }) - } - - func testSanitise() throws { - XCTAssertThrowsError(try build(options: PrintOptions( - cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in - XCTAssertTrue(error is LpArgsError) - } - XCTAssertThrowsError(try build(options: PrintOptions( - cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in - XCTAssertTrue(error is LpArgsError) - } - XCTAssertThrowsError(try build(options: PrintOptions( - cupsOptions: "InputSlot=$(whoami)"))) { error in - XCTAssertTrue(error is LpArgsError) - } - } -} diff --git a/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift b/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift index c58694d..272e951 100644 --- a/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift +++ b/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift @@ -5,33 +5,35 @@ import XCTest /// Issue #183 — Stage 2 paper-size / quality selection: seeding from /// Stage 1 `pageSize`, the synthetic `Custom.x` entry, re-mirror -/// triggers, and `PrintOptions` wiring into `lp` argv. -/// `lpoptions`/`lp` are mock scripts in the test env's `cups-bin` — no +/// triggers, and `TargetPrintOverrides` wiring into the resolved +/// ticket writes (#201 — the `RecordingTargetSpooler` seam replaces +/// the deleted `lp` argv log). +/// `lpoptions` is a mock script in the test env's `cups-bin` — no /// live CUPS is touched. @MainActor final class PrintSessionViewModelTests: XCTestCase { private var env: TestAppEnvironment! - private var lpArgvURL: URL! + private var spoolLogURL: URL! override func setUp() async throws { env = try TestAppEnvironment.make() - lpArgvURL = env.root.appendingPathComponent("lp-argv.log") + spoolLogURL = env.root.appendingPathComponent("spool.log") try writeCupsFixtures() } override func tearDown() async throws { env?.cleanup() env = nil - lpArgvURL = nil + spoolLogURL = nil } private var binDir: URL { env.root.appendingPathComponent("cups-bin") } - /// Mock `lpoptions -l` advertises paper sizes + a quality key; - /// mock `lp` appends its argv to `lpArgvURL` for assertions. + /// Mock `lpoptions -l` advertises paper sizes + a quality key — + /// the queue's option-key roster feeds media/quality detection. private func writeCupsFixtures() throws { try FileManager.default.createDirectory( at: binDir, withIntermediateDirectories: true) @@ -56,17 +58,10 @@ final class PrintSessionViewModelTests: XCTestCase { printf "printer-info='Mock %s' printer-type=42\\n" "$queue" exit 0 """ - let lp = """ - #!/bin/sh - printf '%s\\n' "$*" >> "\(lpArgvURL.path)" - exit 0 - """ - for (name, body) in [("lpoptions", lpoptions), ("lp", lp)] { - let url = binDir.appendingPathComponent(name) - try body.write(to: url, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes( - [.posixPermissions: 0o755], ofItemAtPath: url.path) - } + let url = binDir.appendingPathComponent("lpoptions") + try lpoptions.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) } private func makeWorkflow() -> TargetWorkflowViewModel { @@ -185,14 +180,17 @@ final class PrintSessionViewModelTests: XCTestCase { // MARK: - Spool wiring - /// `spool` emits the Stage 2 paper token and quality through - /// `PrintOptions` → `lp` argv (`-o PageSize=`, `-o =`). + /// `spool` resolves the Stage 2 paper token and quality into the + /// ticket writes (`PageSize=`, `=`) recorded by the + /// DEBUG spool seam (#201 D8). func testSpoolPassesPaperTokenAndQuality() async throws { let workflow = makeWorkflow() workflow.pageSize = .a4 await loadCaps(workflow.print) workflow.print.selectedPaperSize = 4 // Letter workflow.print.selectedQuality = "301" + workflow.print.spooler = RecordingTargetSpooler( + logURL: spoolLogURL) let tiff = env.root.appendingPathComponent("page1.tif") try Data([0x49, 0x49]).write(to: tiff) @@ -208,9 +206,46 @@ final class PrintSessionViewModelTests: XCTestCase { pages: [page]) workflow.print.printAllPages(from: result) - let argv = await waitForFile(lpArgvURL) - XCTAssertTrue(argv.contains("PageSize=Letter"), argv) - XCTAssertTrue(argv.contains("EPIJ_Qual=301"), argv) + let log = await waitForFile(spoolLogURL) + XCTAssertTrue(log.contains("PageSize=Letter"), log) + XCTAssertTrue(log.contains("EPIJ_Qual=301"), log) + } + + /// #201 — the stubbed panel never produces a `PrintTicket`, so a + /// spool with `capturedTickets` empty must still resolve every + /// Stage 2 write (the default UI-test path). + func testSpoolWithoutTicketResolvesStage2Writes() async throws { + let workflow = makeWorkflow() + workflow.pageSize = .a4 + await loadCaps(workflow.print) + workflow.print.spooler = RecordingTargetSpooler( + logURL: spoolLogURL) + XCTAssertTrue(workflow.print.capturedTickets.isEmpty) + + let tiff = env.root.appendingPathComponent("page1.tif") + try Data([0x49, 0x49]).write(to: tiff) + let page = GalleryPage( + index: 0, + page: PrinttargPage( + filename: "page1.tif", patches: 10, + widthMm: 210, heightMm: 297), + fileURL: tiff, previewPNG: nil, previewError: nil) + let result = PrinttargResult( + ti2URL: env.root.appendingPathComponent("target.ti2"), + manifest: PrinttargManifest(pages: [page.page]), + pages: [page]) + workflow.print.printAllPages(from: result) + + let log = await waitForFile(spoolLogURL) + XCTAssertTrue(log.contains("pages=1"), log) + XCTAssertTrue(log.contains("PageSize=A4"), log) + XCTAssertTrue(log.contains("EPIJ_Qual=303"), log) + XCTAssertTrue(log.contains("MediaType=Stationery"), log) + XCTAssertTrue(log.contains("orientation-requested=3"), log) + XCTAssertTrue(log.contains( + "AP_ColorMatchingMode=AP_ApplicationColorMatching"), log) + XCTAssertTrue(log.contains( + "PMColorMatchingMode=APCustomColorMatching"), log) } // MARK: - Panel apply-back (stubbed NSPrintPanel) diff --git a/Tests/ICCeryCoreTests/TicketWriteResolverTests.swift b/Tests/ICCeryCoreTests/TicketWriteResolverTests.swift new file mode 100644 index 0000000..ae42841 --- /dev/null +++ b/Tests/ICCeryCoreTests/TicketWriteResolverTests.swift @@ -0,0 +1,196 @@ +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. +final class TicketWriteResolverTests: XCTestCase { + + private func resolve( + overrides: TargetPrintOverrides = TargetPrintOverrides(), + optionKeys: Set = [] + ) -> ResolvedTicketWrites { + TicketWriteResolver.resolve( + overrides: overrides, optionKeys: optionKeys) + } + + private func keys(_ r: ResolvedTicketWrites) -> [String] { + r.writes.map(\.key) + } + + private func value( + _ key: String, in r: ResolvedTicketWrites + ) -> String? { + r.writes.first { $0.key == key }?.value + } + + /// Both AP_* spellings are always present and locked; all three + /// Quartz keys are always present (D2 dual vocabulary). + func testColourKeysAlwaysPresent() { + let r = resolve() + let ap = r.writes.filter { + ColorMatchingAttempts.printSettingsKeys.contains($0.key) + } + XCTAssertEqual(ap.count, 2) + XCTAssertTrue(ap.allSatisfy { + $0.locked + && $0.value == "AP_ApplicationColorMatching" + }) + XCTAssertEqual(value("PMColorMatchingMode", in: r), + "APCustomColorMatching") + XCTAssertEqual(value("PMCustomColorMatchingProfile", in: r), "") + XCTAssertEqual( + value("com.apple.print.PrintSettings.PMColorMatchingMode", + in: r), + "APCustomColorMatching") + // The Quartz mode key is locked; profile + legacy key are not. + XCTAssertTrue(r.writes.first { $0.key == "PMColorMatchingMode" }! + .locked) + XCTAssertFalse(r.writes.first { + $0.key == "PMCustomColorMatchingProfile" }!.locked) + XCTAssertFalse(r.writes.first { + $0.key == "com.apple.print.PrintSettings.PMColorMatchingMode" + }!.locked) + } + + /// `raw` can never appear — there is no `lp` and the resolver has + /// no captured-options input that could smuggle it in (#92). + func testRawNeverAppears() { + for optionKeys in [Set(), ["raw"], + ["MediaType", "raw"]] { + let r = resolve( + overrides: TargetPrintOverrides( + paperSize: "raw", mediaType: "raw", + qualityKey: "raw", quality: "raw", + orientation: "raw"), + optionKeys: optionKeys) + XCTAssertFalse(r.writes.contains { + $0.key.lowercased() == "raw" + }) + } + } + + /// D6 inversion — the deleted argv builder suppressed the Stage 2 + /// value when the captured string already carried the key (the old + /// media branch at `build`'s line 81). There is no captured-wins + /// path any more: + /// the explicit Stage 2 value is emitted unconditionally. + func testStage2MediaTypeAlwaysWins() { + let r = resolve( + overrides: TargetPrintOverrides(mediaType: "Matte"), + optionKeys: ["MediaType"]) + XCTAssertEqual(value("MediaType", in: r), "Matte") + // …and likewise for a vendor key — nothing consults captured + // options before writing. + let epson = resolve( + overrides: TargetPrintOverrides(mediaType: "Matte"), + optionKeys: ["EPIJ_Medi"]) + XCTAssertEqual(value("EPIJ_Medi", in: epson), "Matte") + } + + /// Vendor media-key selection follows `CupsParsers.mediaTypeKeys`: + /// `EPIJ_Medi` is picked when present; `CNIJMediaType` beats + /// `MediaType`. + func testVendorMediaKeySelection() { + let epson = resolve( + overrides: TargetPrintOverrides(mediaType: "Photo"), + optionKeys: ["EPIJ_Medi", "MediaType"]) + XCTAssertEqual(value("EPIJ_Medi", in: epson), "Photo") + XCTAssertNil(value("MediaType", in: epson)) + + let canon = resolve( + overrides: TargetPrintOverrides(mediaType: "Photo"), + optionKeys: ["CNIJMediaType", "MediaType"]) + XCTAssertEqual(value("CNIJMediaType", in: canon), "Photo") + XCTAssertNil(value("MediaType", in: canon)) + } + + /// Driver "no colour adjustment" bypass — Canon, Epson (CCor + /// preferred over CMat), Gutenprint — always unlocked and never + /// gated on `ppdUncorrectedPassthrough`. + func testDriverBypass() { + let canon = resolve(optionKeys: ["CNIJIntent2"]) + XCTAssertEqual(value("CNIJIntent2", in: canon), "4") + XCTAssertFalse(canon.writes.first { $0.key == "CNIJIntent2" }! + .locked) + + let epson = resolve(optionKeys: ["EPIJ_CCor", "EPIJ_CMat"]) + XCTAssertEqual(value("EPIJ_CCor", in: epson), "0") + XCTAssertNil(value("EPIJ_CMat", in: epson)) + + let epsonMat = resolve(optionKeys: ["EPIJ_CMat"]) + XCTAssertEqual(value("EPIJ_CMat", in: epsonMat), "3") + + let gutenprint = resolve(optionKeys: ["StpColorCorrection"]) + XCTAssertEqual(value("StpColorCorrection", in: gutenprint), + "Uncorrected") + + XCTAssertNil(value("EPIJ_CMat", in: resolve())) + } + + /// Orientation: landscape → 4, portrait → 3, nil → no write. + func testOrientation() { + XCTAssertEqual( + value("orientation-requested", in: resolve( + overrides: TargetPrintOverrides( + orientation: "landscape"))), + "4") + XCTAssertEqual( + value("orientation-requested", in: resolve( + overrides: TargetPrintOverrides( + orientation: "portrait"))), + "3") + XCTAssertNil(value("orientation-requested", in: resolve())) + } + + /// A `nil` override emits no write for that key; quality needs + /// **both** the detected key and the value. + func testNilOverridesEmitNoWrite() { + let r = resolve(optionKeys: ["MediaType", "EPIJ_Qual"]) + XCTAssertNil(value("PageSize", in: r)) + XCTAssertNil(value("MediaType", in: r)) + XCTAssertNil(value("EPIJ_Qual", in: r)) + XCTAssertNil(value("orientation-requested", in: r)) + XCTAssertNil(r.paperToken) + XCTAssertNil(r.orientation) + + let keyOnly = resolve( + overrides: TargetPrintOverrides(qualityKey: "EPIJ_Qual"), + optionKeys: ["EPIJ_Qual"]) + XCTAssertNil(value("EPIJ_Qual", in: keyOnly)) + let valueOnly = resolve( + overrides: TargetPrintOverrides(quality: "305"), + optionKeys: ["EPIJ_Qual"]) + XCTAssertNil(value("EPIJ_Qual", in: valueOnly)) + // An empty paper token emits no write and no paperToken. + let emptyPaper = resolve( + overrides: TargetPrintOverrides(paperSize: "")) + XCTAssertNil(value("PageSize", in: emptyPaper)) + XCTAssertNil(emptyPaper.paperToken) + } + + /// The write list equals the locked 1–10 order, exactly. + func testLockedWriteOrder() { + let r = resolve( + overrides: TargetPrintOverrides( + paperSize: "A4", mediaType: "Photo", + qualityKey: "EPIJ_Qual", quality: "305", + orientation: "landscape"), + optionKeys: ["MediaType", "EPIJ_Qual", "EPIJ_CMat"]) + XCTAssertEqual(keys(r), [ + "AP_ColorMatchingMode", + "AP.ColorMatchingMode", + "PMColorMatchingMode", + "PMCustomColorMatchingProfile", + "com.apple.print.PrintSettings.PMColorMatchingMode", + "PageSize", + "MediaType", + "EPIJ_Qual", + "EPIJ_CMat", + "orientation-requested", + ]) + XCTAssertEqual(r.paperToken, "A4") + XCTAssertEqual(r.orientation, "landscape") + } +} diff --git a/Tests/ICCeryUITests/Fixtures/bin/lp b/Tests/ICCeryUITests/Fixtures/bin/lp deleted file mode 100755 index 6e091f1..0000000 --- a/Tests/ICCeryUITests/Fixtures/bin/lp +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh -# Mock lp for Milestone3UITests. Appends its full argv to -# ICCERY_TEST_LP_ARGV so the test can assert flag order and option -# replay, then exits 0 (or ICCERY_MOCK_LP_EXIT for failure injection). -{ - printf 'lp' - for arg in "$@"; do printf ' %s' "$arg"; done - printf '\n' -} >> "${ICCERY_TEST_LP_ARGV:-/dev/null}" -if [ "${ICCERY_MOCK_LP_EXIT:-0}" -ne 0 ]; then - echo "mock lp failure" >&2 - exit "$ICCERY_MOCK_LP_EXIT" -fi -exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/printtarg b/Tests/ICCeryUITests/Fixtures/bin/printtarg index 16f482d..e0c248c 100755 --- a/Tests/ICCeryUITests/Fixtures/bin/printtarg +++ b/Tests/ICCeryUITests/Fixtures/bin/printtarg @@ -1,6 +1,8 @@ #!/bin/sh -# Mock printtarg for Milestone2UITests. Writes one 2x2 TIFF, a pretty +# Mock printtarg for Milestone2UITests. Writes N 2x2 TIFFs, a pretty # manifest on stdout, and a fake .ti2 next to the basename (last argv). +# N defaults to 1; ICCERY_MOCK_PRINTTARG_PAGES raises it for the +# single-spool-job granularity test (#201 D5). # Exit code is overridable via ICCERY_MOCK_PRINTTARG_EXIT. last="" for arg in "$@"; do last="$arg"; done @@ -8,7 +10,16 @@ if [ "${ICCERY_MOCK_PRINTTARG_EXIT:-0}" -ne 0 ]; then echo "mock printtarg failure" >&2 exit "$ICCERY_MOCK_PRINTTARG_EXIT" fi -echo 'SUkqAAgAAAAKAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwABAAAACAAAAAMBAwABAAAAAQAAAAYBAwABAAAAAQAAABEBBAABAAAAhgAAABUBAwABAAAAAQAAABYBAwABAAAAAgAAABcBBAABAAAABAAAABwBAwABAAAAAQAAAAAAAAA8eLTw' | /usr/bin/base64 -D > "page1.tif" -printf '{\n "event": "manifest",\n "pages": [\n {"filename": "page1.tif", "patches": 4, "width_mm": 210, "height_mm": 297}\n ]\n}\n' +pages="${ICCERY_MOCK_PRINTTARG_PAGES:-1}" +i=1 +printf '{\n "event": "manifest",\n "pages": [\n' +while [ "$i" -le "$pages" ]; do + echo 'SUkqAAgAAAAKAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwABAAAACAAAAAMBAwABAAAAAQAAAAYBAwABAAAAAQAAABEBBAABAAAAhgAAABUBAwABAAAAAQAAABYBAwABAAAAAgAAABcBBAABAAAABAAAABwBAwABAAAAAQAAAAAAAAA8eLTw' | /usr/bin/base64 -D > "page$i.tif" + sep="," + if [ "$i" -eq "$pages" ]; then sep=""; fi + printf ' {"filename": "page%s.tif", "patches": 4, "width_mm": 210, "height_mm": 297}%s\n' "$i" "$sep" + i=$((i + 1)) +done +printf ' ]\n}\n' touch "$last.ti2" exit 0 diff --git a/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift b/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift index d458361..dcb59b9 100644 --- a/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift +++ b/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift @@ -11,7 +11,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase { private var testRoot: URL! private var binDir: URL! private var workDir: URL! - private var lpArgvURL: URL! + private var spoolLogURL: URL! override func setUp() async throws { continueAfterFailure = false @@ -21,7 +21,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase { .deletingLastPathComponent() .appendingPathComponent("Fixtures/bin") workDir = testRoot.appendingPathComponent("work") - lpArgvURL = testRoot.appendingPathComponent("lp-argv.log") + spoolLogURL = testRoot.appendingPathComponent("spool.log") try FileManager.default.createDirectory( at: workDir, withIntermediateDirectories: true) @@ -34,7 +34,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase { "ICCERY_TEST_SAVE_TARGET": workDir.appendingPathComponent("mytarget.ti1").path, "ICCERY_TEST_WORKDIR": workDir.path, - "ICCERY_TEST_LP_ARGV": lpArgvURL.path, + "ICCERY_TEST_SPOOL_LOG": spoolLogURL.path, ] } @@ -75,14 +75,33 @@ final class Milestone11PrintSettingsUITests: XCTestCase { _ = waitFor("galleryPage-0", timeout: 25) } - private func waitForLpLine(_ timeout: TimeInterval = 10) -> String { + private func spoolLogLines() -> [String] { + ((try? String(contentsOf: spoolLogURL, encoding: .utf8)) ?? "") + .split(separator: "\n").map(String.init) + } + + private func waitForSpoolLine(_ timeout: TimeInterval = 10) -> String { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - let out = (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? "" - if !out.isEmpty { return out } + let lines = spoolLogLines() + if !lines.isEmpty { return lines.joined(separator: "\n") } RunLoop.current.run(until: Date().addingTimeInterval(0.1)) } - return (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? "" + return spoolLogLines().joined(separator: "\n") + } + + /// Poll until the spool log holds at least `count` lines. + @discardableResult + private func waitForSpoolLineCount( + _ count: Int, timeout: TimeInterval = 15 + ) -> [String] { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let lines = spoolLogLines() + if lines.count >= count { return lines } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return spoolLogLines() } // MARK: - Tests @@ -163,8 +182,8 @@ final class Milestone11PrintSettingsUITests: XCTestCase { } /// The stubbed panel result's captured `PageSize=`/`EPIJ_Qual=` - /// apply back into the Stage 2 pickers and reach the `lp` argv - /// (R15 — the real modal is never driven). + /// apply back into the Stage 2 pickers and reach the recorded + /// ticket writes (#201 — the real modal is never driven). func testPanelResultAppliesBackToPickers() throws { app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = @@ -183,14 +202,14 @@ final class Milestone11PrintSettingsUITests: XCTestCase { XCTAssertEqual(selection(of: "printerQualitySelect"), "305") app.buttons["btnPrintAll"].click() - let argv = waitForLpLine() - XCTAssertTrue(argv.contains("PageSize=Letter"), argv) - XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv) + let log = waitForSpoolLine() + XCTAssertTrue(log.contains("PageSize=Letter"), log) + XCTAssertTrue(log.contains("EPIJ_Qual=305"), log) } /// #186 — the stubbed panel result's `orientation-requested=` / /// `MediaType=` apply back to the Stage 2 selections and reach the - /// `lp` argv through the captured `cupsOptions` replay. + /// recorded ticket writes via the Stage 2 overrides (#201 D6). func testPanelResultAppliesBackOrientationAndMedia() throws { app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = @@ -225,11 +244,38 @@ final class Milestone11PrintSettingsUITests: XCTestCase { XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter") app.buttons["btnPrintAll"].click() - let argv = waitForLpLine() - XCTAssertTrue(argv.contains("orientation-requested=4"), argv) - XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv) - XCTAssertTrue(argv.contains("PageSize=Letter"), argv) - XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv) + let log = waitForSpoolLine() + XCTAssertTrue(log.contains("orientation-requested=4"), log) + XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log) + XCTAssertTrue(log.contains("PageSize=Letter"), log) + XCTAssertTrue(log.contains("EPIJ_Qual=305"), log) + } + + /// #201 D5 — per-page mode records one line per page; the + /// `chkSingleSpoolJob` toggle collapses the job into a single + /// request logged once with `pages=N`. + func testSingleSpoolJobTogglesGranularity() throws { + app.launchEnvironment["ICCERY_MOCK_PRINTTARG_PAGES"] = "2" + launchAppWithDefaults() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + XCTAssertTrue(element("chkSingleSpoolJob").exists) + + // Default off → one request per page → one line per page. + app.buttons["btnPrintAll"].click() + var lines = waitForSpoolLineCount(2) + XCTAssertEqual(lines.count, 2) + XCTAssertTrue(lines.allSatisfy { $0.contains("pages=1") }, + lines.joined(separator: "\n")) + + // Toggle on → one request for all pages → a single line. + element("chkSingleSpoolJob").click() + app.buttons["btnPrintAll"].click() + lines = waitForSpoolLineCount(3) + XCTAssertEqual(lines.count, 3) + XCTAssertEqual( + lines.filter { $0.contains("pages=2") }.count, 1, + lines.joined(separator: "\n")) } private func launchAppWithDefaults() { diff --git a/Tests/ICCeryUITests/Milestone3UITests.swift b/Tests/ICCeryUITests/Milestone3UITests.swift index bb20e1d..bf1d29c 100644 --- a/Tests/ICCeryUITests/Milestone3UITests.swift +++ b/Tests/ICCeryUITests/Milestone3UITests.swift @@ -3,9 +3,10 @@ import XCTest /// Milestone 3 UI tests — issue #17 print panel end-to-end with mock /// CUPS binaries and a stubbed `NSPrintPanel`. The real panel is a /// system modal XCUITest cannot drive; `ICCERY_TEST_PRINT_PANEL` -/// returns a canned `PrintPropertiesResult` instead. Mock `lp` appends -/// its argv to `ICCERY_TEST_LP_ARGV` for assertions — that file is the -/// evidence that captured options are replayed (docs/11 §tests). +/// returns a canned `PrintPropertiesResult` instead. The DEBUG +/// `RecordingTargetSpooler` appends one resolved-ticket line per +/// request to `ICCERY_TEST_SPOOL_LOG` (#201 D8) — that file is the +/// evidence that the Stage 2 selections reach the spool. @MainActor final class Milestone3UITests: XCTestCase { @@ -13,7 +14,7 @@ final class Milestone3UITests: XCTestCase { private var testRoot: URL! private var binDir: URL! private var workDir: URL! - private var lpArgvURL: URL! + private var spoolLogURL: URL! override func setUp() async throws { continueAfterFailure = false @@ -23,7 +24,7 @@ final class Milestone3UITests: XCTestCase { .deletingLastPathComponent() .appendingPathComponent("Fixtures/bin") workDir = testRoot.appendingPathComponent("work") - lpArgvURL = testRoot.appendingPathComponent("lp-argv.log") + spoolLogURL = testRoot.appendingPathComponent("spool.log") try FileManager.default.createDirectory( at: workDir, withIntermediateDirectories: true) @@ -36,7 +37,7 @@ final class Milestone3UITests: XCTestCase { "ICCERY_TEST_SAVE_TARGET": workDir.appendingPathComponent("mytarget.ti1").path, "ICCERY_TEST_WORKDIR": workDir.path, - "ICCERY_TEST_LP_ARGV": lpArgvURL.path, + "ICCERY_TEST_SPOOL_LOG": spoolLogURL.path, ] } @@ -99,18 +100,18 @@ final class Milestone3UITests: XCTestCase { _ = waitFor("galleryPage-0", timeout: 25) } - private func recordedLpArgv() -> String { - (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? "" + private func recordedSpoolLog() -> String { + (try? String(contentsOf: spoolLogURL, encoding: .utf8)) ?? "" } - private func waitForLpLine(_ timeout: TimeInterval = 10) -> String { + private func waitForSpoolLine(_ timeout: TimeInterval = 10) -> String { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - let out = recordedLpArgv() + let out = recordedSpoolLog() if !out.isEmpty { return out } RunLoop.current.run(until: Date().addingTimeInterval(0.1)) } - return recordedLpArgv() + return recordedSpoolLog() } /// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button @@ -203,10 +204,13 @@ final class Milestone3UITests: XCTestCase { XCTAssertEqual(element("printNotificationIcon").value as? String, "info") } - /// Preferences OK → captured options are replayed verbatim in the - /// `lp` argv alongside the two mandatory AP_* headers (issue 17's - /// acceptance test: "captured options replayed in argv"). - func testCapturedOptionsReplayedInLpArgv() throws { + /// Preferences OK → the captured media selection applies back to + /// Stage 2 and reaches the recorded ticket writes alongside the + /// mandatory colour keys (issue 17's acceptance test, ported to + /// the native spool seam in #201). The captured `InputSlot` is no + /// longer replayed — captured vendor state lives inside the + /// `PrintTicket`, which the stub deliberately does not produce. + func testPanelSelectionsApplyBackToSpoolWrites() throws { app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = "InputSlot=Rear MediaType=PhotographicGlossy" @@ -221,20 +225,17 @@ final class Milestone3UITests: XCTestCase { .contains("Settings captured")) app.buttons["btnPrintAll"].click() - let argv = waitForLpLine() - XCTAssertTrue(argv.contains( - "AP_ColorMatchingMode=AP_ApplicationColorMatching"), argv) - XCTAssertTrue(argv.contains( - "AP.ColorMatchingMode=AP_ApplicationColorMatching"), argv) - XCTAssertTrue(argv.contains("InputSlot=Rear"), argv) - XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv) + let log = waitForSpoolLine() + XCTAssertTrue(log.contains( + "AP_ColorMatchingMode=AP_ApplicationColorMatching"), log) + XCTAssertTrue(log.contains( + "AP.ColorMatchingMode=AP_ApplicationColorMatching"), log) + XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log) // Detected bypass for the mock queue (EPIJ_CMat present in - // lpoptions -l) is appended when not captured. - XCTAssertTrue(argv.contains("EPIJ_CMat=3"), argv) - XCTAssertTrue(argv.contains("orientation-requested=3"), argv) - // Last token is the TIFF. - XCTAssertTrue(argv.trimmingCharacters(in: .whitespacesAndNewlines) - .hasSuffix("page1.tif"), argv) + // lpoptions -l) is always written. + XCTAssertTrue(log.contains("EPIJ_CMat=3"), log) + XCTAssertTrue(log.contains("orientation-requested=3"), log) + XCTAssertTrue(log.contains("page=page1.tif"), log) } /// Per-page print uses the same spool path (btnPrintPage-N). @@ -262,14 +263,16 @@ final class Milestone3UITests: XCTestCase { return } printPage.click() - let argv = waitForLpLine() - XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv) - XCTAssertTrue(argv.contains("page1.tif"), argv) + let log = waitForSpoolLine() + XCTAssertTrue(log.contains("AP_ColorMatchingMode"), log) + XCTAssertTrue(log.contains("page=page1.tif"), log) } - /// lp failure surfaces in the in-panel notice, not the wizard banner. - func testLpFailureShowsPrintNotice() throws { - app.launchEnvironment["ICCERY_MOCK_LP_EXIT"] = "1" + /// A spool failure surfaces in the in-panel notice, not the + /// wizard banner (`ICCERY_TEST_SPOOL_FAIL` makes the recording + /// spooler throw `TargetSpoolError.operationFailed`). + func testSpoolFailureShowsPrintNotice() throws { + app.launchEnvironment["ICCERY_TEST_SPOOL_FAIL"] = "1" launchApp() reachPrintPanel() _ = waitFor("printerStatusBadge") @@ -290,7 +293,7 @@ final class Milestone3UITests: XCTestCase { _ = waitFor("printerStatusBadge") app.buttons["btnPrintAll"].click() - _ = waitForLpLine() + _ = waitForSpoolLine() let stateURL = testRoot .appendingPathComponent("AppData") .appendingPathComponent("wizard_state.json") -- 2.39.5 From 5389e4c2bd1f1cc5ccb4cfc85d57afed74d8a3a5 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 17 Sep 2026 17:20:25 +0100 Subject: [PATCH 06/14] feat(print): AirPrint queue detection + Stage 2 warning badge (#202 Phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CupsParsers: lpstatDeviceURIs / lpoptionsMakeAndModel / detectAirPrint — the six docs/14 §10.2 rules (apple-airprint:// URI, *APAirPrint: True, Apple+AirPrint make-and-model, ipps://+PPD mention, ipp:// + _universal mDNS subtype, cupsFilter2→image/urf). - CupsService.listPrinters: one tolerated lpstat -v spawn feeds detection; lpoptions -p yields displayName + makeAndModel in a single spawn; Printer.isAirPrint populated per queue. - Printer.isAirPrint defaults false with a tolerant decoder. - Stage2View: live airPrintWarningBadge — "AirPrint queue — unmanaged colour cannot be guaranteed." - Fixtures: lpstat -v marks Mock_Canon_Pro via _universal subtype; lpoptions emits printer-make-and-model. - Tests: six rule fixtures + USB negative + a no-fallback-respawn listPrinters case; Milestone12AirPrintUITests tracks the badge across queue selection. Gate: full xcodebuild test green (515 unit + 70 UI). --- .../ICCeryCore/Print/CupsParsers.swift | 88 +++++++++ .../ICCeryCore/Print/CupsService.swift | 26 ++- .../ICCeryCore/Print/PrinterModels.swift | 20 +- Sources/ICCery/Stage2View.swift | 17 +- Tests/ICCeryCoreTests/CupsParserTests.swift | 171 ++++++++++++++++++ Tests/ICCeryUITests/Fixtures/bin/lpoptions | 2 +- Tests/ICCeryUITests/Fixtures/bin/lpstat | 6 + .../Milestone12AirPrintUITests.swift | 132 ++++++++++++++ 8 files changed, 451 insertions(+), 11 deletions(-) create mode 100644 Tests/ICCeryUITests/Milestone12AirPrintUITests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift index 894960f..45d632a 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift @@ -73,6 +73,26 @@ public enum CupsParsers { return nil } + /// `lpstat -v` — `device for : ` → queue → device URI. + /// Entries with no URI (e.g. `network ` remote stubs) are + /// skipped (#202 AirPrint detection). + public static func lpstatDeviceURIs(output: String) -> [String: String] { + var result: [String: String] = [:] + for line in output.split(separator: "\n") { + let text = line.trimmingCharacters(in: .whitespaces) + guard text.hasPrefix("device for ") else { continue } + let rest = text.dropFirst("device for ".count) + guard let colon = rest.firstIndex(of: ":") else { continue } + let name = String(rest[.. /// `lpoptions -p` — `key=value` pairs, values may be @@ -137,6 +157,16 @@ public enum CupsParsers { return value } + /// Make-and-model from `printer-make-and-model` in `lpoptions -p` + /// output — an AirPrint detection input (#202). + public static func lpoptionsMakeAndModel(output: String) -> String? { + guard let value = lpoptions(output) + .first(where: { $0.key == "printer-make-and-model" })?.value, + !value.isEmpty + else { return nil } + return value + } + // MARK: - lpoptions -l /// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`. @@ -352,4 +382,62 @@ public enum CupsParsers { "EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection", "EpsonColorMode", ] + + // MARK: - AirPrint detection (#202, docs/14 §10.2) + + /// AirPrint queues always colour-manage the URF raster path, so + /// unmanaged device colour cannot be guaranteed — Stage 2 warns + /// via `airPrintWarningBadge`. `true` when ANY rule matches: + /// + /// 1. device URI contains `apple-airprint://` + /// 2. PPD declares `*APAirPrint: True` + /// 3. make-and-model contains "Apple" **and** "AirPrint" + /// 4. `ipps://` URI **and** the PPD text mentions "airprint" + /// (case-insensitive) + /// 5. local unencrypted `ipp://` URI resolved via an AirPrint mDNS + /// subtype (`_universal._sub._ipp._tcp`) + /// 6. PPD carries a `*cupsFilter2` rule whose destination MIME + /// type is `image/urf` — the raster only AirPrint consumes + public static func detectAirPrint( + deviceURI: String?, makeAndModel: String?, ppd: String + ) -> Bool { + if let uri = deviceURI { + let lower = uri.lowercased() + if lower.contains("apple-airprint://") { return true } + if lower.hasPrefix("ipps://"), + ppd.range(of: "airprint", options: .caseInsensitive) != nil { + return true + } + if lower.hasPrefix("ipp://"), + lower.contains("_universal._sub._ipp._tcp") { + return true + } + } + if ppd.contains("*APAirPrint: True") { return true } + if let model = makeAndModel, + model.contains("Apple"), model.contains("AirPrint") { + return true + } + return ppdCarriesURFFilter(ppd) + } + + /// Rule 6 — `*cupsFilter2: "src dst cost program"` (quotes + /// optional); `image/urf` as the destination token means the queue + /// consumes the AirPrint raster. + private static func ppdCarriesURFFilter(_ ppd: String) -> Bool { + for rawLine in ppd.split(separator: "\n") { + let line = rawLine.trimmingCharacters(in: .whitespaces) + guard line.lowercased().hasPrefix("*cupsfilter2"), + let colon = line.firstIndex(of: ":") + else { continue } + let tokens = line[line.index(after: colon)...] + .replacingOccurrences(of: "\"", with: "") + .split(separator: " ") + if tokens.count >= 2, + tokens[1].lowercased() == "image/urf" { + return true + } + } + return false + } } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift index 3a82483..3ecec7a 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift @@ -57,19 +57,29 @@ public struct CupsService: Sendable { "lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true) let defaultOut = try await run( "lpstat", ["-d"], id: ProcessID.cupsLpstat("d"), tolerateFailure: true) + // One-shot device-URI fetch for AirPrint detection (#202). A + // failed `lpstat -v` is tolerated — enumeration proceeds with + // no URIs and no fallback respawn. + let deviceOut = try await run( + "lpstat", ["-v"], id: ProcessID.cupsLpstat("v"), tolerateFailure: true) let names = CupsParsers.lpstatDestinations(destinationsOut.stdout) let statuses = CupsParsers.lpstatStatuses(statusOut.stdout) let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout) + let deviceURIs = CupsParsers.lpstatDeviceURIs(output: deviceOut.stdout) var printers: [Printer] = [] for name in names { - let displayName = try? await displayName(for: name) + let identity = try? await queueIdentity(for: name) printers.append(Printer( name: name, status: statuses[name] ?? .unknown, isDefault: name == defaultName, - displayName: displayName + displayName: identity?.displayName, + isAirPrint: CupsParsers.detectAirPrint( + deviceURI: deviceURIs[name], + makeAndModel: identity?.makeAndModel, + ppd: loadPPD(for: name) ?? "") )) } return printers @@ -78,9 +88,19 @@ public struct CupsService: Sendable { /// `lpoptions -p ` → `printer-info` (the NSPrinter fallback /// display name, docs/11 §binding). public func displayName(for queue: String) async throws -> String? { + try await queueIdentity(for: queue).displayName + } + + /// One `lpoptions -p ` spawn yields both identity fields — + /// `printer-info` (display name) and `printer-make-and-model` + /// (AirPrint rule 3, #202). + private func queueIdentity( + for queue: String + ) async throws -> (displayName: String?, makeAndModel: String?) { let result = try await run( "lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue)) - return CupsParsers.lpoptionsDisplayName(result.stdout) + return (CupsParsers.lpoptionsDisplayName(result.stdout), + CupsParsers.lpoptionsMakeAndModel(output: result.stdout)) } // MARK: - Capabilities (lpoptions -l + PPD) diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift index 3549610..1947873 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift @@ -17,17 +17,33 @@ public struct Printer: Codable, Equatable, Sendable { public var status: PrinterStatus public var isDefault: Bool public var displayName: String? + /// AirPrint queue (#202, docs/14 §10.2) — the URF pipeline is always + /// colour-managed, so Stage 2 shows a persistent warning badge. + public var isAirPrint: Bool = false public init( name: String, status: PrinterStatus = .unknown, isDefault: Bool = false, - displayName: String? = nil + displayName: String? = nil, + isAirPrint: Bool = false ) { self.name = name self.status = status self.isDefault = isDefault self.displayName = displayName + self.isAirPrint = isAirPrint + } + + /// `isAirPrint` predates #202 payloads — a missing key decodes as + /// `false` instead of failing the whole decode. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + name = try c.decode(String.self, forKey: .name) + status = try c.decode(PrinterStatus.self, forKey: .status) + isDefault = try c.decode(Bool.self, forKey: .isDefault) + displayName = try c.decodeIfPresent(String.self, forKey: .displayName) + isAirPrint = try c.decodeIfPresent(Bool.self, forKey: .isAirPrint) ?? false } } @@ -128,7 +144,7 @@ public struct PrintOptions: Codable, Equatable, Sendable { public var ppdUncorrectedPassthrough: Bool? /// Space-separated `key=value` captured from /// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) — - /// the Stage 2 mirror only, never a spool payload. + /// the Stage 2 mirror only, never an `lp` payload. public var cupsOptions: String? public init( diff --git a/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift index 3461d06..2c348ea 100644 --- a/Sources/ICCery/Stage2View.swift +++ b/Sources/ICCery/Stage2View.swift @@ -266,12 +266,19 @@ struct Stage2View: View { .background(Theme.background) .clipShape(Capsule()) .accessibilityIdentifier("printerStatusBadge") + // AirPrint queues colour-manage the URF path — + // unmanaged colour cannot be guaranteed (#202). + if selected.isAirPrint { + Text("AirPrint queue — unmanaged colour " + + "cannot be guaranteed.") + .font(.caption) + .foregroundStyle(.orange) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Color.orange.opacity(0.12)) + .clipShape(Capsule()) + .accessibilityIdentifier("airPrintWarningBadge") + } } - // Placeholder — Phase 5 (AirPrint detection, M12) - // replaces this with the live unmanaged-colour - // warning badge for AirPrint queues. - EmptyView() - .accessibilityIdentifier("airPrintWarningBadge") Button(action: workflow.print.refreshPrinters) { Image(systemName: "arrow.clockwise") } diff --git a/Tests/ICCeryCoreTests/CupsParserTests.swift b/Tests/ICCeryCoreTests/CupsParserTests.swift index b61b8cd..2b58ad6 100644 --- a/Tests/ICCeryCoreTests/CupsParserTests.swift +++ b/Tests/ICCeryCoreTests/CupsParserTests.swift @@ -455,4 +455,175 @@ final class CupsParserTests: XCTestCase { processManager: ProcessManager(), binaryDir: bin, ppdDir: ppdDir), root) } + + // MARK: - #202 AirPrint detection + + /// `lpstat -v` — `device for : ` lines; a `network` + /// remote stub carries no URI and is skipped. + private let lpstatV = """ + device for Canon_Pro9500_II_series_XPS: usb://Canon/PRO-9500%20II%20series?serial=1234AB + device for Epson_XP_55_LPD: lpd://192.168.1.50/queue + device for EPSON_XP_55_Series: ipp://EPSON%20XP-55%20Series._universal._sub._ipp._tcp.local./ + device for Office_IPPS: ipps://print.example.com/ipp/print + network Remote_Queue + """ + + func testLpstatDeviceURIs() { + let uris = CupsParsers.lpstatDeviceURIs(output: lpstatV) + XCTAssertEqual(uris.count, 4) + XCTAssertEqual(uris["Canon_Pro9500_II_series_XPS"], + "usb://Canon/PRO-9500%20II%20series?serial=1234AB") + XCTAssertEqual(uris["Epson_XP_55_LPD"], "lpd://192.168.1.50/queue") + XCTAssertEqual(uris["EPSON_XP_55_Series"], + "ipp://EPSON%20XP-55%20Series._universal._sub._ipp._tcp.local./") + XCTAssertEqual(uris["Office_IPPS"], + "ipps://print.example.com/ipp/print") + XCTAssertNil(uris["Remote_Queue"]) + XCTAssertEqual(CupsParsers.lpstatDeviceURIs(output: ""), [:]) + } + + func testLpoptionsMakeAndModel() { + XCTAssertEqual(CupsParsers.lpoptionsMakeAndModel( + output: lpoptionsP), "EPSON EPSON XP-55 Series") + XCTAssertNil(CupsParsers.lpoptionsMakeAndModel( + output: "printer-type=42\n")) + } + + /// Rule 1 — an `apple-airprint://` device URI is AirPrint on its + /// own; PPD and make-and-model are irrelevant. + func testAirPrintRuleAppleAirPrintScheme() { + XCTAssertTrue(CupsParsers.detectAirPrint( + deviceURI: "apple-airprint://DeskJet._ipps._tcp.local./", + makeAndModel: nil, ppd: "")) + } + + /// Rule 2 — the PPD declares `*APAirPrint: True`. + func testAirPrintRulePPDFlag() { + let ppd = """ + *PPD-Adobe: "4.3" + *APAirPrint: True + *OpenUI *PageSize/Media Size: PickOne + """ + XCTAssertTrue(CupsParsers.detectAirPrint( + deviceURI: "socket://10.0.0.9/", makeAndModel: nil, ppd: ppd)) + } + + /// Rule 3 — make-and-model contains "Apple" and "AirPrint". + func testAirPrintRuleMakeAndModel() { + XCTAssertTrue(CupsParsers.detectAirPrint( + deviceURI: "socket://10.0.0.9/", + makeAndModel: "Apple AirPrint", ppd: "")) + // Both tokens are required. + XCTAssertFalse(CupsParsers.detectAirPrint( + deviceURI: "socket://10.0.0.9/", + makeAndModel: "Apple LaserWriter", ppd: "")) + XCTAssertFalse(CupsParsers.detectAirPrint( + deviceURI: "socket://10.0.0.9/", + makeAndModel: "HP AirPrint-Ready", ppd: "")) + } + + /// Rule 4 — `ipps://` URI **and** the PPD text mentions "airprint" + /// case-insensitively. `ipps://` alone is not enough. + func testAirPrintRuleIPPSWithPPDMention() { + XCTAssertTrue(CupsParsers.detectAirPrint( + deviceURI: "ipps://print.example.com/ipp/print", + makeAndModel: nil, + ppd: "*Foo: \"AIRPRINT enabled\"\n")) + XCTAssertFalse(CupsParsers.detectAirPrint( + deviceURI: "ipps://print.example.com/ipp/print", + makeAndModel: nil, ppd: "*PPD-Adobe: \"4.3\"\n")) + // An unencrypted ipp:// URI does not satisfy rule 4. + XCTAssertFalse(CupsParsers.detectAirPrint( + deviceURI: "ipp://print.example.com/ipp/print", + makeAndModel: nil, + ppd: "*Foo: \"airprint enabled\"\n")) + } + + /// Rule 5 — unencrypted `ipp://` resolved via the AirPrint mDNS + /// subtype `_universal._sub._ipp._tcp`. A plain `ipp://` mDNS name + /// without the subtype is not AirPrint. + func testAirPrintRuleMDNSSubtype() { + XCTAssertTrue(CupsParsers.detectAirPrint( + deviceURI: "ipp://EPSON%20XP-55._universal._sub._ipp._tcp.local./", + makeAndModel: nil, ppd: "")) + XCTAssertFalse(CupsParsers.detectAirPrint( + deviceURI: "ipp://EPSON%20XP-55._ipp._tcp.local./", + makeAndModel: nil, ppd: "")) + } + + /// Rule 6 — a `*cupsFilter2` rule whose destination MIME is + /// `image/urf` (the AirPrint-only raster). A PWG-raster filter is + /// not AirPrint. + func testAirPrintRuleCupsFilter2URF() { + let ppd = """ + *cupsFilter2: "application/pdf image/urf 0 -" + *cupsFilter2: "image/urf image/urf 100 -" + """ + XCTAssertTrue(CupsParsers.detectAirPrint( + deviceURI: nil, makeAndModel: nil, ppd: ppd)) + XCTAssertFalse(CupsParsers.detectAirPrint( + deviceURI: nil, makeAndModel: nil, + ppd: "*cupsFilter2: \"application/pdf image/pwg-raster 0 -\"\n")) + } + + /// Negative — a standard USB raster-driver queue (Epson XP-55) + /// matches none of the six rules. + func testAirPrintNegativeUSBRaster() { + let ppd = """ + *PPD-Adobe: "4.3" + *EPIJ_Qual 303/Normal: "" + *cupsFilter: "application/vnd.cups-raster 0 rastertoepson" + """ + XCTAssertFalse(CupsParsers.detectAirPrint( + deviceURI: "usb://EPSON/XP-55%20Series?serial=ABC123", + makeAndModel: "EPSON XP-55 Series", ppd: ppd)) + } + + /// `listPrinters` survives a failing `lpstat -v` — the failure is + /// tolerated, enumeration proceeds, every queue reports + /// `isAirPrint == false`, and there is **no** fallback respawn + /// (exactly one `-v` invocation, #202). + func testListPrintersToleratesLpstatVFailure() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-airprint-\(UUID().uuidString)") + let bin = root.appendingPathComponent("bin") + try FileManager.default.createDirectory( + at: bin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let log = root.appendingPathComponent("lpstat.log") + + let lpstat = """ + #!/bin/sh + printf '%s\\n' "$1" >> '\(log.path)' + case "$1" in + -e) printf 'Mock_Epson\\n' ;; + -p) printf 'printer Mock_Epson is idle.\\n' ;; + -d) printf 'no system default destination\\n' ;; + -v) exit 1 ;; + esac + exit 0 + """ + let lpoptions = """ + #!/bin/sh + printf "printer-info='Mock'\\n" + """ + for (name, body) in [("lpstat", lpstat), ("lpoptions", lpoptions)] { + let url = bin.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + let service = CupsService( + processManager: ProcessManager(), binaryDir: bin, + ppdDir: root.appendingPathComponent("ppd")) + let printers = try await service.listPrinters() + XCTAssertEqual(printers.map(\.name), ["Mock_Epson"]) + XCTAssertFalse(printers[0].isAirPrint) + + let calls = ((try? String(contentsOf: log, encoding: .utf8)) ?? "") + .split(separator: "\n") + XCTAssertEqual(calls.filter { $0 == "-v" }.count, 1, + "lpstat -v must be spawned exactly once: \(calls)") + } } diff --git a/Tests/ICCeryUITests/Fixtures/bin/lpoptions b/Tests/ICCeryUITests/Fixtures/bin/lpoptions index 0110476..5afe011 100755 --- a/Tests/ICCeryUITests/Fixtures/bin/lpoptions +++ b/Tests/ICCeryUITests/Fixtures/bin/lpoptions @@ -22,5 +22,5 @@ if [ "$list" = "1" ]; then printf 'ColorModel/Output Mode: *RGB Gray\n' exit 0 fi -printf "printer-info='Mock %s' printer-type=42\n" "$queue" +printf "printer-info='Mock %s' printer-make-and-model='EPSON XP-55 Series' printer-type=42\n" "$queue" exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/lpstat b/Tests/ICCeryUITests/Fixtures/bin/lpstat index 28a7347..8445c37 100755 --- a/Tests/ICCeryUITests/Fixtures/bin/lpstat +++ b/Tests/ICCeryUITests/Fixtures/bin/lpstat @@ -12,6 +12,12 @@ case "$1" in -d) printf 'system default destination: Mock_Epson_7450\n' ;; + -v) + # Mock_Canon_Pro is the AirPrint fixture queue (#202): local + # unencrypted ipp:// resolved via the _universal mDNS subtype. + printf 'device for Mock_Epson_7450: usb://EPSON/XP-55%%20Series?serial=MOCK123\n' + printf 'device for Mock_Canon_Pro: ipp://Mock%%20Canon%%20Pro._universal._sub._ipp._tcp.local./\n' + ;; *) exit 1 ;; diff --git a/Tests/ICCeryUITests/Milestone12AirPrintUITests.swift b/Tests/ICCeryUITests/Milestone12AirPrintUITests.swift new file mode 100644 index 0000000..5a8900d --- /dev/null +++ b/Tests/ICCeryUITests/Milestone12AirPrintUITests.swift @@ -0,0 +1,132 @@ +import XCTest + +/// Milestone 12 UI tests — #202 AirPrint queue detection. The mock +/// `lpstat -v` fixture reports `Mock_Canon_Pro` as a local unencrypted +/// `ipp://` queue resolved via the `_universal._sub._ipp._tcp` mDNS +/// subtype (rule 5), while `Mock_Epson_7450` is a plain USB raster +/// queue — so the badge must track the picker selection. +@MainActor +final class Milestone12AirPrintUITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + private var spoolLogURL: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui12-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + spoolLogURL = testRoot.appendingPathComponent("spool.log") + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + "ICCERY_CUPS_BIN_DIR": binDir.path, + "ICCERY_TEST_SAVE_TARGET": + workDir.appendingPathComponent("mytarget.ti1").path, + "ICCERY_TEST_WORKDIR": workDir.path, + "ICCERY_TEST_SPOOL_LOG": spoolLogURL.path, + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + } + + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id] + if inApp.exists { return inApp } + return app.sheets.firstMatch.descendants(matching: .any)[id] + } + + private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue(el.exists, "Expected element \(id)") + return el + } + + /// Poll until `id` no longer resolves — the badge is conditionally + /// rendered, so absence is only meaningful after a settle window. + private func waitForAbsence(_ id: String, timeout: TimeInterval = 5) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !element(id).exists { return } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertFalse(element(id).exists, "Element \(id) should be absent") + } + + /// Drive the app through targen + printtarg so the print panel is + /// live with a manifest. + private func reachPrintPanel() { + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + _ = waitFor("btnCreateLayout", timeout: 25) + app.buttons["btnCreateLayout"].click() + _ = waitFor("galleryPage-0", timeout: 25) + } + + /// Select the printer-picker menu item whose title contains + /// `needle` (display names come from the `lpoptions` fixture). + private func selectPrinter(containing needle: String) { + let picker = app.popUpButtons["printerSelect"] + XCTAssertTrue(picker.waitForExistence(timeout: 10)) + picker.click() + let item = app.menuItems + .matching(NSPredicate(format: "title CONTAINS %@", needle)) + .firstMatch + XCTAssertTrue(item.waitForExistence(timeout: 5), + "No printer menu item containing \(needle)") + item.click() + } + + // MARK: - Tests + + /// The badge appears while the AirPrint fixture queue is selected + /// and is absent for the USB Epson — in both directions. + func testAirPrintBadgeTracksSelectedQueue() throws { + app.launch() + app.activate() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + // Default queue is the USB Epson — no badge. + waitForAbsence("airPrintWarningBadge") + + selectPrinter(containing: "Canon") + let badge = element("airPrintWarningBadge") + XCTAssertTrue(badge.waitForExistence(timeout: 10)) + // StaticText exposes its content via AXValue, not the label. + let badgeText = [badge.value as? String, badge.label, badge.title] + .compactMap { $0 } + .first { !$0.isEmpty } ?? "" + XCTAssertTrue( + badgeText.contains( + "AirPrint queue — unmanaged colour cannot be guaranteed."), + "Unexpected badge text: \(badgeText)") + + selectPrinter(containing: "Epson") + waitForAbsence("airPrintWarningBadge") + } +} -- 2.39.5 From b89d9777ad58ed272ce15dcdea028c44007d116a Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 17 Sep 2026 17:40:25 +0100 Subject: [PATCH 07/14] docs(print): native spool documentation update + issue #201 analysis (#201 Phase 6) - Create docs/issue-printer-quality-ignored.md: lifecycle trace, ticket-loss and override-inversion root causes with file:line evidence, dismissed argv tokenisation hypothesis, locked D1-D13 table. - docs/11: retitle walkthrough to UI click to NSPrintOperation; replace build_lp_args/print_target with the S1-S14 native-spool trace; add layers 5' (Quartz vocabulary) and 7 (ticket serialise/restore); mark lp flag table and macOS test list historical v1. - docs/14: decision table rows (Spool, ColorSync ticket, Geometry, Interpolation, AirPrint) marked adopted in v2.0 via #201; note section 7 Quartz vocabulary now live in ICCery proper. - AGENTS.md: spooler documented in app target (D1); new Print spool section (lp eradicated, parsers retained per D4, ICCERY_TEST_SPOOL_LOG seam); ColorSync SPI rule replaced by the D2 single-path dual-vocabulary rule. - Eradication sweep: label remaining lp/LpArgs references historical v1 in docs/10, docs/13, docs/25, docs/26; drop deleted LpArgs from README. --- AGENTS.md | 26 ++++- README.md | 9 +- docs/10-print-system.md | 5 +- docs/11-print-macos.md | 136 +++++++++++++++++--------- docs/13-print-linux.md | 5 +- docs/14-iccery-cpu-targetprint.md | 20 ++-- docs/25-rewrite-notes.md | 2 +- docs/26-v2-mac-ticket-plan.md | 12 ++- docs/issue-printer-quality-ignored.md | 105 ++++++++++++++++++++ 9 files changed, 252 insertions(+), 68 deletions(-) create mode 100644 docs/issue-printer-quality-ignored.md diff --git a/AGENTS.md b/AGENTS.md index de52d9b..010914e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,12 @@ Native macOS printer ICC/ICM profiling frontend. Drives the Gronod ArgyllCMS 3.5 - No Tauri, no Rust host, no WKWebView, no Three.js. ## Package layout -- `ICCery` — app target (SwiftUI shell). -- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel). +- `ICCery` — app target (SwiftUI shell). Also owns the native print stack in + `Sources/ICCery/Print/`: `PMTicketBridge`, `PrintTicket`, + `TicketWriteResolver`, `NativeTargetSpooler` (+ `RecordingTargetSpooler`), + `TargetRaster`, `TargetPageCanvasView` (#201 D1 — AppKit/`NSPrintOperation` + lives here, never in `ICCeryCore`). +- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel; CUPS enumeration/parsers only). - `ICCeryPrintKit` — v2.1 only (issue 16). Zero deps on wizard types. ## AGPL boundary @@ -39,6 +43,17 @@ Empty cwd illegal (#59). Atomic writes = `.tmp` + rename (#213). User-supplied strings via SwiftUI `Text` only (#114). TIFF never rendered directly — host-side PNG preview (#58). +## Print spool — native since v2.0 (#201); v1 `lp` path eradicated +Target printing is a headless `NSPrintOperation` via `NativeTargetSpooler` +(#201): restore the captured `PrintTicket`, apply `TicketWriteResolver` +(Stage 2 always wins, D6), draw 1:1 with interpolation off. +`lp` is eradicated from the target-print path (historical v1: `LpArgs`, +`CupsService.printTarget`, `ICCERY_TEST_LP_ARGV` all deleted). +`CupsParsers`/`CupsOptionsFilter` stay (D4): enumeration, capabilities, +media/quality/bypass key detection and the Stage 2 mirror. +UI-test seam: `ICCERY_TEST_SPOOL_LOG` — DEBUG `RecordingTargetSpooler` +appends one resolved-ticket line per page (D8). + ## Versioning `scripts/version.sh` is the single source: tag/describe → `ICCERY_RELEASE_TAG` (About shows `tag (marketing)`), `MARKETING_VERSION` = strict `X.Y.Z`, @@ -81,7 +96,12 @@ Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for rel ## Private ColorSync SPI 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`. Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`. -`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix. +One spool path remains (#201 D2): write **both** vocabularies on the native +path — locked AP_* (`AP_ColorMatchingMode` + `AP.ColorMatchingMode` = +`AP_ApplicationColorMatching`) **and** the Quartz dictionary +(`PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, +legacy `com.apple.print.PrintSettings.PMColorMatchingMode`, nested +`com.apple.print.printSettings` mirror). ## Gitea issue dependencies Use the `gitea` MCP (custom build with blocking support — verified working): diff --git a/README.md b/README.md index bb10879..edd0a1c 100644 --- a/README.md +++ b/README.md @@ -207,15 +207,16 @@ not in this tree. leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate. - **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`, `ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`, - `ProfcheckArgs`, `LpArgs`, `SpotReadArgs`, …). UI must not concatenate flags. + `ProfcheckArgs`, `SpotReadArgs`, …). UI must not concatenate flags. - **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal` must not replace the input profile on cancel or non-zero exit. - **Concurrency.** View models are `@MainActor`. No blocking I/O on the main actor. Swift 5.7 / macOS 12: `ObservableObject`, not Observation `@Observable`. -- **Print.** Unmanaged `lp` with ColorSync suppression - (`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel` - options win over derived CUPS keys. Never `lp -o raw`. +- **Print.** Unmanaged headless `NSPrintOperation` (#201 — no `lp`): + restore the captured `PrintTicket`, write both ColorSync vocabularies + (locked `AP_*` + Quartz `PMColorMatchingMode`), Stage 2 selections always + win over the ticket, draw 1:1 with interpolation off. - **SwiftUI ViewBuilder.** Xcode 14.2 / Swift 5.7 still has the ten-child limit. Split large `VStack`/`Group` trees (#146). diff --git a/docs/10-print-system.md b/docs/10-print-system.md index a959413..f7279bd 100644 --- a/docs/10-print-system.md +++ b/docs/10-print-system.md @@ -71,7 +71,10 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor | `media_types` | `Vec` | `#[serde(default)]`. Frontend fills `#printerMediaTypeSelect`. | | `supports_orientation` | `bool` | Always `true` on both Windows and Unix. | -### `PrintOptions` (mod.rs:42–55) — `Default` + `PartialEq + Eq` +### `PrintOptions` (mod.rs:42–55) — `Default` + `PartialEq + Eq` — historical v1 + +> The `lp`/`-o` columns below describe the v0.8.5 spool contract. v2.0 +> replaced the macOS `lp` path with a headless `NSPrintOperation` (#201). | Field | Type | Windows | Linux | macOS | |-------|------|---------|-------|-------| diff --git a/docs/11-print-macos.md b/docs/11-print-macos.md index ec2723a..7d246c7 100644 --- a/docs/11-print-macos.md +++ b/docs/11-print-macos.md @@ -19,7 +19,7 @@ This is the most implementation-sensitive chapter. A rewrite that opens System S - `NSWorkspace` open of the printer - `lpoptions` GUI -Linux/Windows do not share this panel. Default button title is **"Use Settings"** (macos.rs:439) — this is a settings-capture dialog, not a print-now dialog. Actual spooling is a later `lp` invocation. +Linux/Windows do not share this panel. Default button title is **"Use Settings"** (macos.rs:439) — this is a settings-capture dialog, not a print-now dialog. Actual spooling is a later, separate step — in v2.0 a headless `NSPrintOperation` (#201; v1 used `lp`). Must run on the Cocoa main thread. `show_printer_properties` (macos.rs:689-710): @@ -68,34 +68,31 @@ if pm_printer was created from ID: `PMPrinter` from `PMPrinterCreateFromPrinterID` is released with `PMRelease` on all exit paths (cancel, error, success). The NSPrinter fallback path leaves `pm_printer` null so no release. -### ColorSync suppression strategy — UI click to `lp` +### ColorSync suppression strategy — UI click to `NSPrintOperation` -End-to-end, **six independent layers**. All of them exist because no single Apple API is sufficient across Epson PDE / Canon PDE / `cgpdftoraster` / CUPS. +End-to-end, **seven independent layers**. All of them exist because no single Apple API is sufficient across Epson PDE / Canon PDE / `cgpdftoraster` / CUPS. v2.0 (#201) replaced the `lp` tail with a headless `NSPrintOperation` that replays a captured `PMPrintSettings`/`PMPageFormat` ticket — layer ⑦ is new. ``` [Preferences click] - show_printer_properties - run_on_main_thread - run_native_print_panel - ① PMSessionSetCurrentPMPrinter bind queue + PrintSessionViewModel.openPrinterPreferences + PrintPanelService.showProperties + runNativePanel + ① PMPrinterCreateFromPrinterID + PMSessionSetCurrentPMPrinter bind queue ② set_session_color_matching_mode SPI gray out PDE Color Matching ③ PMPrintSettingsSetValue AP_ColorMatchingMode + dotted ④ detect_driver_color_bypass → SetValue pre-select Canon/Epson/Gutenprint "off" ⑤ NSPrintInfo.printSettings dictionary same keys for AppKit PDEs - NSPrintPanel.runModalWithPrintInfo + ⑤′ ColorSyncSuppressor.applyQuartzMode PMColorMatchingMode + legacy + nested mirror + NSPrintPanel.runModal("Use Settings") user picks media / quality (color locked) - ⑥ PMPrintSettingsToOptions → filter → PrintPropertiesResult + ⑥ PMPrintSettingsToOptions → filter → Stage 2 mirror + ⑦ PMTicketBridge.serialise(printInfo) → PrintTicket ticket capture [frontend] - capturedCupsOptions[printer] = cups_options + capturedCupsOptions[queue] = mirror + capturedTickets[queue] = ticket [Print Target] - print_target_native → macos::print_target → build_lp_args - ALWAYS -o AP_ColorMatchingMode=AP_ApplicationColorMatching - ALWAYS -o AP.ColorMatchingMode=AP_ApplicationColorMatching - THEN captured cups_options as -o k=v - THEN media_type if not already present (detected key) - THEN detect_driver_color_bypass if no color-bypass key yet - THEN orientation / PageSize if not already present - lp -d -t "ICCery Target - …" … + PrintSessionViewModel → TargetPrintRequest → NativeTargetSpooler.spool + restore ticket → Stage 2 writes → NSPrintOperation.runOperation + (S1–S14 below — 1:1, device colour space, panels off) ``` Linux uses `-o raw` instead of AP_* flags. macOS **does not** use `-o raw`: a raw queue would skip the raster filter that actually understands `AP_ColorMatchingMode`. The macOS strategy is "tell the filter the application already matched color", not "skip the filter". @@ -161,7 +158,7 @@ AGENTS.md:118, macos.rs:82-88: | `AP_ColorSyncMatching` | **Avoided.** ColorSync applies the printer/display profile. Patches become color-managed. | | `AP_VendorColorMatching` | **Avoided.** Epson/Canon driver color engine (ICM inside the PDE). Same corruption. | -ICCery-CPU uses a **different** vocabulary (`APCustomColorMatching` / `APColorSync` / `APPrinterExtension` on `PMColorMatchingMode`). See the CPU section. Do not mix the two dictionaries. +ICCery-CPU uses a **different** vocabulary (`APCustomColorMatching` / `APColorSync` / `APPrinterExtension` on `PMColorMatchingMode`). See the CPU section. v1 kept the two dictionaries on separate paths (`lp` vs Quartz); v2.0 (#201, D2) has a single native path that writes **both** vocabularies — layer ⑤′ below. ### Layer ③ — `PMPrintSettingsSetValue` (macos.rs:357-375) @@ -204,6 +201,18 @@ print_settings.insert(, ) `NSString` is transmuted to `&AnyObject` for the dictionary (`macos.rs:414-416`). +### Layer ⑤′ — Quartz vocabulary (v2.0, #201 D2) + +With `lp` gone there is a single native path, and it carries **both** +dictionaries. `ColorSyncSuppressor.applyQuartzMode` additionally writes the +Quartz/`NSPrintOperation` vocabulary (docs/14 §7): + +- `PMColorMatchingMode` = `APCustomColorMatching` +- `PMCustomColorMatchingProfile` = `""` +- `com.apple.print.PrintSettings.PMColorMatchingMode` (legacy) +- the same keys inside the nested `com.apple.print.printSettings` + sub-dictionary of `printInfo.dictionary()` + ### Panel options (macos.rs:434-449) ``` @@ -231,9 +240,27 @@ After OK: Failure of `PMPrintSettingsToOptions` is a hard `Err`. +### Layer ⑦ — ticket serialise/restore (v2.0, #201 D3) + +The layer-⑥ flattening is what lost the ticket (#201 root cause 1): it kept +only a `key=value` string and `CupsOptionsFilter` drops every `com.apple.*` +key, so `com.apple.print.PrintSettings` never survived. After the layer-⑥ +capture, `PMTicketBridge.serialise(printInfo, queue:)` now snapshots the +whole ticket: + +1. `PMPrintSettingsCreateDataRepresentation(settings, &data, kPMDataFormatXMLDefault)` → `PrintTicket.printSettings`. +2. `PMPageFormatCreateDataRepresentation` → `PrintTicket.pageFormat`. +3. Binary-plist snapshot of `NSPrintInfo.dictionary()`, plist-filtered → `PrintTicket.printInfoPlist` — fallback only, never the primary restore path. + +`PrintTicket` is in-memory, session-only, keyed by queue. On spool, +`PMTicketBridge.restore` replays it into the job's `NSPrintInfo`: +`PM*CreateWithDataRepresentation` → `PMCopy*` → `PMSessionValidate*` → +`updateFromPM*`. A ticket captured for queue A is never replayed onto +queue B. + ### `RELEVANT_CUPS_OPTION_KEYS` (macos.rs:142-174) -Forwarded from the panel to `lp`: +Captured from the panel into the Stage 2 mirror (v1 forwarded them to `lp`): ``` Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType @@ -255,31 +282,38 @@ Duplex: Duplex, sides - Drops `collate`, `copies`, `pserrorhandler-requested`, `job-sheets` - **Keeps unknown non-`com.*` keys** (permissive: unknown driver keys survive) -### `build_lp_args` (macos.rs:518-643) +### Native spool — S1–S14 (v2.0, #201) -Always, even with `options=None`: +`NativeTargetSpooler.spool(_:)` (app target, `Sources/ICCery/Print/` — `ICCeryCore` stays AppKit-free, D1). One `TargetPrintRequest` per page, or one per run when `singleJobForAllPages` is on (D5): ``` -lp -d -t "ICCery Target - " - -o AP_ColorMatchingMode=AP_ApplicationColorMatching - -o AP.ColorMatchingMode=AP_ApplicationColorMatching - … captured / detected options … - +S1 NSPrintInfo() +S2 printInfo.printer = NSPrinter(name: queue) ?? NSPrinter(name: displayName) [best effort] +S3 PMTicketBridge.makePrinter(queue:) → bind(printer:to:) ① + PMSessionDefault* +S4 ticket != nil → PMTicketBridge.restore(ticket, into: printInfo) ⑦′ +S5 TicketWriteResolver.resolve(...) → apply to PMPrintSettings + mirror dict ③④⑤+D2 +S6 paper override → PMTicketBridge.applyPaper(token:…) +S7 orientation → printInfo.orientation + orientation-requested +S8 suppressor.applySPIMode(session) ② +S9 Cocoa geometry: margins 0, pagination .clip, scaling 1.0, centering off, + jobDisposition .spool (or .save + jobSavingURL under the PDF harness) +S10 TargetRasterLoader.load(each page) → device-tagged CGImage + pointSize +S11 TargetPageCanvasView(pages:paperSize: printInfo.paperSize) +S12 NSPrintOperation(view:printInfo:) — panels off, jobTitle set +S13 operation.runOperation() → false ⇒ throw TargetSpoolError.operationFailed +S14 PMRelease the printer on every path (defer) ``` -Order after the two AP_* flags: - -1. Parse `opts.cups_options` into `-o k=v`, record lowercased keys in `added_keys`. -2. If `media_type` set and none of `mediatype` / `cnijmediatype` / `epij_medi` / `stpmediatype` already added: `detect_media_type_key(lpoptions)` and add it. -3. If no color-bypass key yet (`cnijintent2`, `cnijintent`, `epij_cmat`, `epij_ccor`, `epij_oscolmat`, `colorcorrection`, `stpcolorcorrection`, `epsoncolormode`): `detect_driver_color_bypass` and add. **Not gated on `ppd_uncorrected_passthrough`.** -4. Orientation → `orientation-requested=4|3` unless already present. -5. `PageSize=` unless `pagesize` already present. - -`ppd_uncorrected_passthrough` is stored from the panel but **does not change macOS lp flags**. There is no `-o raw` on macOS. - -### `print_target` (macos.rs:646-677) - -Exists-check, `build_lp_args`, `Command::new("lp").args(&args).output()`. Error wrapping same pattern as Unix (`"macOS CUPS print job failed: …"`). +`TicketWriteResolver` produces the resolved write list as a pure value, in a +locked order: both `AP_*` keys (locked) → the three Quartz keys (⑤′) → +`PageSize` → the detected media key → the detected quality key → the driver +colour bypass → `orientation-requested`. Stage 2 overrides **always win** +over the rehydrated ticket (D6 — the v1 captured-wins inversion is gone); +`raw` can never appear because there is no `lp`. Panels stay off +(`showsPrintPanel` / `showsProgressPanel` false, `canSpawnSeparateThread` +false); a multi-page job is one `TargetPageCanvasView` driven by +`knowsPageRange` / `rectForPage`, drawing each page 1:1 top-left anchored +with interpolation and antialiasing disabled (D9, docs/14 §6). ### Cancellation as `None` @@ -295,19 +329,26 @@ The rewrite: - `display_name` fallback for `NSPrinter::printerWithName`. - Private SPI to lock Color Matching. - Dual AP_* keys (underscore + dotted). -- Driver-specific PPD bypass pre-selected and re-applied on `lp`. -- Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions`. +- Driver-specific PPD bypass pre-selected in the panel and re-applied on the spool ticket (v1 re-applied it on `lp`). +- Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions` (Stage 2 mirror) plus the `PrintTicket` serialise (layer ⑦, v2.0). `PMPrinter` lifetime is explicit `PMRelease` on every path. SPI is `dlsym`'d so missing symbols on old OS X do not prevent launch. Panel **must** be main-thread (`MainThreadMarker::new().ok_or("Print panel must be invoked on the main thread")`). -### macOS tests (macos.rs:712-863 + tests.rs:278-318) +### macOS tests — historical v1 (macos.rs:712-863 + tests.rs:278-318) - Filter drops `com.apple.*`, `collate`, `copies`, `AP_ColorMatchingMode`, empty `AP_D_InputSlot`; keeps `MediaType`, `EPIJ_CMat`, `PageSize`, `CNIJIntent2`, `ColorCorrection`. - `extract_media_type_from_options` prefers `MediaType` then `EPIJ_Medi`. -- `build_lp_args` always contains both AP_* flags; captured options win over explicit `media_type` / orientation / auto color-bypass; last arg is the TIFF path. +- `build_lp_args` always contains both AP_* flags; captured options win over explicit `media_type` / orientation / auto color-bypass; last arg is the TIFF path. (**Historical v1** — the captured-wins behaviour recorded here is #201 root cause 2; v2.0 inverts it: Stage 2 always wins, D6.) - `detect_driver_color_bypass` Canon `4`, Epson `3`, Gutenprint `Uncorrected`. - Missing TIFF errors. +v2.0 replacements (`TicketWriteResolverTests`, `PrintTicketTests`, +`TargetRasterTests`, `TargetCanvasGeometryTests`, `NativeSpoolPDFTests`): +both AP_* keys locked + all three Quartz keys always present; Stage 2 +always wins (D6); `raw` never emitted; ticket serialise/restore +round-trips bytes; 1:1 geometry and draw flags asserted against a real +`.save`-to-PDF `NSPrintOperation`. + --- ## ColorSync suppression — complete key/SPI/flag roster @@ -338,7 +379,12 @@ PMRelease AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPanelOptions::all` + `ShowsPageSetupAccessory`. -### CUPS / lp flags +### CUPS / lp flags — historical v1 + +> No `lp` invocation exists on the v2.0 target-print path (#201 — `LpArgs`, +> `CupsService.printTarget` and the `lp` fixture are deleted). This table +> records the v1 `lp -o` contract for reference only; the live write list is +> `TicketWriteResolver`'s locked order (§native spool above). | Flag | Platform | When | |------|----------|------| diff --git a/docs/13-print-linux.md b/docs/13-print-linux.md index f99728d..6a933d3 100644 --- a/docs/13-print-linux.md +++ b/docs/13-print-linux.md @@ -53,7 +53,10 @@ First-match order: `CNIJMediaType` > `EPIJ_Medi` > `StpMediaType` > `MediaType`. -### Linux `build_lp_args` / `print_target` (unix.rs:362-457) +### Linux `build_lp_args` / `print_target` (unix.rs:362-457) — historical v1 + +> v0.8.5 Linux spool contract, kept for reference. ICCery v2 is macOS-only +> and its native path spools via `NSPrintOperation`, not `lp` (#201). ``` lp -d -t "ICCery Target - " diff --git a/docs/14-iccery-cpu-targetprint.md b/docs/14-iccery-cpu-targetprint.md index cde65b4..fdb4dc9 100644 --- a/docs/14-iccery-cpu-targetprint.md +++ b/docs/14-iccery-cpu-targetprint.md @@ -8,7 +8,7 @@ Separate native macOS AppKit app. Spec: `/tmp/ICCery-CPU/SPEC.md`. Binary name ` ### Why it exists -ICCery's Tauri path spools TIFF via `lp` and never goes through Quartz. That is correct for "don't let ColorSync touch the file", but: +ICCery's Tauri path spooled TIFF via `lp` and never went through Quartz (v1; v2.0 spools via a headless `NSPrintOperation` in ICCery proper — #201). That is correct for "don't let ColorSync touch the file", but: - No 1:1 physical-size preview - Windows-style `StretchDIBits` scaler (macOS `lp` may still scale inside the filter) @@ -100,7 +100,7 @@ Injected into: - `printInfo.dictionary()["com.apple.print.PrintSettings.PMColorMatchingMode"]` (legacy) - nested `com.apple.print.printSettings` dictionary, same keys -**This is not `AP_ApplicationColorMatching`.** TargetPrint talks to Quartz/`NSPrintOperation`. ICCery talks to the CUPS `lp` ticket / `cgpdftoraster`. A rewrite that unifies them must keep both vocabularies or prove one is honored on both paths. +**This is not `AP_ApplicationColorMatching`.** TargetPrint talks to Quartz/`NSPrintOperation`. v1 ICCery talked to the CUPS `lp` ticket / `cgpdftoraster`; a rewrite that unifies them must keep both vocabularies or prove one is honored on both paths. **v2.0 (#201, D2) resolved this:** the Quartz vocabulary in this section is now live in ICCery proper — `ColorSyncSuppressor.applyQuartzMode` / `TicketWriteResolver` write `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, the legacy `com.apple.print.PrintSettings.PMColorMatchingMode` and the nested `com.apple.print.printSettings` mirror **alongside** the locked AP_* keys on the single native spool path. Panel policy (`ColorMatching.configurePanel`): @@ -125,7 +125,7 @@ static inline const char *TPCupsGetPPD(const char *name) { - AirPrint (SPEC §10.2) if any of: URI `apple-airprint://`; PPD `*APAirPrint: True`; make Apple + model contains AirPrint; `ipps://` **and** PPD text contains `airprint`. Persistent warning badge; unmanaged color cannot be trusted. Tests in `AirPrintTests.swift`. - Vendor bypass (SPEC §10.3) — **different keys from ICCery's lpoptions detector:** -| Vendor | TargetPrint keys | ICCery macOS `lp` keys | +| Vendor | TargetPrint keys | ICCery macOS `lpoptions`-detected keys (v1: `lp -o`) | |--------|------------------|------------------------| | Epson | `ColorModel=RGB`, `EPSONColorControls=Off` | `EPIJ_CMat=3` / `EPIJ_CCor=0` / `EpsonColorMode=Off` | | Canon | `CNColorMatching=None` | `CNIJIntent2=4` / `CNIJIntent=4` | @@ -176,9 +176,9 @@ Command::new("/Applications/TargetPrint.app/Contents/MacOS/TargetPrint") CI publishes `vendor-iccery.zip` with `macos-x86_64` / `macos-aarch64` / `macos-universal` app bundles to drop into `src-tauri/targetprint/`. -Suggested ICCery integration: +Suggested ICCery integration (**superseded** — v2.0 took option 2's rendering model in-process instead; #201 adopted Quartz/`NSPrintOperation` spooling inside ICCery proper and removed `lp` entirely. The `--job` companion-app contract remains the v2.1 plan for `ICCeryPrintKit`, #16): -1. Keep current `lp` path as the headless/fast path (and the only path on Linux). +1. ~~Keep current `lp` path as the headless/fast path (and the only path on Linux).~~ 2. On macOS, Preferences / Print can spawn TargetPrint with a `TargetJob` built from `PrintOptions` + TIFF list + `forceUnmanagedColor: true` + `lockColorManagement: true`. 3. Do not `CREATE_NO_WINDOW` (macOS); do not `wait()`. Cleanup of the JSON is ICCery's job after process exit, or leave in `/tmp` as an audit trail (SPEC §13). @@ -186,14 +186,14 @@ Suggested ICCery integration: | Concern | ICCery `macos.rs` | TargetPrint | Rewrite recommendation | |---------|-------------------|-------------|------------------------| -| Spool | `lp` TIFF | Quartz `NSPrintOperation` | Keep `lp` for unattended; TargetPrint for preview+panel | -| ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** if using NSPrintOperation; keep AP_* on `lp` | +| Spool | `lp` TIFF | Quartz `NSPrintOperation` | Quartz `NSPrintOperation` — **adopted in v2.0 via #201** (`NativeTargetSpooler`; `lp` removed from the target-print path) | +| ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** — **adopted in v2.0 via #201** (D2: the single native path carries AP_* and the Quartz §7 vocabulary) | | Lock PDE UI | private `PMSessionSetColorMatchingMode*` SPI | strip Color Matching accessories | Use SPI **and** strip; accessories API misses driver PDEs (the #188 failure mode) | | Canon off | `CNIJIntent2=4` | `CNColorMatching=None` | Apply both | | Epson off | `EPIJ_CMat=3` / `EPIJ_CCor=0` | `EPSONColorControls=Off` + `ColorModel=RGB` | Apply both; prefer captured panel values | -| Geometry | none (filter decides) | 1:1 pt from DPI | TargetPrint (or do not scale in GDI/`lp`) | -| Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges | -| AirPrint | none | detected + warned | Port detector into ICCery printer list | +| Geometry | none (filter decides) | 1:1 pt from DPI | 1:1 pt from DPI — **adopted in v2.0 via #201** (`TargetRasterLoader`/`TargetPageCanvasView`, docs/14 §6) | +| Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges — **adopted in v2.0 via #201** (interpolation/antialias off in `TargetPageCanvasView.draw`) | +| AirPrint | none | detected + warned | Ported into ICCery — **adopted in v2.0 via #201/#202** (`lpstat -v` + PPD §10.2 rules, Stage 2 `airPrintWarningBadge`) | | Linux | `-o raw` | n/a (macOS only) | Keep raw + PPD fallback | | Windows | GDI ICM_OFF | n/a | Keep GDI; do not route through TargetPrint | diff --git a/docs/25-rewrite-notes.md b/docs/25-rewrite-notes.md index 7b95dd7..045df80 100644 --- a/docs/25-rewrite-notes.md +++ b/docs/25-rewrite-notes.md @@ -85,7 +85,7 @@ Lock these before rewriting UI: 4. `parseProfcheckReport` JSON + legacy + empty→warning 5. Color bypass detector: Canon/Epson/Gutenprint samples 6. `filter_cups_options_string` drops `com.apple.*`, keeps `EPIJ_CMat` -7. `build_lp_args` always emits both AP_* keys +7. `build_lp_args` always emits both AP_* keys (**historical v1** — superseded by `TicketWriteResolverTests`, #201) 8. Threshold validation `good < warning` 9. `snapshot_ti3` 1-based and removes canonical 10. DEVMODE round-trip size diff --git a/docs/26-v2-mac-ticket-plan.md b/docs/26-v2-mac-ticket-plan.md index 03ce20c..d3f7628 100644 --- a/docs/26-v2-mac-ticket-plan.md +++ b/docs/26-v2-mac-ticket-plan.md @@ -29,6 +29,12 @@ ## Locked product decisions +> **Errata (M12, #201):** item 4's `lp` spool was replaced in v2.0 by a +> headless `NSPrintOperation` replaying the captured `PMPrintSettings` +> ticket, and the Quartz vocabulary of item 5 is now written **alongside** +> AP_* on the single native path (D2). "Never mix" no longer applies inside +> ICCery proper; it still governs the future `ICCeryPrintKit` boundary. + 1. **Stack:** SwiftUI (`@Observable`, `@MainActor` view models) + AppKit for printing/panels. No Tauri, no Rust, no WebView. 2. **Floor:** macOS 14.0, universal `arm64` + `x86_64`. 3. **AGPL:** never link Argyll. Spawn with piped stdio + `ARGYLL_NOT_INTERACTIVE=1` on **every** child (streaming and captured). @@ -120,7 +126,7 @@ Issues 1–6. **Hardware:** none. Issues 7–11. -### M3 — macOS unmanaged printing (`lp` path) +### M3 — macOS unmanaged printing (`lp` path) — historical v1, superseded by #201 **CI/mock:** `lpstat`/`lpoptions` parsers; `build_lp_args` golden vectors (both `AP_*` always present); option filter; cancel → nil. **Hardware:** Preferences opens **driver PDE** on a real Epson or Canon queue; colour matching off/grayed; printed TIFF measures unmanaged (no ColorSync transform). @@ -376,7 +382,7 @@ Six layers (spec [11](11-print-macos.md) roster): - Deps: 12, 13. - Test CI: injectable dlsym order; filter fixtures. Hardware: PDE colour grayed/off on Epson **and** Canon. -**Issue 15 — `lp` spool path** +**Issue 15 — `lp` spool path — historical v1, superseded by #201 native spool** Labels: `Feature/Backend`, `Priority/High` Milestone: M3 @@ -583,7 +589,7 @@ Milestone: **Later** - Separate Swift package `ICCeryPrintKit`. **Zero** deps on wizard types. - Public API: `TargetJob` v1 JSON + `--job` CLI (fire-and-forget) **and** in-process `NSPrintOperation`. Preserve extractability to a standalone app. -- ColorSync vocabulary is **not** the `lp` path: `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, legacy `com.apple.print.PrintSettings.PMColorMatchingMode`. **Never mix with `AP_ApplicationColorMatching`.** +- ColorSync vocabulary is **not** the `lp` path: `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, legacy `com.apple.print.PrintSettings.PMColorMatchingMode`. **Never mix with `AP_ApplicationColorMatching`** — amended by #201 (D2): with `lp` gone, ICCery's single native path writes **both** vocabularies; this constraint now governs only the future `ICCeryPrintKit` boundary. - Vendor keys (separate table from issue 14): Epson `ColorModel=RGB` + `EPSONColorControls=Off`; Canon `CNColorMatching=None`; HP `ColorModel=RGB` + `HPColorControl=Off`. - Geometry: 72pt=1in, no `backingScaleFactor`, interpolation `.none`, antialias off, pixel-integrity seam test. Resolve SPEC contradiction: job JSON `"centered": true` vs draw “no centering” — **lock “no centering, scale 1.0” for profiling targets.** - AirPrint detection → persistent warning. diff --git a/docs/issue-printer-quality-ignored.md b/docs/issue-printer-quality-ignored.md new file mode 100644 index 0000000..aa1e84c --- /dev/null +++ b/docs/issue-printer-quality-ignored.md @@ -0,0 +1,105 @@ +# Issue #201 — Printer quality and media type ignored when printing target + +> Analysis doc referenced by issue **[Bug/Critical] Printer quality and media +> type settings ignored when printing target — lp path missing PMPrintSettings +> ticket** (#201). Line numbers refer to `develop` @ `0513b27` (pre-M12 code, +> verified 2026-09-17). The fix landed on `milestone/m12-native-spool` — see +> "Resolution" below. + +## 0. Lifecycle trace — what the code did (pre-#201, historical v1) + +``` +Stage2View "btnPrinterProperties" + → PrintSessionViewModel.openPrinterPreferences() :141 + → PrintPanelService.showProperties() :59 + → runNativePanel() :85 + ① PMPrinterCreateFromPrinterID + PMSessionSetCurrentPMPrinter :96–113 + ▸ applyInitialSelections (PMPrintSettingsSetValue ×4) :216 + ▸ applyPaperPageFormat (PMPaper match → PMCopyPageFormat) :258 + ②–⑤ ColorSyncSuppressor SPI / AP_* / bypass / mirror :152–163 + NSPrintPanel.runModal("Use Settings") :173 + ⑥ PMPrintSettingsToOptions → CupsOptionsFilter → String :185–191 + ✗ THE TICKET IS DISCARDED HERE — only the flattened `k=v` string survives + → capturedCupsOptions[queue] = String :174 + +Stage2View "btnPrintAll" + → PrintSessionViewModel.printAllPages(from:) :205 + → spool(page, index:) :263 + → CupsService.printTarget() CupsService.swift:170 + → LpArgs.build() LpArgs.swift:42 + → ProcessManager.runCaptured("/usr/bin/lp", argv) +``` + +## Root causes (pre-#201, historical v1) + +Two defects, both confirmed by reading the code: + +### 1. Ticket loss + +`ColorSyncSuppressor.captureOptions` (:133) was the only capture path. It +funneled the whole `PMPrintSettings` object through `PMPrintSettingsToOptions` +→ a space-separated `key=value` string, then `CupsOptionsFilter.filter` +**dropped every `com.apple.*` key** (`CupsOptionsFilter.swift:49`) — i.e. it +deliberately discarded `com.apple.print.PrintSettings`, the ticket the +Epson/Canon raster filter reads. `lp` cannot reconstruct it, so the driver +fell back to plain paper / normal quality. + +### 2. Override inversion + +`LpArgs.build` recorded every captured key in `addedKeys` (:67–73) and then +*suppressed* the explicit Stage 2 value when the key was already present — +media (:81), quality (:90), bypass (:102), orientation (:109), `PageSize` +(:117). A Stage 2 dropdown change after a panel capture was silently ignored. + +### Dismissed hypothesis + +**argv tokenisation is safe** — `ProcessManager.runCaptured` hands an argv +array to `Process`, never a shell. Space-containing `-o` values were +preserved; this was checked and dismissed in the issue investigation and +re-verified during the M12 audit. + +## Locked decisions (D1–D13) + +Decisions taken by the M12 megaplan; **locked — do not re-derive.** +(`lp`/`LpArgs` mentions in this table name the deleted historical v1 path.) + +| # | Decision | Consequence | +|---|----------|-------------| +| D1 | Spooler lives in the **app target** (`Sources/ICCery/Print/`), not in `CupsService` | `ICCeryCore` stays AppKit-free (AGENTS §Package layout). `CupsService.printTarget` is **deleted**; `CupsService` keeps enumeration/capabilities/PPD only. The issue body's "in `CupsService`" is superseded — posted as errata on #201 | +| D2 | Write **both** ColorSync vocabularies on the native path | AP_* (locked) **and** Quartz `PMColorMatchingMode=APCustomColorMatching` + `PMCustomColorMatchingProfile=""` + legacy `com.apple.print.PrintSettings.PMColorMatchingMode` + the nested `com.apple.print.printSettings` mirror. AGENTS §"Private ColorSync SPI — never mix" is **amended**: with `lp` gone there is one path and it carries both dictionaries (docs/14 decision table, "Set both if using NSPrintOperation") | +| D3 | Ticket = `PMPrintSettings` **Data** + `PMPageFormat` **Data** + an `NSPrintInfo.dictionary()` plist fallback | `kPMDataFormatXMLDefault`. Restored via `PM*CreateWithDataRepresentation` → `PMCopy*` → `PMSessionValidate*` → `updateFromPM*`. Byte round-trip is unit-testable | +| D4 | Delete `lp`; **keep** `CupsParsers` + `CupsOptionsFilter` | Parsers still drive capabilities (#183/#180/#181), media/quality key detection, driver-bypass detection, and the panel→Stage-2 apply-back mirror (#186). Only `LpArgs` dies | +| D5 | Job granularity is **user-selectable**, default **one job per page** | New session-only `@Published var singleJobForAllPages = false`; Stage 2 checkbox `chkSingleSpoolJob`. Default preserves today's per-page notices and error attribution | +| D6 | **Stage 2 always wins** over the rehydrated ticket | Unconditional overwrite of paper / media / quality / orientation. Mitigation for vendor companion-key desync: only write when the value differs from the ticket's current value, and log both (R4) | +| D7 | Spool **silently** | `showsPrintPanel = false`, `showsProgressPanel = false`, `canSpawnSeparateThread = false`. Feedback stays on the existing `isPrinting` + `Notice`. No new system modal → XCUITest unaffected | +| D8 | Verification = **recorder seam + PDF harness** | DEBUG `RecordingTargetSpooler` writes resolved ticket lines to `ICCERY_TEST_SPOOL_LOG` (replaces `ICCERY_TEST_LP_ARGV`); a real `NSPrintOperation` with `jobDisposition = .save` produces a PDF for geometry assertions | +| D9 | Never scale; **warn + spool** | 1:1 always, anchored at the paper's top-left. `warn` when the DPI-derived size disagrees with the manifest `width_mm`/`height_mm` by >0.5 mm; warning `Notice` when the image exceeds the paper | +| D10 | Device colour spaces implemented **and verified** for 1/3/4 channels at 8 and 16 bpc | Re-tag the decoded `CGImage` into `DeviceGray`/`DeviceRGB`/`DeviceCMYK` reusing the source `dataProvider` — **no resample, no bit-depth change**. Hardware gate covers RGB-8, RGB-16, DeviceGray-8 and CMYK-16 | +| D11 | AirPrint detection + Stage 2 banner **in scope**, as its own issue | docs/14 §10.2 rules. Blocked-by #201 → filed as #202 | +| D12 | Extract a **shared** `PMTicketBridge` | Single `@MainActor enum` owning every `PM*` call, with one documented `PMRelease` rule. `PrintPanelService` migrates onto it | +| D13 | New milestone **M12 — Native print spool** | `milestone/m12-native-spool` from `develop`; #201 moves off the shipped M11 (id 34) | + +## Resolution + +Implemented on `milestone/m12-native-spool` (Phases 1–5, PRs #203–#207). +(`LpArgs`/`lp` references below name deleted historical v1 artefacts.) + +- `PMTicketBridge` owns every `PM*` call; `PrintPanelService.showProperties` + returns `PanelCaptureResult` carrying a `PrintTicket` (layer ⑦ serialise). +- `NativeTargetSpooler` rehydrates the ticket into a fresh `NSPrintInfo` + (S1–S14, docs/11 §native spool) and spools a headless `NSPrintOperation` + over `TargetPageCanvasView` — 1:1, top-left anchored, interpolation off. +- `TicketWriteResolver` replaces `LpArgs.build`: Stage 2 always wins (D6), + both ColorSync vocabularies written (D2), no `raw` can ever appear. +- `LpArgs`, `CupsService.printTarget`, the `lp` fixture and + `ICCERY_TEST_LP_ARGV` are deleted; `RecordingTargetSpooler` writes to + `ICCERY_TEST_SPOOL_LOG` for tests (D8). +- AirPrint detection + Stage 2 warning badge shipped as #202 (D11). + +## References + +- `Sources/ICCery/Print/{PMTicketBridge,PrintTicket,NativeTargetSpooler,TicketWriteResolver,TargetRaster,TargetPageCanvasView}.swift` +- `Packages/ICCeryCore/Sources/ICCeryCore/Print/{CupsService,CupsParsers,CupsOptionsFilter,PrinterModels,ColorMatchingAttempts}.swift` +- `docs/11-print-macos.md` §ColorSync suppression — UI click to `NSPrintOperation` +- `docs/14-iccery-cpu-targetprint.md` §6–§7 (geometry + Quartz vocabulary) +- M12 megaplan (`plan-be303e6f3f5e89da`) -- 2.39.5 From 2407abfd9e5387421b6538244359b43cdec7fc62 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 17 Sep 2026 18:03:00 +0100 Subject: [PATCH 08/14] chore: bump MARKETING_VERSION for M12 native spool release --- project.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project.yml b/project.yml index be46970..0f622e3 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.1" + MARKETING_VERSION: "2.0.2" 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 From 226e7b2a781a7f27a799e3e0a4399d6a9c2a1078 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 17 Sep 2026 20:49:52 +0100 Subject: [PATCH 09/14] test(print): flip canvas coordinate space to bottom-up for NSPrintOperation compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TargetPageCanvasView.isFlipped: true → false (bottom-up CoreGraphics origin) - TargetCanvasGeometryTests: assertion updated to XCTAssertFalse(view.isFlipped) --- Sources/ICCery/Print/TargetPageCanvasView.swift | 2 +- Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ICCery/Print/TargetPageCanvasView.swift b/Sources/ICCery/Print/TargetPageCanvasView.swift index 52d638c..eeb6bdb 100644 --- a/Sources/ICCery/Print/TargetPageCanvasView.swift +++ b/Sources/ICCery/Print/TargetPageCanvasView.swift @@ -29,7 +29,7 @@ final class TargetPageCanvasView: NSView { /// Flipped: the CoreGraphics coordinate space is correctly oriented /// top-down, so page 1 is the TOP rect. - override var isFlipped: Bool { true } + override var isFlipped: Bool { false } override var isOpaque: Bool { true } override func knowsPageRange(_ range: NSRangePointer) -> Bool { diff --git a/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift b/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift index ce58e6f..2a802e4 100644 --- a/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift +++ b/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift @@ -39,7 +39,7 @@ final class TargetCanvasGeometryTests: XCTestCase { let view = TargetPageCanvasView( pages: [raster(), raster(), raster()], paperSize: paperSize) - XCTAssertTrue(view.isFlipped) + XCTAssertFalse(view.isFlipped) let page1 = view.rectForPage(1) let page3 = view.rectForPage(3) -- 2.39.5 From ddb9245f446eecc4f41a1471690c90d4b85ca760 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 17 Sep 2026 21:53:17 +0100 Subject: [PATCH 10/14] fix(print): anchor raster at page top in bottom-up canvas space (#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 226e7b2 un-flipped the canvas but left destinationRect anchored at pageRect.minY — the page's BOTTOM edge in bottom-up space — so sub-page targets printed at the foot of the sheet and oversize targets clipped their top rows. Anchor at pageRect.maxY - height, restoring the spec'd top-left anchor (docs/14 §6, D9). Adds the missing regression coverage: the geometry test now asserts the top anchor with a sub-page raster, and the PDF harness gains a banded (red-over-blue) fixture that fails on either a mirrored draw or a bottom anchor — solid fills could catch neither. --- .../ICCery/Print/TargetPageCanvasView.swift | 10 +- .../ICCeryCoreTests/NativeSpoolPDFTests.swift | 179 ++++++++++++++---- .../TargetCanvasGeometryTests.swift | 27 ++- .../TargetRasterFixtures.swift | 41 ++-- 4 files changed, 200 insertions(+), 57 deletions(-) diff --git a/Sources/ICCery/Print/TargetPageCanvasView.swift b/Sources/ICCery/Print/TargetPageCanvasView.swift index eeb6bdb..f63ae8d 100644 --- a/Sources/ICCery/Print/TargetPageCanvasView.swift +++ b/Sources/ICCery/Print/TargetPageCanvasView.swift @@ -27,8 +27,10 @@ final class TargetPageCanvasView: NSView { fatalError("TargetPageCanvasView is code-only") } - /// Flipped: the CoreGraphics coordinate space is correctly oriented - /// top-down, so page 1 is the TOP rect. + /// Bottom-up CoreGraphics space: `CGContext.draw(_:in:)` renders + /// the raster upright — a flipped view prints mirrored (#211). + /// Page 1 is the BOTTOM band of the frame, the non-flipped + /// pagination convention. override var isFlipped: Bool { false } override var isOpaque: Bool { true } @@ -37,7 +39,7 @@ final class TargetPageCanvasView: NSView { return true } - /// 1-based page → its paper-sized rect, stacked top-down. + /// 1-based page → its paper-sized rect, stacked bottom-up. override func rectForPage(_ page: Int) -> NSRect { NSRect(x: 0, y: CGFloat(page - 1) * paperSize.height, @@ -53,7 +55,7 @@ final class TargetPageCanvasView: NSView { let pageRect = rectForPage(page) let size = pages[page - 1].pointSize return NSRect(x: Self.snap(pageRect.minX), - y: Self.snap(pageRect.minY), + y: Self.snap(pageRect.maxY - size.height), width: size.width, height: size.height) } diff --git a/Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift b/Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift index 51984ae..4e2b7c5 100644 --- a/Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift +++ b/Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift @@ -40,21 +40,8 @@ final class NativeSpoolPDFTests: XCTestCase { pixelBytes: patchBytes, in: tempRoot) let raster = try TargetRasterLoader.load(tiff: tiff) - let info = NSPrintInfo() - info.paperSize = paperSize - info.orientation = .portrait - info.topMargin = 0 - info.bottomMargin = 0 - info.leftMargin = 0 - info.rightMargin = 0 - info.horizontalPagination = .clip - info.verticalPagination = .clip - info.scalingFactor = 1.0 - info.isHorizontallyCentered = false - info.isVerticallyCentered = false - info.jobDisposition = .save let pdfURL = tempRoot.appendingPathComponent("spool.pdf") - info.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = pdfURL + let info = makeSaveInfo(pdfURL: pdfURL) let canvas = TargetPageCanvasView( pages: [raster, raster], paperSize: paperSize) @@ -88,29 +75,7 @@ final class NativeSpoolPDFTests: XCTestCase { ) throws { let width = Int(ceil(mediaBox.width)) let height = Int(ceil(mediaBox.height)) - let rep = try XCTUnwrap(NSBitmapImageRep( - bitmapDataPlanes: nil, - pixelsWide: width, - pixelsHigh: height, - bitsPerSample: 8, - samplesPerPixel: 4, - hasAlpha: true, - isPlanar: false, - colorSpaceName: .deviceRGB, - bytesPerRow: 0, - bitsPerPixel: 0)) - let context = try XCTUnwrap( - NSGraphicsContext(bitmapImageRep: rep)) - let cg = context.cgContext - let transform = page.getDrawingTransform( - .mediaBox, - rect: CGRect(x: 0, y: 0, width: width, height: height), - rotate: 0, preserveAspectRatio: true) - NSGraphicsContext.saveGraphicsState() - NSGraphicsContext.current = context - cg.concatenate(transform) - cg.drawPDFPage(page) - NSGraphicsContext.restoreGraphicsState() + let rep = try renderPageToBitmap(page, mediaBox: mediaBox) func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) { let color = try XCTUnwrap( @@ -161,4 +126,144 @@ final class NativeSpoolPDFTests: XCTestCase { XCTAssertEqual(outside.1, 1, accuracy: 0.04) XCTAssertEqual(outside.2, 1, accuracy: 0.04) } + + /// #211 — a top/bottom banded block proves the page is drawn + /// upright AND anchored at the paper's top edge: a mirrored draw + /// swaps the bands; a bottom-anchored draw leaves white above the + /// block instead of below. + func testSavePDFDrawsPageUprightTopAnchored() throws { + try XCTSkipIf( + ProcessInfo.processInfo.environment["ICCERY_SKIP_PRINT_PDF"] + == "1", + "ICCERY_SKIP_PRINT_PDF=1 — print system unavailable") + + // 144×144 px @ 72 dpi → a 144×144 pt block, top half red, + // bottom half blue. + let topBytes: [UInt8] = [230, 40, 50] + let bottomBytes: [UInt8] = [40, 50, 230] + let tiff = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 144, height: 144), dpi: 72, + components: 3, bitsPerComponent: 8, + pixelBytes: topBytes, + bottomHalfPixelBytes: bottomBytes, in: tempRoot) + let raster = try TargetRasterLoader.load(tiff: tiff) + + let pdfURL = tempRoot.appendingPathComponent("bands.pdf") + let info = makeSaveInfo(pdfURL: pdfURL) + let canvas = TargetPageCanvasView( + pages: [raster], paperSize: paperSize) + let operation = NSPrintOperation(view: canvas, printInfo: info) + operation.showsPrintPanel = false + operation.showsProgressPanel = false + operation.canSpawnSeparateThread = false + XCTAssertTrue(operation.run(), + "NSPrintOperation.save failed") + + let document = try XCTUnwrap( + CGPDFDocument(pdfURL as CFURL)) + let page = try XCTUnwrap(document.page(at: 1)) + let mediaBox = page.getBoxRect(.mediaBox) + let rep = try renderPageToBitmap(page, mediaBox: mediaBox) + + func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) { + let color = try XCTUnwrap( + rep.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB)) + return (color.redComponent, + color.greenComponent, + color.blueComponent) + } + func matches( + _ p: (Double, Double, Double), _ rgb: [UInt8] + ) -> Bool { + abs(p.0 - Double(rgb[0]) / 255) < 0.04 + && abs(p.1 - Double(rgb[1]) / 255) < 0.04 + && abs(p.2 - Double(rgb[2]) / 255) < 0.04 + } + + // Painted region = the page fill inset by the queue's + // unprintable margin (R10); minimum opaque y is its top edge + // in the rendered bitmap (same convention as the 1:1 test). + let width = Int(ceil(mediaBox.width)) + let height = Int(ceil(mediaBox.height)) + var topX = width, topY = height + for y in 0.. 0.5 { + topX = min(topX, x) + topY = min(topY, y) + } + } + XCTAssertLessThan(topY, height, "PDF page rendered empty") + + // The 144 pt block hangs from the page's top edge: top-band + // colour for the first 72 pt, bottom-band colour for the + // next 72 pt, then the white page fill. + XCTAssertTrue( + try matches(pixel(topX + 10, topY + 10), topBytes), + "block top is not the top-band colour — mirrored draw?") + XCTAssertTrue( + try matches(pixel(topX + 10, topY + 82), bottomBytes), + "block bottom is not the bottom-band colour — mirrored draw?") + XCTAssertTrue( + try matches(pixel(topX + 10, topY + 134), bottomBytes), + "block does not reach 144 pt below the painted top edge") + let below = try pixel(topX + 10, topY + 160) + XCTAssertEqual(below.0, 1, accuracy: 0.04) + XCTAssertEqual(below.1, 1, accuracy: 0.04) + XCTAssertEqual(below.2, 1, accuracy: 0.04) + } + + /// Print settings shared by the save-PDF harness tests: A4, zero + /// margins, `.clip` pagination, 1:1, no centring, `.save` to + /// `pdfURL`. + private func makeSaveInfo(pdfURL: URL) -> NSPrintInfo { + let info = NSPrintInfo() + info.paperSize = paperSize + info.orientation = .portrait + info.topMargin = 0 + info.bottomMargin = 0 + info.leftMargin = 0 + info.rightMargin = 0 + info.horizontalPagination = .clip + info.verticalPagination = .clip + info.scalingFactor = 1.0 + info.isHorizontallyCentered = false + info.isVerticallyCentered = false + info.jobDisposition = .save + info.dictionary()[ + NSPrintInfo.AttributeKey.jobSavingURL] = pdfURL + return info + } + + /// Render a saved-PDF page into a bitmap at 1 px/pt. + private func renderPageToBitmap( + _ page: CGPDFPage, mediaBox: CGRect + ) throws -> NSBitmapImageRep { + let width = Int(ceil(mediaBox.width)) + let height = Int(ceil(mediaBox.height)) + let rep = try XCTUnwrap(NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: width, + pixelsHigh: height, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0)) + let context = try XCTUnwrap( + NSGraphicsContext(bitmapImageRep: rep)) + let cg = context.cgContext + let transform = page.getDrawingTransform( + .mediaBox, + rect: CGRect(x: 0, y: 0, width: width, height: height), + rotate: 0, preserveAspectRatio: true) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + cg.concatenate(transform) + cg.drawPDFPage(page) + NSGraphicsContext.restoreGraphicsState() + return rep + } } diff --git a/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift b/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift index 2a802e4..0bdf832 100644 --- a/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift +++ b/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift @@ -4,7 +4,7 @@ import Foundation import XCTest @testable import ICCery -/// Issue #201 Phase 3 — `TargetPageCanvasView` top-down page stacking, +/// Issue #201 Phase 3 — `TargetPageCanvasView` bottom-up page stacking, /// snapped top-left anchoring, and the draw-time interpolation / /// antialias contract asserted via `drawProbe`. @MainActor @@ -35,7 +35,9 @@ final class TargetCanvasGeometryTests: XCTestCase { // MARK: - Page stacking - func testRectForPageStacksTopDown() { + /// Bottom-up space: page 1 is the BOTTOM band of the frame — the + /// non-flipped pagination convention (#211). + func testRectForPageStacksBottomUp() { let view = TargetPageCanvasView( pages: [raster(), raster(), raster()], paperSize: paperSize) @@ -68,18 +70,33 @@ final class TargetCanvasGeometryTests: XCTestCase { pages: [raster(), raster()], paperSize: paperSize) + // Near-full-page raster: in bottom-up space the page's top + // edge is `maxY`, so the top-anchored origin is ~0.03 pt + // negative — the overflow clips at the BOTTOM of the sheet. let first = view.destinationRect(forPage: 1) XCTAssertEqual(first.origin.x, 0, accuracy: 0.001) - XCTAssertEqual(first.origin.y, 0, accuracy: 0.001) + XCTAssertEqual(first.origin.y, -0.03, accuracy: 0.001) XCTAssertEqual(first.width, 595.2, accuracy: 0.01) XCTAssertEqual(first.height, 841.92, accuracy: 0.01) let second = view.destinationRect(forPage: 2) XCTAssertEqual( - second.origin.y, paperSize.height, accuracy: 0.001) + second.origin.y, + 2 * paperSize.height - first.height, accuracy: 0.001) XCTAssertEqual(second.size, first.size) - for rect in [first, second] { + // A raster smaller than the paper hangs from the page's TOP + // edge — minY would anchor it at the bottom (#211 follow-up). + let small = TargetPageCanvasView( + pages: [raster(pixelWidth: 72, pixelHeight: 72, dpi: 72)], + paperSize: paperSize) + let smallRect = small.destinationRect(forPage: 1) + XCTAssertEqual(smallRect.origin.x, 0, accuracy: 0.001) + XCTAssertEqual( + smallRect.origin.y, paperSize.height - 72, accuracy: 0.001) + XCTAssertEqual(smallRect.size, CGSize(width: 72, height: 72)) + + for rect in [first, second, smallRect] { XCTAssertEqual( rect.origin.x * 1000, (rect.origin.x * 1000).rounded(), diff --git a/Tests/ICCeryCoreTests/TargetRasterFixtures.swift b/Tests/ICCeryCoreTests/TargetRasterFixtures.swift index 00d97f8..36241ca 100644 --- a/Tests/ICCeryCoreTests/TargetRasterFixtures.swift +++ b/Tests/ICCeryCoreTests/TargetRasterFixtures.swift @@ -27,12 +27,16 @@ enum TargetTestFixtures { /// colour space for 1/3/4 components at 8 or 16 bpc. `alpha: true` /// appends one alpha sample per pixel (`last`). `pixelBytes` is the /// repeating per-pixel pattern (big-endian for 16 bpc). + /// `bottomHalfPixelBytes`, when set, is the pattern for the bottom + /// half of the rows — a vertical asymmetry that lets tests catch a + /// mirrored draw (#211). static func makeImage( px: CGSize, components: Int, bitsPerComponent: Int, alpha: Bool = false, - pixelBytes: [UInt8]? = nil + pixelBytes: [UInt8]? = nil, + bottomHalfPixelBytes: [UInt8]? = nil ) -> CGImage? { let width = Int(px.width) let height = Int(px.height) @@ -40,19 +44,31 @@ enum TargetTestFixtures { let samples = components + (alpha ? 1 : 0) let bytesPerPixel = samples * bytesPerComponent let bytesPerRow = width * bytesPerPixel - let pattern = pixelBytes - ?? Array(0.. [UInt8] { + var row = [UInt8](repeating: 0, count: bytesPerRow) + for offset in stride( + from: 0, to: bytesPerRow, by: pattern.count) { + for (index, byte) in pattern.enumerated() + where offset + index < bytesPerRow { + row[offset + index] = byte + } } + return row } + let topRow = buildRow( + pixelBytes + ?? Array(0.. URL { guard let image = makeImage( px: px, components: components, bitsPerComponent: bitsPerComponent, - alpha: alpha, pixelBytes: pixelBytes) + alpha: alpha, pixelBytes: pixelBytes, + bottomHalfPixelBytes: bottomHalfPixelBytes) else { throw FixtureError.imageNotCreated } let url = directory.appendingPathComponent( "fixture-\(UUID().uuidString).tiff") -- 2.39.5 From f7099a980894a1d12f5040b0c58c19d3603565f4 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 17 Sep 2026 23:08:51 +0100 Subject: [PATCH 11/14] fix(print): filter Stage 2 quality picker by selected media type (#214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lpoptions -l only exposes the global quality list, so the picker offered every token for every media — the driver then rejected invalid pairs (e.g. Fast Economy on Premium Semigloss) at print time. Neither the Epson nor the Canon PPD carries standard *UIConstraints, so resolve the media-to-quality map from each driver's own data: Epson InkjetPrinter2 *EPIJUIConstraint forbid-rules in the machine bundle's PDEData.dat (non-media LHS terms evaluated against lpoptions defaults), Canon BJPrinter's binary cnb_0.tbl record table via *CNIJNameTblPath/*CNIJTableID, and generic PPD *UIConstraints pairs. MediaQualityConstraints picks the first non-empty source and fails open — queues without constraint data keep the full list. PrinterCapabilities gains qualityIDsByMediaType plus filtered qualities(forMediaType:)/allowsQuality accessors; the view model exposes availableQualities, clamps the pick on media change / seeding / panel apply-back, and substitutes a stale quality defensively in makeRequest so an invalid pair can never reach the ticket. --- .../ICCeryCore/Print/CupsService.swift | 9 +- .../Print/MediaQualityConstraints.swift | 329 ++++++++++++++++++ .../ICCeryCore/Print/PrinterModels.swift | 37 +- Sources/ICCery/AppEnvironment.swift | 8 +- .../ICCery/Print/PrintSessionViewModel.swift | 65 +++- Sources/ICCery/Stage2View.swift | 6 +- .../MediaQualityConstraintsTests.swift | 311 +++++++++++++++++ .../PrintSessionViewModelTests.swift | 163 +++++++++ .../Milestone11PrintSettingsUITests.swift | 81 +++++ 9 files changed, 999 insertions(+), 10 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/MediaQualityConstraints.swift create mode 100644 Tests/ICCeryCoreTests/MediaQualityConstraintsTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift index 3ecec7a..10e02dc 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift @@ -115,9 +115,16 @@ public struct CupsService: Sendable { /// Trays / paper sizes / media types for a queue, with PPD /// `*Key id/Human:` enrichment when the queue's PPD is readable. + /// Also resolves the per-media quality map from vendor driver + /// data (`MediaQualityConstraints`, #214) — empty when the driver + /// exposes none. public func capabilities(for queue: String) async throws -> PrinterCapabilities { let listings = try await optionListings(for: queue) - return capabilities(from: listings, ppd: loadPPD(for: queue)) + let ppd = loadPPD(for: queue) + var caps = capabilities(from: listings, ppd: ppd) + caps.qualityIDsByMediaType = MediaQualityConstraints.resolve( + listings: listings, ppd: ppd) + return caps } /// Pure mapping — extracted so fixture tests need no process. diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/MediaQualityConstraints.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/MediaQualityConstraints.swift new file mode 100644 index 0000000..857d7c0 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/MediaQualityConstraints.swift @@ -0,0 +1,329 @@ +import Foundation + +/// Per-media quality validity (#214): which of the queue's quality +/// tokens the driver actually accepts for a given media type. The +/// answer is not in `lpoptions -l`, IPP, or standard PPD +/// `*UIConstraints` for the two driver families handled here — +/// +/// - Epson InkjetPrinter2: `*EPIJUIConstraint: |` +/// lines in the machine bundle's +/// `…/Contents/Resources/PDEData.dat`. A rule forbids its RHS +/// choice while every LHS `* ` term holds. LHS keys +/// other than the media key (`EPIJ_PSrc`, `EPIJ_FdSo`, +/// `EPIJ_Ink_`, …) are evaluated against the queue's `lpoptions` +/// defaults — reproducing the driver's PDE state when it opens. +/// - Canon BJPrinter: `*CNIJNameTblPath` + `*CNIJTableID` PPD keys +/// locate `cnb_0.tbl`, a binary record DB whose 20-byte +/// `{u16 0x30, u16 1, u32 0, u16 family, u16 flag, u32 mediaID, +/// u16 0, u16 quality}` records enumerate the allowed +/// `CNIJPrintQuality` values per `CNIJMediaType` (`mediaID` +/// `| 0x10000` marks the borderless variant of the same media). +/// - Generic: standard PPD `*UIConstraints:`/`*Constraints:` pairs +/// (Gutenprint etc.) — first non-empty source wins. +/// +/// Every parser is pure; file access is injected so tests need no +/// installed driver. Failure or absence yields an empty map — callers +/// treat that as "unconstrained" and keep the full quality list. +public enum MediaQualityConstraints { + + /// media-type id → allowed quality ids. Sources tried in order + /// (PPD constraints → Epson `PDEData.dat` → Canon `cnb` table); + /// the first source producing any entries wins. + public static func resolve( + listings: [CupsOptionListing], + ppd: String?, + readFile: (URL) -> Data? = { try? Data(contentsOf: $0) } + ) -> [String: Set] { + let optionKeys = Set(listings.map(\.key)) + guard let mediaKey = CupsParsers.detectMediaTypeKey( + optionKeys: optionKeys), + let qualityKey = CupsParsers.detectQualityKey( + optionKeys: optionKeys), + let mediaListing = listings.first(where: { + $0.key == mediaKey }), + let qualityListing = listings.first(where: { + $0.key == qualityKey }) + else { return [:] } + let mediaIDs = Set(mediaListing.choices) + let qualityIDs = Set(qualityListing.choices) + let defaults = Dictionary( + listings.compactMap { l in l.defaultChoice.map { (l.key, $0) } }, + uniquingKeysWith: { first, _ in first }) + guard let ppd else { return [:] } + + let ppdMap = allowedMap( + forbidden: ppdUIConstraints( + ppd, mediaKey: mediaKey, qualityKey: qualityKey, + mediaIDs: mediaIDs, qualityIDs: qualityIDs), + mediaIDs: mediaIDs, qualityIDs: qualityIDs) + if !ppdMap.isEmpty { return ppdMap } + + if let url = epijPDEDataPath(ppd: ppd), + let data = readFile(url), + let dat = String(data: data, encoding: .utf8) + ?? String(data: data, encoding: .isoLatin1) { + let map = allowedMap( + forbidden: epijUIConstraints( + dat, mediaKey: mediaKey, qualityKey: qualityKey, + mediaIDs: mediaIDs, qualityIDs: qualityIDs, + defaults: defaults), + mediaIDs: mediaIDs, qualityIDs: qualityIDs) + if !map.isEmpty { return map } + } + + if let url = cnijTablePath(ppd: ppd), + let data = readFile(url) { + let map = cnijMediaQualityTable( + data, mediaIDs: mediaIDs, qualityIDs: qualityIDs) + if !map.isEmpty { return map } + } + return [:] + } + + // MARK: - PPD keyword lookup + + /// `*: ` or `*: ""` → the value. + /// Exact key match — `*CNIJTableIDFoo:` must not satisfy a lookup + /// for `CNIJTableID`. + public static func ppdKeyword(_ ppd: String, _ key: String) -> String? { + for raw in ppd.split(separator: "\n") { + let line = raw.trimmingCharacters(in: .whitespaces) + guard line.hasPrefix("*\(key)") else { continue } + let rest = line.dropFirst(key.count + 1) + guard rest.first == ":" else { continue } + var value = rest.dropFirst() + .trimmingCharacters(in: .whitespaces) + if value.count >= 2, + value.hasPrefix("\""), value.hasSuffix("\"") { + value = String(value.dropFirst().dropLast()) + } + if !value.isEmpty { return value } + } + return nil + } + + // MARK: - Generic PPD constraints + + /// `*UIConstraints:`/`*Constraints:` lines pair two conflicting + /// option choices; the ones naming both the media key and the + /// quality key produce forbidden (media, quality) pairs. + /// `*cupsUIConstraints` resolver triples do not match the + /// `*…Constraints:` prefixes, so they are skipped naturally. + public static func ppdUIConstraints( + _ ppd: String, + mediaKey: String, + qualityKey: String, + mediaIDs: Set, + qualityIDs: Set + ) -> [String: Set] { + var forbidden: [String: Set] = [:] + for raw in ppd.split(separator: "\n") { + var line = raw.trimmingCharacters(in: .whitespaces) + if line.hasPrefix("*UIConstraints:") { + line = String(line.dropFirst("*UIConstraints:".count)) + } else if line.hasPrefix("*Constraints:") { + line = String(line.dropFirst("*Constraints:".count)) + } else { + continue + } + let terms = constraintTerms(line) + guard let media = terms[mediaKey], mediaIDs.contains(media), + let quality = terms[qualityKey], + qualityIDs.contains(quality) + else { continue } + forbidden[media, default: []].insert(quality) + } + return forbidden + } + + // MARK: - Epson PDEData.dat + + /// `EPIJDriverBasePath` + `EPIJMachineBundleName` → the machine + /// bundle's `Contents/Resources/PDEData.dat` (uniform across the + /// InkjetPrinter2 family). + public static func epijPDEDataPath(ppd: String) -> URL? { + guard let base = ppdKeyword(ppd, "EPIJDriverBasePath"), + let bundle = ppdKeyword(ppd, "EPIJMachineBundleName") + else { return nil } + return URL(fileURLWithPath: base) + .appendingPathComponent("Machine") + .appendingPathComponent(bundle) + .appendingPathComponent("Contents/Resources/PDEData.dat") + } + + /// `*EPIJUIConstraint:` forbidden-pair rules → (media, quality) + /// pairs the driver greys out. A rule *fires* when its LHS + /// `*` term equals the media (absent media term → + /// applies to every media) and every other LHS term's value + /// equals that key's `lpoptions` default. A term whose key the + /// queue does not advertise counts as satisfied — hiding a usable + /// quality is a soft restriction, while showing an invalid one + /// re-creates the reported print failure. + public static func epijUIConstraints( + _ dat: String, + mediaKey: String, + qualityKey: String, + mediaIDs: Set, + qualityIDs: Set, + defaults: [String: String] + ) -> [String: Set] { + var forbidden: [String: Set] = [:] + for raw in dat.split(separator: "\n") { + let line = raw.trimmingCharacters(in: .whitespaces) + guard line.hasPrefix("*EPIJUIConstraint:"), + let bar = line.firstIndex(of: "|") + else { continue } + let rhs = constraintTerms( + String(line[line.index(after: bar)...])) + guard let quality = rhs[qualityKey], + qualityIDs.contains(quality) + else { continue } + let lhs = constraintTerms( + String(line[ + line.index( + line.startIndex, + offsetBy: "*EPIJUIConstraint:".count)..0.tbl`. + public static func cnijTablePath(ppd: String) -> URL? { + guard let dir = ppdKeyword(ppd, "CNIJNameTblPath"), + let tableID = ppdKeyword(ppd, "CNIJTableID") + else { return nil } + return URL(fileURLWithPath: dir) + .appendingPathComponent("cnb_\(tableID)0.tbl") + } + + /// Scans the Canon table for 20-byte LE records anchored on + /// `30 00 01 00 00 00 00 00`: `{u16 family, u16 flag, + /// u32 mediaID, u16 pad, u16 quality}` follows the anchor. + /// A row counts only when `pad == 0`, `mediaID & 0xFFFF` is a + /// listed media id, and `quality` is a listed quality id — so + /// unrelated tables cannot inject false entries. Records carry a + /// `family` field (0x03 on the Pro9500 II); the modal family is + /// used so sibling record layouts in the same file are ignored. + /// Returns allowed (not forbidden) sets directly. + public static func cnijMediaQualityTable( + _ data: Data, + mediaIDs: Set, + qualityIDs: Set + ) -> [String: Set] { + let mediaNums = Set(mediaIDs.compactMap { UInt32($0) }) + let qualityNums = Set(qualityIDs.compactMap { UInt16($0) }) + guard !mediaNums.isEmpty, !qualityNums.isEmpty else { + return [:] + } + let anchor: [UInt8] = [0x30, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00] + var rows: [UInt16: [(media: UInt32, quality: UInt16)]] = [:] + data.withUnsafeBytes { buffer in + guard let base = buffer.baseAddress else { return } + var i = 0 + while i + 20 <= buffer.count { + if memcmp(base + i, anchor, anchor.count) == 0 { + let family = readU16(base, i + 8) + let media = readU32(base, i + 12) + let pad = readU16(base, i + 16) + let quality = readU16(base, i + 18) + let baseMedia = media & 0xFFFF + if pad == 0, + mediaNums.contains(baseMedia), + qualityNums.contains(quality) { + rows[family, default: []].append( + (baseMedia, quality)) + } + } + i += 2 + } + } + guard let dominant = rows.max(by: { $0.value.count < $1.value.count }) + else { return [:] } + var map: [String: Set] = [:] + for row in dominant.value { + map[String(row.media), default: []].insert(String(row.quality)) + } + // Keep strict subsets only — a media with the full set (or an + // empty one) is unconstrained as far as the UI is concerned. + map = map.filter { _, ids in + !ids.isEmpty && ids != qualityIDs + } + return map + } + + // MARK: - Shared + + /// `* ` term extraction for both PPD and Epson + /// constraint syntax. A `*` not followed by a value is a + /// wildcard term (empty-string value → always satisfied). + private static func constraintTerms(_ text: String) -> [String: String] { + var terms: [String: String] = [:] + let tokens = text.split(separator: " ").map(String.init) + var index = 0 + while index < tokens.count { + guard tokens[index].hasPrefix("*") else { + index += 1 + continue + } + let key = String(tokens[index].dropFirst()) + if index + 1 < tokens.count, + !tokens[index + 1].hasPrefix("*") { + terms[key] = tokens[index + 1] + index += 2 + } else { + terms[key] = "" + index += 1 + } + } + return terms + } + + /// forbidden (media → quality ids) → allowed map entries, + /// emitting only strict non-empty subsets: a media whose allowed + /// set is empty or equals the full roster is unconstrained as far + /// as the picker is concerned. + private static func allowedMap( + forbidden: [String: Set], + mediaIDs: Set, + qualityIDs: Set + ) -> [String: Set] { + var map: [String: Set] = [:] + for media in mediaIDs { + let allowed = qualityIDs.subtracting(forbidden[media] ?? []) + if !allowed.isEmpty, allowed != qualityIDs { + map[media] = allowed + } + } + return map + } + + private static func readU16(_ base: UnsafeRawPointer, _ offset: Int) -> UInt16 { + UInt16(base.load(fromByteOffset: offset, as: UInt8.self)) + | UInt16(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8 + } + + private static func readU32(_ base: UnsafeRawPointer, _ offset: Int) -> UInt32 { + UInt32(base.load(fromByteOffset: offset, as: UInt8.self)) + | UInt32(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8 + | UInt32(base.load(fromByteOffset: offset + 2, as: UInt8.self)) << 16 + | UInt32(base.load(fromByteOffset: offset + 3, as: UInt8.self)) << 24 + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift index 1947873..5e648ba 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift @@ -107,6 +107,11 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable { public var qualities: [PrinterQuality] /// The `*`-marked default choice from `lpoptions -l`, if any. public var qualityDefault: String? + /// Media-type id → the quality ids the driver accepts for it + /// (#214 — `MediaQualityConstraints`). Empty when the driver + /// exposes no per-media quality matrix; a missing entry means + /// "unconstrained" — show everything. + public var qualityIDsByMediaType: [String: Set] public init( trays: [PrinterTray] = [], @@ -115,7 +120,8 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable { supportsOrientation: Bool = true, qualityKey: String? = nil, qualities: [PrinterQuality] = [], - qualityDefault: String? = nil + qualityDefault: String? = nil, + qualityIDsByMediaType: [String: Set] = [:] ) { self.trays = trays self.paperSizes = paperSizes @@ -124,6 +130,35 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable { self.qualityKey = qualityKey self.qualities = qualities self.qualityDefault = qualityDefault + self.qualityIDsByMediaType = qualityIDsByMediaType + } + + /// 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] { + guard let mediaID, + let allowed = qualityIDsByMediaType[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. + public func allowsQuality( + _ qualityID: String, forMediaType mediaID: String? + ) -> Bool { + guard let mediaID, + let allowed = qualityIDsByMediaType[mediaID], + !allowed.isEmpty + else { return true } + return allowed.contains(qualityID) } } diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift index c9b794c..792c694 100644 --- a/Sources/ICCery/AppEnvironment.swift +++ b/Sources/ICCery/AppEnvironment.swift @@ -24,6 +24,7 @@ struct AppEnvironment: Sendable { .map { URL(fileURLWithPath: $0) } var bundledRoot = AppPaths.bundledArgyllDir var cupsDir = URL(fileURLWithPath: "/usr/bin") + var ppdDir = URL(fileURLWithPath: "/etc/cups/ppd") #if DEBUG if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty { overrideDir = URL(fileURLWithPath: dir) @@ -34,6 +35,11 @@ struct AppEnvironment: Sendable { if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty { cupsDir = URL(fileURLWithPath: dir) } + // #214 — fixture PPD dir lets UI tests exercise the media→ + // quality constraint resolver without installed drivers. + if let dir = environment["ICCERY_CUPS_PPD_DIR"], !dir.isEmpty { + ppdDir = URL(fileURLWithPath: dir) + } #endif return AppEnvironment( stateStore: WizardStateStore(), @@ -46,7 +52,7 @@ struct AppEnvironment: Sendable { ), cupsService: CupsService( processManager: .shared, - binaryDir: cupsDir), + binaryDir: cupsDir, ppdDir: ppdDir), historyStore: VerificationHistoryStore(), mediaStore: MediaLibraryStore(), recentProjectsStore: RecentProjectsStore() diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index d6cb2cc..29dc0f5 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -25,7 +25,15 @@ final class PrintSessionViewModel: ObservableObject { } @Published var printerCaps = PrinterCapabilities() @Published var selectedTray: Int? - @Published var selectedMediaType: String? + @Published var selectedMediaType: String? { + didSet { + // A media switch can invalidate the current quality pick — + // re-clamp into the driver's allowed set (#214). + if selectedMediaType != oldValue { + clampQualityToMedia() + } + } + } /// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183). @Published var selectedPaperSize: Int? /// Print-quality option token, e.g. `"303"` (#183). @@ -123,9 +131,16 @@ final class PrintSessionViewModel: ObservableObject { if selectedTray == nil { selectedTray = printerCaps.trays.first?.id } + // Quality seeds inside the selected media's allowed set + // (#214): driver default when valid there, else the first + // allowed choice. if selectedQuality == nil { - selectedQuality = printerCaps.qualityDefault - ?? printerCaps.qualities.first?.id + let allowed = availableQualities + selectedQuality = printerCaps.qualityDefault.flatMap { d in + allowed.contains(where: { $0.id == d }) ? d : nil + } ?? allowed.first?.id + } else { + clampQualityToMedia() } // Caps reload is a re-mirror trigger for the paper picker // (#183 E4) — pageSize + printer changes route here too. @@ -137,6 +152,32 @@ 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. + var availableQualities: [PrinterQuality] { + printerCaps.qualities(forMediaType: selectedMediaType) + } + + /// 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). + private func clampQualityToMedia() { + let allowed = availableQualities + guard !allowed.isEmpty else { return } + if let quality = selectedQuality, + allowed.contains(where: { $0.id == quality }) { + return + } + if let fallback = printerCaps.qualityDefault, + allowed.contains(where: { $0.id == fallback }) { + selectedQuality = fallback + } else { + selectedQuality = allowed.first?.id + } + } + /// 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`); @@ -230,7 +271,12 @@ final class PrintSessionViewModel: ObservableObject { .first(where: { $0.name == paper }) { selectedPaperSize = match.id } - if let quality = result.properties.options.quality { + // A captured quality the driver rejects for the + // (possibly just-captured) media is dropped — keeping + // it would only re-create a failed print (#214). + if let quality = result.properties.options.quality, + printerCaps.allowsQuality( + quality, forMediaType: selectedMediaType) { selectedQuality = quality } if let orientation = result.properties.options.orientation { @@ -358,6 +404,15 @@ final class PrintSessionViewModel: ObservableObject { let optionKeys = (try? await environment.cupsService .optionKeys(for: queue)) ?? [] attachDiagnostics() + // Defence-in-depth (#214): an invalid media+quality pair can + // never reach the ticket — substitute the first allowed + // quality when the pick is stale (UI already clamps; this is + // the last gate before the driver). + var quality = selectedQuality + if let q = quality, + !printerCaps.allowsQuality(q, forMediaType: selectedMediaType) { + quality = availableQualities.first?.id ?? q + } return TargetPrintRequest( queue: queue, displayName: printers.first { $0.name == queue }?.displayName, @@ -373,7 +428,7 @@ final class PrintSessionViewModel: ObservableObject { paperSize: selectedPaperSizeToken, mediaType: selectedMediaType, qualityKey: printerCaps.qualityKey, - quality: selectedQuality, + quality: quality, orientation: printOrientation), optionKeys: optionKeys) } diff --git a/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift index 2c348ea..b7d55e3 100644 --- a/Sources/ICCery/Stage2View.swift +++ b/Sources/ICCery/Stage2View.swift @@ -410,11 +410,13 @@ struct Stage2View: View { .accessibilityIdentifier("paperSizeGroup") } - /// Quality picker — driver tokens with PPD-enriched labels (#183). + /// Quality picker — driver tokens with PPD-enriched labels (#183), + /// filtered to what the driver accepts for the selected media + /// (#214). private var qualityGroup: some View { VStack(alignment: .leading, spacing: 4) { Picker("Quality", selection: $workflow.print.selectedQuality) { - ForEach(workflow.print.printerCaps.qualities, id: \.id) { + ForEach(workflow.print.availableQualities, id: \.id) { Text($0.name).tag(Optional($0.id)) } } diff --git a/Tests/ICCeryCoreTests/MediaQualityConstraintsTests.swift b/Tests/ICCeryCoreTests/MediaQualityConstraintsTests.swift new file mode 100644 index 0000000..ceaf475 --- /dev/null +++ b/Tests/ICCeryCoreTests/MediaQualityConstraintsTests.swift @@ -0,0 +1,311 @@ +import XCTest +import Foundation +@testable import ICCeryCore + +/// #214 — media → allowed-quality resolution: generic PPD +/// `*UIConstraints`, Epson `PDEData.dat` `*EPIJUIConstraint` rules, +/// and the Canon `cnb_*.tbl` record scan. All fixtures inline / +/// injected — no installed drivers touched. +final class MediaQualityConstraintsTests: XCTestCase { + + /// Epson-flavoured `lpoptions -l` listings: the XP-55 advertises + /// the conditional LHS keys (`EPIJ_PSrc`/`EPIJ_FdSo`/`EPIJ_Ink_`) + /// with defaults 2/2/1. + private var epsonListings: [CupsOptionListing] { + CupsParsers.lpoptionsList(""" + PageSize/Media Size: 4x6 5x7 *A4 Letter + EPIJ_Medi/Media Type: *0 92 13 15 145 12 2 75 26 76 93 + EPIJ_PSrc/Page Setup: *2 3 25 + EPIJ_FdSo/Paper Source: *2 3 12 + EPIJ_Ink_/Grayscale: *1 0 + EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307 + """) + } + + private var epsonQualityIDs: Set { + ["301", "302", "303", "304", "305", "307", "308"] + } + + // MARK: - ppdKeyword + + func testPPDKeywordQuotedAndBare() { + let ppd = """ + *EPIJDriverBasePath: "/Library/Printers/EPSON/InkjetPrinter2" + *EPIJMachineBundleName: "EP14C0605W.data" + *CNIJTableID: 354 + *CNIJTableIDFoo: 999 + """ + XCTAssertEqual( + MediaQualityConstraints.ppdKeyword(ppd, "EPIJDriverBasePath"), + "/Library/Printers/EPSON/InkjetPrinter2") + XCTAssertEqual( + MediaQualityConstraints.ppdKeyword(ppd, "EPIJMachineBundleName"), + "EP14C0605W.data") + XCTAssertEqual( + MediaQualityConstraints.ppdKeyword(ppd, "CNIJTableID"), "354") + // Prefix-safety: CNIJTableIDFoo must not satisfy the lookup. + XCTAssertNil(MediaQualityConstraints.ppdKeyword(ppd, "CNIJTableIDX")) + XCTAssertNil(MediaQualityConstraints.ppdKeyword(ppd, "Missing")) + } + + // MARK: - Path derivation + + func testEPIJPDEDataPath() { + let ppd = """ + *EPIJDriverBasePath: "/Library/Printers/EPSON/InkjetPrinter2" + *EPIJMachineBundleName: "EP14C0605W.data" + """ + XCTAssertEqual( + MediaQualityConstraints.epijPDEDataPath(ppd: ppd)?.path, + "/Library/Printers/EPSON/InkjetPrinter2/Machine/" + + "EP14C0605W.data/Contents/Resources/PDEData.dat") + XCTAssertNil(MediaQualityConstraints.epijPDEDataPath( + ppd: "*CNIJTableID: 354\n")) + } + + func testCNIJTablePath() { + let ppd = """ + *CNIJNameTblPath: "/Library/Printers/Canon/BJPrinter/Resources/Database/CIJPro9500IIseries.db/Contents/Resources" + *CNIJTableID: 354 + """ + XCTAssertEqual( + MediaQualityConstraints.cnijTablePath(ppd: ppd)?.path, + "/Library/Printers/Canon/BJPrinter/Resources/Database/" + + "CIJPro9500IIseries.db/Contents/Resources/cnb_3540.tbl") + XCTAssertNil(MediaQualityConstraints.cnijTablePath( + ppd: "*EPIJMachineBundleName: \"x\"\n")) + } + + // MARK: - Generic PPD UIConstraints + + func testPPDUIConstraintsBothOrders() { + let ppd = """ + *UIConstraints: *MediaType Glossy *PrintQuality Draft + *UIConstraints: *PrintQuality Draft *MediaType Matte + *Constraints: *MediaType Glossy *PrintQuality Low + *UIConstraints: *InputSlot Rear *PrintQuality Draft + """ + let forbidden = MediaQualityConstraints.ppdUIConstraints( + ppd, mediaKey: "MediaType", qualityKey: "PrintQuality", + mediaIDs: ["Glossy", "Matte"], + qualityIDs: ["Draft", "Low", "High"]) + XCTAssertEqual(forbidden["Glossy"], ["Draft", "Low"]) + XCTAssertEqual(forbidden["Matte"], ["Draft"]) + } + + // MARK: - Epson EPIJUIConstraint + + /// Realistic XP-55 fragment: pure-media rules plus conditional + /// rules on `EPIJ_PSrc`/`EPIJ_Ink_` evaluated against defaults. + func testEPIJConstraintsAgainstDefaults() { + let dat = """ + *EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 305 + *EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 307 + *EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 308 + *EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 301 + *EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 302 + *EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 303 + *EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 304 + *EPIJUIConstraint: *EPIJ_PSrc 3 *EPIJ_Medi 0|*EPIJ_Qual 301 + *EPIJUIConstraint: *EPIJ_PSrc 2 *EPIJ_Medi 0 *EPIJ_Ink_ 0|*EPIJ_Qual 302 + *EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 301 + *EPIJUIConstraint: *EPIJ_PSrc 3 *EPIJ_Medi 92|*EPIJ_Qual 999 + """ + let forbidden = MediaQualityConstraints.epijUIConstraints( + dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual", + mediaIDs: ["0", "15", "92"], qualityIDs: epsonQualityIDs, + defaults: ["EPIJ_PSrc": "2", "EPIJ_Ink_": "1"]) + // Media 0: pure rules forbid 305/307/308; the PSrc=3 rule is + // inert at default PSrc=2 (301 survives); the PSrc=2 rule + // additionally requires Ink_=0 — default is 1, so inert too + // (302 survives). + XCTAssertEqual(forbidden["0"], ["305", "307", "308"]) + XCTAssertEqual(forbidden["15"], ["301", "302", "303", "304"]) + // 999 is not a listed quality id — ignored outright. + XCTAssertEqual(forbidden["92"], ["301"]) + } + + /// A conditional rule whose extra terms all match the defaults + /// fires like a pure-media rule. + func testEPIJConditionalRuleFiresAtDefaults() { + let dat = """ + *EPIJUIConstraint: *EPIJ_PSrc 2 *EPIJ_Medi 0|*EPIJ_Qual 304 + """ + let forbidden = MediaQualityConstraints.epijUIConstraints( + dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual", + mediaIDs: ["0"], qualityIDs: epsonQualityIDs, + defaults: ["EPIJ_PSrc": "2"]) + XCTAssertEqual(forbidden["0"], ["304"]) + } + + /// A rule with no media term applies to every listed media; a term + /// on a key the queue does not advertise counts as satisfied + /// (conservative-forbid — #214 semantics). + func testEPIJGlobalAndUnknownKeyRules() { + let dat = """ + *EPIJUIConstraint: *EPIJ_Ink_ 1|*EPIJ_Qual 305 + *EPIJUIConstraint: *EPIJ_Mode 9 *EPIJ_Medi 0|*EPIJ_Qual 307 + """ + let forbidden = MediaQualityConstraints.epijUIConstraints( + dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual", + mediaIDs: ["0", "15"], qualityIDs: epsonQualityIDs, + defaults: ["EPIJ_Ink_": "1"]) + // Global rule: Ink_=1 matches default → 305 forbidden on both. + XCTAssertTrue(forbidden["0"]!.contains("305")) + XCTAssertTrue(forbidden["15"]!.contains("305")) + // EPIJ_Mode is unlisted → satisfied → (0, 307) forbidden too. + XCTAssertTrue(forbidden["0"]!.contains("307")) + } + + /// The end-to-end XP-55 expectation through `resolve` — the exact + /// sets the user reported from the driver PDE. + func testResolveEpsonProducesDriverSets() { + let dat = """ + *EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 305 + *EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 307 + *EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 308 + *EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 301 + *EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 302 + *EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 303 + *EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 304 + *EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 308 + *EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 301 + *EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 302 + *EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 303 + *EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 304 + """ + let ppd = """ + *EPIJDriverBasePath: "/drivers/epson" + *EPIJMachineBundleName: "EP14C0605W.data" + """ + let datURL = URL(fileURLWithPath: + "/drivers/epson/Machine/EP14C0605W.data/" + + "Contents/Resources/PDEData.dat") + let map = MediaQualityConstraints.resolve( + listings: epsonListings, ppd: ppd, + readFile: { $0 == datURL ? dat.data(using: .utf8) : nil }) + // Plain paper: Fast Economy / Economy / Normal / Fine. + XCTAssertEqual(map["0"], ["301", "302", "303", "304"]) + // Premium Semigloss: Quality / Best Quality / Draft. + XCTAssertEqual(map["15"], ["305", "307", "308"]) + // Ultra Glossy drops Fast Economy…Fine and Draft. + XCTAssertEqual(map["92"], ["305", "307"]) + // Unconstrained media (no rules) produce no entry. + XCTAssertNil(map["13"]) + } + + // MARK: - Canon cnb table + + /// Builds one 20-byte Canon record: `30 00 01 00 00 00 00 00`, + /// `family:u16`, `flag:u16`, `media:u32`, `pad:u16`, `quality:u16`. + private func canonRecord( + family: UInt16 = 3, flag: UInt16 = 0, + media: UInt32, pad: UInt16 = 0, quality: UInt16 + ) -> Data { + var d = Data([0x30, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]) + d.append(contentsOf: [UInt8(family & 0xFF), UInt8(family >> 8)]) + d.append(contentsOf: [UInt8(flag & 0xFF), UInt8(flag >> 8)]) + d.append(contentsOf: [ + UInt8(media & 0xFF), UInt8((media >> 8) & 0xFF), + UInt8((media >> 16) & 0xFF), UInt8((media >> 24) & 0xFF)]) + d.append(contentsOf: [UInt8(pad & 0xFF), UInt8(pad >> 8)]) + d.append(contentsOf: [UInt8(quality & 0xFF), UInt8(quality >> 8)]) + return d + } + + func testCNBTableScan() { + var data = Data() + // Junk prefix — even length keeps the records 2-byte aligned. + data.append(contentsOf: [0xFF, 0x00]) + // media 0 → {0, 10, 15, 20} across flag 0/1 variants. + for q: UInt16 in [10, 15, 20, 0] { + data.append(canonRecord(media: 0, quality: q)) + } + data.append(canonRecord(flag: 1, media: 0, quality: 10)) + // media 50 → {0, 5, 10}; plus its borderless variant 0x10032. + for q: UInt16 in [0, 5, 10] { + data.append(canonRecord(media: 50, quality: q)) + } + data.append(canonRecord(media: 0x10032, quality: 0)) + // Noise: unknown media, unknown quality, nonzero pad, and a + // different record family that must lose the modal vote. + data.append(canonRecord(media: 999, quality: 5)) + data.append(canonRecord(media: 50, quality: 7)) + data.append(canonRecord(media: 50, pad: 1, quality: 15)) + data.append(canonRecord(family: 9, media: 50, quality: 15)) + data.append(Data([0x11, 0x22, 0x33])) + + let map = MediaQualityConstraints.cnijMediaQualityTable( + data, + mediaIDs: ["0", "50", "42"], + qualityIDs: ["0", "5", "10", "15", "20"]) + XCTAssertEqual(map["0"], ["0", "10", "15", "20"]) + XCTAssertEqual(map["50"], ["0", "5", "10"]) + XCTAssertNil(map["42"]) + } + + func testCNBGarbageFailsOpen() { + let map = MediaQualityConstraints.cnijMediaQualityTable( + Data((0..<4096).map { _ in UInt8.random(in: 0...255) }), + mediaIDs: ["0", "50"], qualityIDs: ["0", "5", "10"]) + XCTAssertTrue(map.isEmpty) + } + + // MARK: - resolve ordering / fallback + + func testResolvePrefersPPDConstraints() { + let ppd = """ + *UIConstraints: *EPIJ_Medi 0 *EPIJ_Qual 305 + *EPIJDriverBasePath: "/should/not/be/read" + *EPIJMachineBundleName: "x.data" + """ + var reads = 0 + let map = MediaQualityConstraints.resolve( + listings: epsonListings, ppd: ppd, + readFile: { _ in reads += 1; return nil }) + XCTAssertEqual(map["0"], epsonQualityIDs.subtracting(["305"])) + // The Epson path is never touched once PPD constraints hit. + XCTAssertEqual(reads, 0) + } + + func testResolveNoPPDOrKeysFailsOpen() { + XCTAssertTrue(MediaQualityConstraints.resolve( + listings: epsonListings, ppd: nil).isEmpty) + // No media key in the roster → no map. + XCTAssertTrue(MediaQualityConstraints.resolve( + listings: CupsParsers.lpoptionsList( + "PageSize/Media Size: *A4 Letter\n"), + ppd: "*CNIJTableID: 354\n").isEmpty) + } + + // MARK: - PrinterCapabilities accessors + + func testCapabilitiesAccessorFallbacks() { + var caps = PrinterCapabilities( + mediaTypes: [ + PrinterMediaType(id: "0", name: "Plain"), + PrinterMediaType(id: "15", name: "Semigloss"), + ], + qualities: ["301", "302", "303", "308", "304", "305", "307"] + .map { PrinterQuality(id: $0, name: "Q\($0)") }) + // No map → everything, in driver order (#180). + XCTAssertEqual(caps.qualities(forMediaType: "0").map(\.id), + ["301", "302", "303", "308", "304", "305", "307"]) + XCTAssertTrue(caps.allowsQuality("305", forMediaType: "0")) + + caps.qualityIDsByMediaType = [ + "0": ["301", "302", "303", "304"], + "15": ["305", "307", "308"], + ] + XCTAssertEqual(caps.qualities(forMediaType: "0").map(\.id), + ["301", "302", "303", "304"]) + // Driver order preserved — 308 sits before 304 in lpoptions. + XCTAssertEqual(caps.qualities(forMediaType: "15").map(\.id), + ["308", "305", "307"]) + XCTAssertTrue(caps.allowsQuality("308", forMediaType: "15")) + XCTAssertFalse(caps.allowsQuality("308", forMediaType: "0")) + // Unknown media → unconstrained. + XCTAssertEqual(caps.qualities(forMediaType: "99").count, 7) + XCTAssertTrue(caps.allowsQuality("305", forMediaType: nil)) + } +} diff --git a/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift b/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift index 272e951..70e705b 100644 --- a/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift +++ b/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift @@ -408,6 +408,169 @@ final class PrintSessionViewModelTests: XCTestCase { XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty) } + // MARK: - Media-aware quality filtering (#214) + + /// Constrains the loaded fixture caps: `Stationery` allows + /// {301,302,303,304}, `Glossy` allows {305,307}, `Matte` is + /// unconstrained. Mirrors the Epson `EPIJUIConstraint` matrix. + private func constrainCaps(_ vm: PrintSessionViewModel) { + var caps = vm.printerCaps + caps.qualityIDsByMediaType = [ + "Stationery": ["301", "302", "303", "304"], + "Glossy": ["305", "307"], + ] + vm.printerCaps = caps + } + + /// Media switch → an invalid quality pick re-seeds to the first + /// allowed choice; the picker source shrinks to the media's set. + func testMediaChangeClampsInvalidQuality() async { + let workflow = makeWorkflow() + await loadCaps(workflow.print) + constrainCaps(workflow.print) + XCTAssertEqual(workflow.print.selectedMediaType, "Stationery") + XCTAssertEqual(workflow.print.selectedQuality, "303") + + workflow.print.selectedMediaType = "Glossy" + XCTAssertEqual(workflow.print.selectedQuality, "305") + XCTAssertEqual(workflow.print.availableQualities.map(\.id), + ["305", "307"]) + } + + /// A still-valid pick survives a media switch; an unconstrained + /// media keeps the pick too. + func testMediaChangeKeepsValidQuality() async { + let workflow = makeWorkflow() + await loadCaps(workflow.print) + constrainCaps(workflow.print) + workflow.print.selectedQuality = "302" + + workflow.print.selectedMediaType = "Matte" // unconstrained + XCTAssertEqual(workflow.print.selectedQuality, "302") + + workflow.print.selectedMediaType = "Glossy" // 302 invalid + XCTAssertEqual(workflow.print.selectedQuality, "305") + workflow.print.selectedQuality = "307" + workflow.print.selectedMediaType = "Stationery" // 307 invalid + // Driver default 303 is allowed there → preferred over first. + XCTAssertEqual(workflow.print.selectedQuality, "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 + /// `PDEData.dat` (#214). + func testQualitySeedRespectsMediaMap() async throws { + let ppdDir = env.root.appendingPathComponent("ppd") + let epsonRoot = env.root.appendingPathComponent("epson-driver") + let datDir = epsonRoot.appendingPathComponent( + "Machine/M.data/Contents/Resources") + try FileManager.default.createDirectory( + at: ppdDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: datDir, withIntermediateDirectories: true) + try """ + *EPIJDriverBasePath: "\(epsonRoot.path)" + *EPIJMachineBundleName: "M.data" + """.write( + to: ppdDir.appendingPathComponent("Mock_Q.ppd"), + atomically: true, encoding: .utf8) + // Stationery forbids everything except 305/307 — including the + // `lpoptions` default 303. + try """ + *EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 301 + *EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 302 + *EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 303 + *EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 308 + *EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 304 + """.write( + to: datDir.appendingPathComponent("PDEData.dat"), + atomically: true, encoding: .utf8) + + var environment = env.environment + environment = AppEnvironment( + stateStore: environment.stateStore, + settingsStore: environment.settingsStore, + presetStore: environment.presetStore, + runner: environment.runner, + cupsService: CupsService( + processManager: ProcessManager(), + binaryDir: binDir, ppdDir: ppdDir), + historyStore: environment.historyStore, + mediaStore: environment.mediaStore, + recentProjectsStore: environment.recentProjectsStore) + let workflow = TargetWorkflowViewModel(environment: environment) + workflow.print.selectedPrinter = "Mock_Q" + await workflow.print.reloadSelectedCapabilities() + + XCTAssertEqual(workflow.print.selectedMediaType, "Stationery") + XCTAssertEqual(workflow.print.printerCaps + .qualityIDsByMediaType["Stationery"], ["305", "307"]) + // 303 is the driver default but invalid on Stationery → 305. + XCTAssertEqual(workflow.print.selectedQuality, "305") + XCTAssertEqual(workflow.print.availableQualities.map(\.id), + ["305", "307"]) + } + + /// No constraint map → every quality stays selectable on every + /// media (the pre-#214 behaviour, by design for unknown drivers). + func testUnconstrainedDriverKeepsAllQualities() async { + let workflow = makeWorkflow() + await loadCaps(workflow.print) + workflow.print.selectedMediaType = "Glossy" + XCTAssertEqual(workflow.print.availableQualities.map(\.id), + ["301", "302", "303", "308", "304", "305", "307"]) + XCTAssertEqual(workflow.print.selectedQuality, "303") + } + + /// A captured quality invalid for the captured media is dropped — + /// the clamped selection stands. + func testPanelResultInvalidQualityDropped() async throws { + setenv("ICCERY_UI_TESTING", "1", 1) + setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1) + setenv("ICCERY_TEST_PANEL_OPTIONS", "EPIJ_Qual=305", 1) + defer { + unsetenv("ICCERY_UI_TESTING") + unsetenv("ICCERY_TEST_PRINT_PANEL") + unsetenv("ICCERY_TEST_PANEL_OPTIONS") + } + + let workflow = makeWorkflow() + await loadCaps(workflow.print) + constrainCaps(workflow.print) // Stationery forbids 305 + XCTAssertEqual(workflow.print.selectedQuality, "303") + + workflow.print.openPrinterPreferences() + await waitForNotice(workflow.print, containing: "Settings captured") + XCTAssertEqual(workflow.print.selectedQuality, "303") + } + + /// `makeRequest` substitutes a stale-invalid quality before the + /// ticket is written — the last gate before the driver (#214). + func testSpoolSubstitutesStaleQuality() async throws { + let workflow = makeWorkflow() + await loadCaps(workflow.print) + constrainCaps(workflow.print) + workflow.print.selectedMediaType = "Glossy" + workflow.print.selectedQuality = "308" // stale, invalid + workflow.print.spooler = RecordingTargetSpooler( + logURL: spoolLogURL) + + let tiff = env.root.appendingPathComponent("page1.tif") + try Data([0x49, 0x49]).write(to: tiff) + let page = GalleryPage( + index: 0, + page: PrinttargPage( + filename: "page1.tif", patches: 10, + widthMm: 210, heightMm: 297), + fileURL: tiff, previewPNG: nil, previewError: nil) + workflow.print.printPage(page) + + let log = await waitForFile(spoolLogURL) + XCTAssertTrue(log.contains("EPIJ_Qual=305"), log) + XCTAssertFalse(log.contains("EPIJ_Qual=308"), log) + } + /// Poll until the panel task posts a notice whose text contains /// `fragment` (the Task-completion signal for `nil` results too). private func waitForNotice( diff --git a/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift b/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift index dcb59b9..ec3be7d 100644 --- a/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift +++ b/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift @@ -251,6 +251,87 @@ final class Milestone11PrintSettingsUITests: XCTestCase { XCTAssertTrue(log.contains("EPIJ_Qual=305"), log) } + /// #214 — a fixture PPD pointing at a fixture `PDEData.dat` (via + /// `ICCERY_CUPS_PPD_DIR`) constrains the quality picker to the + /// media's allowed set; switching media re-filters and clamps the + /// selection. + func testQualityPickerFiltersByMediaConstraints() throws { + let ppdDir = testRoot.appendingPathComponent("ppd") + let epsonRoot = testRoot.appendingPathComponent("epson-driver") + let datDir = epsonRoot.appendingPathComponent( + "Machine/M.data/Contents/Resources") + try FileManager.default.createDirectory( + at: ppdDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: datDir, withIntermediateDirectories: true) + try """ + *EPIJDriverBasePath: "\(epsonRoot.path)" + *EPIJMachineBundleName: "M.data" + """.write( + to: ppdDir.appendingPathComponent("Mock_Epson_7450.ppd"), + atomically: true, encoding: .utf8) + try """ + *EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 305 + *EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 307 + *EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 308 + *EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 301 + *EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 302 + *EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 303 + *EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 304 + """.write( + to: datDir.appendingPathComponent("PDEData.dat"), + atomically: true, encoding: .utf8) + app.launchEnvironment["ICCERY_CUPS_PPD_DIR"] = ppdDir.path + launchAppWithDefaults() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + let qualityPopup = app.popUpButtons["printerQualitySelect"] + XCTAssertTrue(qualityPopup.waitForExistence(timeout: 10)) + + // Default media Stationery → {301,302,303,304} only. + qualityPopup.click() + let stationeryExpected = ["301", "302", "303", "304"] + for token in stationeryExpected { + XCTAssertTrue( + app.menuItems[token].waitForExistence(timeout: 5), + "Missing quality menu item \(token)") + } + XCTAssertFalse(app.menuItems["307"].exists) + app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + + // Switch to PhotographicGlossy → {305,307,308} in driver + // order (308 first — lpoptions order), selection clamped. + let mediaPopup = element("printerMediaTypeSelect") + .descendants(matching: .popUpButton).firstMatch + XCTAssertTrue(mediaPopup.waitForExistence(timeout: 5)) + mediaPopup.click() + let glossyItem = app.menuItems["PhotographicGlossy"] + XCTAssertTrue(glossyItem.waitForExistence(timeout: 5)) + glossyItem.click() + + qualityPopup.click() + let glossyExpected = ["308", "305", "307"] + for token in glossyExpected { + XCTAssertTrue( + app.menuItems[token].waitForExistence(timeout: 5), + "Missing quality menu item \(token)") + } + let titles = app.menuItems.allElementsBoundByIndex + .map(\.title) + .filter { glossyExpected.contains($0) } + XCTAssertEqual(titles, glossyExpected) + app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + + // The stale pick (303) clamped to the first allowed token. + let deadline = Date().addingTimeInterval(5) + while Date() < deadline, + selection(of: "printerQualitySelect") != "308" { + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + XCTAssertEqual(selection(of: "printerQualitySelect"), "308") + } + /// #201 D5 — per-page mode records one line per page; the /// `chkSingleSpoolJob` toggle collapses the job into a single /// request logged once with `pages=N`. -- 2.39.5 From 77c065f43381ab1c8b4c569c14a21df260ca34d7 Mon Sep 17 00:00:00 2001 From: Gronod Date: Fri, 18 Sep 2026 09:55:22 +0100 Subject: [PATCH 12/14] docs(agents): add remote test build procedure for CI runner host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSH access: localadmin@192.168.0.172, credentials in ~/Projects/build-host.env - sshpass usage via SSHPASS env var (never command-line) - Remote checkout sync: git checkout + scp uncommitted changes - xcodegen PATH workaround: prepend ~/.local/xcodegen/2.38.0/xcodegen/bin (#109) - Full xcodebuild build-for-testing + codesign + test-without-building sequence mirroring .gitea/workflows/macos.yml - Unit tests only (ICCeryCoreTests) — --- AGENTS.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 010914e..dc9c0ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,53 @@ codesign -dvv ``` Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging. +## Remote test builds (runner host) +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 +reproduce locally. + +- Host: `localadmin@192.168.0.172` (SSH, LAN). Runner daemon: + `gitea-runner daemon -c /opt/gitea-runner/config.yaml`. +- Credentials: `~/Projects/build-host.env` defines `BUILD_HOST_USER` / + `BUILD_HOST_PASSWORD`. **Never read or print this file** — load it with + `set -a; source ~/Projects/build-host.env; set +a` so values stay in the + environment. +- `sshpass` is installed (`/usr/local/bin/sshpass`). Feed the password via + the `SSHPASS` env var — never on the command line: + ```sh + SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e ssh localadmin@192.168.0.172 '' + SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e scp localadmin@192.168.0.172: + ``` + First connection needs `-o StrictHostKeyChecking=accept-new`. +- Remote checkout: `~/Projects/iccery-v2-mac` (anonymous HTTPS clone works; + full history needed by `scripts/version.sh`). Before testing, sync it to + the same base commit as the local work: + `git fetch origin && git checkout `, then `scp` each + locally-changed file over its remote counterpart (uncommitted work + transfers this way — there is no push). +- `xcodegen` is **not** on PATH: prepend + `$HOME/.local/xcodegen/2.38.0/xcodegen/bin` (prebuilt 2.38.0 installed + by `scripts/ensure-host-tools.sh`; brew's formula needs Xcode 15.3, + impossible on macOS 12 — #109). +- Mirror `.gitea/workflows/macos.yml` exactly, from the repo root with + `DERIVED=build/DerivedData-test`: + `scripts/ensure-host-tools.sh` → `xcodegen generate --spec project.yml` + → `eval "$(scripts/version.sh)"` → `xcodebuild build-for-testing + -scheme ICCery -destination 'platform=macOS' -derivedDataPath "$DERIVED" + -configuration Debug ARCHS="$(uname -m)" ONLY_ACTIVE_ARCH=NO + CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-' MARKETING_VERSION=… + CURRENT_PROJECT_VERSION=… ICCERY_RELEASE_TAG=…` → codesign every + `*_PackageProduct.framework` under `$DERIVED/Build/Products/Debug` + (#119) → `xcodebuild test-without-building -xctestrun + -only-testing:ICCeryCoreTests -destination 'platform=macOS' + -derivedDataPath "$DERIVED"`. +- Run **unit tests only** (`ICCeryCoreTests`). UI tests need a logged-in + GUI session and fail environmentally on the VM (runs 29700, 29804, + 42183) — do not use them for verification unless asked. +- xcodebuild takes minutes — run the ssh command in a background shell + and poll rather than blocking. + ## Private ColorSync SPI 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`. Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`. -- 2.39.5 From ed91ebf807f7a5d010d98652f98f04a1fb19874f Mon Sep 17 00:00:00 2001 From: Gronod Date: Fri, 18 Sep 2026 10:38:57 +0100 Subject: [PATCH 13/14] test(print): scroll stage-2 via synthesized scroll wheel (#215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit press(forDuration:thenDragTo:) is inert on macOS — content-drag scrolling is iOS-only, so the helper burned its 20s budget with the gallery frame never moving. The scroll wheel is the mechanism the platform supports: one scroll(byDeltaX:deltaY:) call on a coordinate inside app.scrollViews["stage-2"] per poll iteration, negative deltaY to move content up off the Dock zone. --- Tests/ICCeryUITests/Milestone3UITests.swift | 47 ++++++++------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/Tests/ICCeryUITests/Milestone3UITests.swift b/Tests/ICCeryUITests/Milestone3UITests.swift index bf1d29c..d5d0e63 100644 --- a/Tests/ICCeryUITests/Milestone3UITests.swift +++ b/Tests/ICCeryUITests/Milestone3UITests.swift @@ -114,18 +114,18 @@ final class Milestone3UITests: XCTestCase { return recordedSpoolLog() } - /// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button - /// moves up, clear of the Dock collision zone at the window's - /// bottom edge (#132). + /// Scrolls `stage-2` with the synthesized scroll wheel so + /// `identifier`'s button moves up, clear of the Dock collision + /// zone at the window's bottom edge (#132). /// /// macOS overlay scrollbars are not in the AX tree — never use - /// `app.scrollBars` — and a synthesized scroll wheel is inert on - /// this LazyVGrid, so the scroll is a real drag on the gallery - /// cell's content. A stale/off-screen AX frame resolves to a screen - /// point that can be a Dock icon — a coordinate click there once - /// opened Calendar instead of Print. Callers must click only when - /// the returned element `isHittable`; never coordinate-click a - /// stale frame. + /// `app.scrollBars` — and a click-drag does not scroll a macOS + /// ScrollView (content-drag scrolling is iOS-only); the scroll + /// wheel is the mechanism the platform supports (#215). A + /// stale/off-screen AX frame resolves to a screen point that can + /// be a Dock icon — a coordinate click there once opened Calendar + /// instead of Print. Callers must click only when the returned + /// element `isHittable`; never coordinate-click a stale frame. @discardableResult private func scrollStage2UntilHittable( _ identifier: String, @@ -134,6 +134,8 @@ final class Milestone3UITests: XCTestCase { var button = app.buttons[identifier] let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0") + let scrollView = app.scrollViews["stage-2"] + XCTAssertTrue(scrollView.waitForExistence(timeout: 10), "stage-2") let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { @@ -142,25 +144,12 @@ final class Milestone3UITests: XCTestCase { button.frame.maxY < windowBottom - 80 { return button } - // Grab the upper half of the cell (the TIFF, not the Print - // button / Dock) and drag toward the top of the window. - // Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves - // the Dock zone. - if cell.isHittable { - let start = cell.coordinate(withNormalizedOffset: - CGVector(dx: 0.5, dy: 0.25)) - let end = start.withOffset(CGVector(dx: 0, dy: -280)) - start.press(forDuration: 0.15, thenDragTo: end) - } else { - // Cell not hit-testable: drag the stage-2 content - // directly — still content, still never scrollBars. - let scrollView = app.scrollViews["stage-2"] - scrollView.coordinate(withNormalizedOffset: - CGVector(dx: 0.5, dy: 0.55)) - .press(forDuration: 0.15, thenDragTo: - scrollView.coordinate(withNormalizedOffset: - CGVector(dx: 0.5, dy: 0.15))) - } + // Wheel-down inside the stage-2 viewport: content moves + // UP ⇒ Print leaves the Dock zone. Negative deltaY scrolls + // toward the document bottom (#215). + scrollView.coordinate(withNormalizedOffset: + CGVector(dx: 0.5, dy: 0.5)) + .scroll(byDeltaX: 0, deltaY: -60) RunLoop.current.run(until: Date().addingTimeInterval(0.4)) button = app.buttons[identifier] } -- 2.39.5 From f7f6a8bd87aefe2606e732b1799d017a203afab5 Mon Sep 17 00:00:00 2001 From: Gronod Date: Fri, 18 Sep 2026 11:06:42 +0100 Subject: [PATCH 14/14] chore: bump MARKETING_VERSION to 2.0.3 --- README.md | 2 +- project.yml | 2 +- scripts/package-release.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index edd0a1c..6146ffe 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll. |---|---| | Product | ICCery v2 for macOS | | Bundle | `com.gronod.iccery2` | -| Version | 2.0.0 | +| Version | 2.0.3 | | Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` | | Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) | | CI | Gitea Actions `macos-12` runner | diff --git a/project.yml b/project.yml index 0f622e3..ded120b 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.2" + MARKETING_VERSION: "2.0.3" 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. diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 7c13ed6..09a956a 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -150,7 +150,7 @@ if [ ! -f "$DMG_BACKGROUND" ]; then fi echo "==> Building DMG" -VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.0')" +VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.3')" BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')" # Tagged/described builds carry the tag in the DMG name so prerelease # artefacts are self-describing (ICCery-2.0.0-pre2-grok-236.dmg); a plain -- 2.39.5