Compare commits

...
Author SHA1 Message Date
gronod efeefb8cea chore: bump MARKETING_VERSION to 2.0.4
macOS CI / build-and-test (push) Successful in 43m3s
macOS CI / package (push) Successful in 3m25s
Bumps the project version ahead of the develop→main merge so the
upcoming release tag can match project.yml.
2026-09-22 19:40:28 +01:00
gronod 7fc38aabbb Merge branch 'milestone/m13-print-ticket' of https://git.i3omb.com/gronod/iccery-v2-mac into milestone/m13-print-ticket
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 14m9s
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 1m48s
2026-09-22 19:22:48 +01:00
gronod 972e166430 Merge branch 'feat/218-paper-source' into milestone/m13-print-ticket 2026-09-22 19:21:18 +01:00
gronod ecbf46723b feat(print): #218 Stage 2 paper-source selection + orientation dropdown
macOS CI / build-and-test (push) Skipped
- Detect vendor tray keys in CupsParsers with InputSlot/MediaSource/EPIJ_FdSo/EPIJ_PSrc/CNIJMediaSupply roster; EPIJ_FdSo wins over EPIJ_PSrc.
- PrinterTray now carries raw token + display name; PrinterCapabilities exposes trayKey, trayDefault and tray-specific quality maps.
- CupsService enumerates trays from the detected key, enriches labels from PPD, seeds selection from CUPS default, and precomputes per-tray media→quality constraints.
- MediaQualityConstraints resolves quality availability from both selected media and selected tray, falling back to the media-only map.
- TicketWriteResolver emits the selected tray key=token in the locked write order between PageSize and media key.
- PrintPanelService applies the Stage 2 tray token to PMPrintSettings on open and captures the tray back from the full roster plus AP_D_InputSlot.
- PrintSessionViewModel keeps selectedTrayToken, applies capture-back, preserves unknown captured tokens as synthetic trays, and passes tray into spool overrides.
- Stage2View replaces portrait/landscape buttons with an orientation dropdown (printerOrientationSelect) while preserving portrait/landscape semantics and orientation-requested=3|4.
- Add/update core, UI and fixture tests for tray detection, enumeration, PPD labels, defaults, unknown-token synthesis, panel capture, ticket write order and tray-aware quality clamping.
- Update docs/10-print-system.md, docs/11-print-macos.md, docs/21-ui-reference.md and docs/24-issues-invariants.md.

Verified on the macOS 12.7.6 x86_64 CI runner: xcodebuild build-for-testing and ICCeryCoreTests pass (557 tests, 0 failures).
2026-09-22 19:19:33 +01:00
gronod bd14f30a05 Merge pull request '[Bug/High] Restore captured PrintTicket when reopening the printer properties dialog' (#219) from feat/217-panel-ticket-restore into milestone/m13-print-ticket
macOS CI / build-and-test (push) Skipped
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 1m25s
Reviewed-on: #219
2026-09-22 18:56:31 +01:00
gronod 12061a03ac fix(print): restore captured ticket when reopening properties dialog (#217)
macOS CI / build-and-test (push) Skipped
The bound NSPrintPanel always opened on queue driver defaults —
capturedTickets[queue] was stored but never passed back, so every
vendor-PDE setting outside the four mirrored fields was discarded
on reopen (RC1), and pre-selection writes were never validated into
the ticket before runModal (RC2).

Extract the pre-modal configuration into a testable
configureBoundPanel seam with the spooler's S4→S7 order: ticket
restore (warn-only, unlike the spooler's fatal try — a stale ticket
must not keep the dialog from opening → Stage 2 selections (D6
parity) → PMSessionValidatePrintSettings + updateFromPMPrintSettings
→ applyPaper → suppression ②–⑤.

ICCERY_DEBUG_PANEL_DUMP=1 logs the raw PMPrintSettingsToOptions
output and serialised ticket sizes pre/post modal for the XP-55
RC3 verification.

Refs #217
EOF
)
2026-09-22 17:47:09 +01:00
27 changed files with 1080 additions and 165 deletions
+4
View File
@@ -94,6 +94,10 @@ codesign -dvv <sidecar>
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging. Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
## Remote test builds (runner host) ## Remote test builds (runner host)
**Policy: all builds and test runs that _can_ execute on the remote host
_should_ execute there.** Use `localadmin@192.168.0.172` whenever the
Gitea `macos_12` runner is not actively executing a workflow job.
Test builds done as part of change implementations run on the CI runner 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 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 with Xcode 14.2, and CI failures there are often environmental and do not
@@ -17,7 +17,8 @@ public enum CupsOptionsFilter {
// Media // Media
"MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType", "MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType",
// Tray // Tray
"InputSlot", "AP_D_InputSlot", "InputSlot", "MediaSource", "AP_D_InputSlot",
"EPIJ_FdSo", "EPIJ_PSrc", "CNIJMediaSupply",
// Size // Size
"PageSize", "PageSize",
// Colour bypass // Colour bypass
@@ -353,6 +353,55 @@ public enum CupsParsers {
.map { $0 == "4" ? "landscape" : "portrait" } .map { $0 == "4" ? "landscape" : "portrait" }
} }
// MARK: - Tray detection (#218)
/// Paper-source option key in preference order. `EPIJ_FdSo` is
/// preferred over `EPIJ_PSrc` because Epson InkjetPrinter2 PPDs
/// label `EPIJ_FdSo` as "Paper Source" and `EPIJ_PSrc` as
/// "Page Setup" — the feed source is what Stage 2 should control.
/// `AP_D_InputSlot` is a capture-side alias only.
public static let trayKeys = [
"InputSlot", "MediaSource", "EPIJ_FdSo", "EPIJ_PSrc",
"CNIJMediaSupply"
]
public static func detectTrayKey(optionKeys: Set<String>) -> String? {
trayKeys.first { optionKeys.contains($0) }
}
/// Extract a paper-source token from a captured `key=value` string.
/// If `preferredKey` is supplied and present, it wins; otherwise
/// every roster key is tried. `AP_D_InputSlot` is accepted as an
/// alias when it carries a non-empty value. Empty values are
/// ignored so an `AP_D_InputSlot=` placeholder never maps to a
/// selection (#218 capture-back).
public static func extractTray(
fromOptionsString options: String,
preferredKey: String? = nil
) -> String? {
let pairs = lpoptions(options)
if let preferredKey,
let value = pairs.first(where: {
$0.key.caseInsensitiveCompare(preferredKey) == .orderedSame
})?.value, !value.isEmpty {
return value
}
for key in trayKeys {
if let value = pairs.first(where: {
$0.key.caseInsensitiveCompare(key) == .orderedSame
})?.value, !value.isEmpty {
return value
}
}
// Capture-side alias used by some macOS Core Printing paths.
if let value = pairs.first(where: {
$0.key.caseInsensitiveCompare("AP_D_InputSlot") == .orderedSame
})?.value, !value.isEmpty {
return value
}
return nil
}
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys /// Driver "no colour adjustment" key=value for `lpoptions -l` keys
/// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`; /// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`;
/// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`; /// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`;
@@ -122,8 +122,28 @@ public struct CupsService: Sendable {
let listings = try await optionListings(for: queue) let listings = try await optionListings(for: queue)
let ppd = loadPPD(for: queue) let ppd = loadPPD(for: queue)
var caps = capabilities(from: listings, ppd: ppd) var caps = capabilities(from: listings, ppd: ppd)
// Default queue-level map, then per-tray overrides (#218).
// Share one file-read cache across all contexts so multiple
// tray-token resolutions read Epson PDEData.dat only once.
var fileCache: [URL: Data] = [:]
let readFile: (URL) -> Data? = {
if let cached = fileCache[$0] { return cached }
guard let data = try? Data(contentsOf: $0) else { return nil }
fileCache[$0] = data
return data
}
caps.qualityIDsByMediaType = MediaQualityConstraints.resolve( caps.qualityIDsByMediaType = MediaQualityConstraints.resolve(
listings: listings, ppd: ppd) listings: listings, ppd: ppd,
optionOverrides: [:], readFile: readFile)
if let trayKey = caps.trayKey {
for tray in caps.trays {
let map = MediaQualityConstraints.resolve(
listings: listings, ppd: ppd,
optionOverrides: [trayKey: tray.token],
readFile: readFile)
caps.qualityIDsByMediaTypeByTray[tray.token] = map
}
}
return caps return caps
} }
@@ -132,16 +152,12 @@ public struct CupsService: Sendable {
from listings: [CupsOptionListing], from listings: [CupsOptionListing],
ppd: String? ppd: String?
) -> PrinterCapabilities { ) -> PrinterCapabilities {
var trays: [PrinterTray] = [] let optionKeys = Set(listings.map(\.key))
var sizes: [PrinterPaperSize] = [] var sizes: [PrinterPaperSize] = []
var media: [PrinterMediaType] = [] var media: [PrinterMediaType] = []
for listing in listings { for listing in listings {
switch listing.key { switch listing.key {
case "InputSlot", "MediaSource":
trays = listing.choices.enumerated().map {
PrinterTray(id: $0.offset + 1, name: $0.element)
}
case "PageSize", "MediaSize": case "PageSize", "MediaSize":
sizes = listing.choices.enumerated().map { sizes = listing.choices.enumerated().map {
PrinterPaperSize(id: $0.offset + 1, name: $0.element) PrinterPaperSize(id: $0.offset + 1, name: $0.element)
@@ -159,12 +175,32 @@ public struct CupsService: Sendable {
} }
} }
// Paper source — one deterministic vendor roster key (#218).
let trayKey = CupsParsers.detectTrayKey(optionKeys: optionKeys)
let trays: [PrinterTray]
let trayDefault: String?
if let trayKey,
let listing = listings.first(where: { $0.key == trayKey }) {
let labels = ppd.map {
CupsParsers.ppdChoiceLabels($0, key: trayKey)
} ?? [:]
trays = listing.choices.enumerated().map {
PrinterTray(
id: $0.offset + 1,
token: $0.element,
name: labels[$0.element] ?? $0.element)
}
trayDefault = listing.defaultChoice
} else {
trays = []
trayDefault = nil
}
// Print quality — the detected roster key's listing maps to // Print quality — the detected roster key's listing maps to
// `PrinterQuality` with PPD labels and the `*` default (#183). // `PrinterQuality` with PPD labels and the `*` default (#183).
var qualities: [PrinterQuality] = [] var qualities: [PrinterQuality] = []
var qualityDefault: String? var qualityDefault: String?
let qualityKey = CupsParsers.detectQualityKey( let qualityKey = CupsParsers.detectQualityKey(optionKeys: optionKeys)
optionKeys: Set(listings.map(\.key)))
if let qualityKey, if let qualityKey,
let listing = listings.first(where: { $0.key == qualityKey }) { let listing = listings.first(where: { $0.key == qualityKey }) {
let labels = ppd.map { let labels = ppd.map {
@@ -178,7 +214,8 @@ public struct CupsService: Sendable {
return PrinterCapabilities( return PrinterCapabilities(
trays: trays, paperSizes: sizes, mediaTypes: media, trays: trays, paperSizes: sizes, mediaTypes: media,
qualityKey: qualityKey, qualities: qualities, trayKey: trayKey, qualityKey: qualityKey,
qualities: qualities, trayDefault: trayDefault,
qualityDefault: qualityDefault) qualityDefault: qualityDefault)
} }
@@ -32,6 +32,7 @@ public enum MediaQualityConstraints {
public static func resolve( public static func resolve(
listings: [CupsOptionListing], listings: [CupsOptionListing],
ppd: String?, ppd: String?,
optionOverrides: [String: String] = [:],
readFile: (URL) -> Data? = { try? Data(contentsOf: $0) } readFile: (URL) -> Data? = { try? Data(contentsOf: $0) }
) -> [String: Set<String>] { ) -> [String: Set<String>] {
let optionKeys = Set(listings.map(\.key)) let optionKeys = Set(listings.map(\.key))
@@ -46,9 +47,12 @@ public enum MediaQualityConstraints {
else { return [:] } else { return [:] }
let mediaIDs = Set(mediaListing.choices) let mediaIDs = Set(mediaListing.choices)
let qualityIDs = Set(qualityListing.choices) let qualityIDs = Set(qualityListing.choices)
let defaults = Dictionary( var defaults = Dictionary(
listings.compactMap { l in l.defaultChoice.map { (l.key, $0) } }, listings.compactMap { l in l.defaultChoice.map { (l.key, $0) } },
uniquingKeysWith: { first, _ in first }) uniquingKeysWith: { first, _ in first })
for (key, value) in optionOverrides {
defaults[key] = value
}
guard let ppd else { return [:] } guard let ppd else { return [:] }
let ppdMap = allowedMap( let ppdMap = allowedMap(
@@ -48,13 +48,17 @@ public struct Printer: Codable, Equatable, Sendable {
} }
/// Paper source. `id` is the 1-based index of the `InputSlot` / /// Paper source. `id` is the 1-based index of the `InputSlot` /
/// `MediaSource` choice (not a PPD code) — docs/10. /// `MediaSource` / vendor choice (not a PPD code); `token` is the
/// exact CUPS/PPD machine value written to the ticket; `name` is the
/// PPD-enriched display label (#218).
public struct PrinterTray: Codable, Equatable, Sendable { public struct PrinterTray: Codable, Equatable, Sendable {
public var id: Int public var id: Int
public var token: String
public var name: String public var name: String
public init(id: Int, name: String) { public init(id: Int, token: String, name: String) {
self.id = id self.id = id
self.token = token
self.name = name self.name = name
} }
} }
@@ -101,10 +105,16 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
/// Always `true` on macOS (spec parity — CUPS honours /// Always `true` on macOS (spec parity — CUPS honours
/// `orientation-requested`). /// `orientation-requested`).
public var supportsOrientation: Bool public var supportsOrientation: Bool
/// The queue's detected tray enumeration key
/// (`CupsParsers.detectTrayKey`), e.g. `EPIJ_FdSo` (#218).
public var trayKey: String?
/// The queue's detected quality enumeration key /// The queue's detected quality enumeration key
/// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183). /// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183).
public var qualityKey: String? public var qualityKey: String?
public var qualities: [PrinterQuality] public var qualities: [PrinterQuality]
/// The `*`-marked default tray token from `lpoptions -l`, if any
/// (#218).
public var trayDefault: String?
/// The `*`-marked default choice from `lpoptions -l`, if any. /// The `*`-marked default choice from `lpoptions -l`, if any.
public var qualityDefault: String? public var qualityDefault: String?
/// Media-type id → the quality ids the driver accepts for it /// Media-type id → the quality ids the driver accepts for it
@@ -112,50 +122,81 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
/// exposes no per-media quality matrix; a missing entry means /// exposes no per-media quality matrix; a missing entry means
/// "unconstrained" — show everything. /// "unconstrained" — show everything.
public var qualityIDsByMediaType: [String: Set<String>] public var qualityIDsByMediaType: [String: Set<String>]
/// Tray token → media-type id → allowed quality ids. Computed
/// once at capability load; a missing tray falls back to
/// `qualityIDsByMediaType` (#218).
public var qualityIDsByMediaTypeByTray: [String: [String: Set<String>]]
public init( public init(
trays: [PrinterTray] = [], trays: [PrinterTray] = [],
paperSizes: [PrinterPaperSize] = [], paperSizes: [PrinterPaperSize] = [],
mediaTypes: [PrinterMediaType] = [], mediaTypes: [PrinterMediaType] = [],
supportsOrientation: Bool = true, supportsOrientation: Bool = true,
trayKey: String? = nil,
qualityKey: String? = nil, qualityKey: String? = nil,
qualities: [PrinterQuality] = [], qualities: [PrinterQuality] = [],
trayDefault: String? = nil,
qualityDefault: String? = nil, qualityDefault: String? = nil,
qualityIDsByMediaType: [String: Set<String>] = [:] qualityIDsByMediaType: [String: Set<String>] = [:],
qualityIDsByMediaTypeByTray: [String: [String: Set<String>]] = [:]
) { ) {
self.trays = trays self.trays = trays
self.paperSizes = paperSizes self.paperSizes = paperSizes
self.mediaTypes = mediaTypes self.mediaTypes = mediaTypes
self.supportsOrientation = supportsOrientation self.supportsOrientation = supportsOrientation
self.trayKey = trayKey
self.qualityKey = qualityKey self.qualityKey = qualityKey
self.qualities = qualities self.qualities = qualities
self.trayDefault = trayDefault
self.qualityDefault = qualityDefault self.qualityDefault = qualityDefault
self.qualityIDsByMediaType = qualityIDsByMediaType self.qualityIDsByMediaType = qualityIDsByMediaType
self.qualityIDsByMediaTypeByTray = qualityIDsByMediaTypeByTray
} }
/// Qualities valid for `mediaID`, preserving the driver's listing /// The quality map for the requested tray token, if any.
/// order (#180). Falls back to the full list when the driver has /// Prefers the tray-specific map; otherwise falls back to the
/// no per-media data, the media is absent from the map, or the /// queue-default map. An explicitly empty tray map means the tray
/// filter would empty the picker (#214). /// is unconstrained, so `nil` is returned only when neither map
public func qualities(forMediaType mediaID: String?) -> [PrinterQuality] { /// exists.
private func qualityMap(
forTrayToken trayToken: String?
) -> [String: Set<String>]? {
if let trayToken,
let map = qualityIDsByMediaTypeByTray[trayToken],
!map.isEmpty {
return map
}
return qualityIDsByMediaType.isEmpty ? nil : qualityIDsByMediaType
}
/// Qualities valid for `mediaID` and an optional tray token,
/// preserving the driver's listing order (#180/#218). Falls back
/// to the full list when the driver has no per-media data, the
/// media is absent from the map, or the filter would empty the
/// picker (#214).
public func qualities(
forMediaType mediaID: String?,
trayToken: String? = nil
) -> [PrinterQuality] {
guard let mediaID, guard let mediaID,
let allowed = qualityIDsByMediaType[mediaID], let allowed = qualityMap(forTrayToken: trayToken)?[mediaID],
!allowed.isEmpty !allowed.isEmpty
else { return qualities } else { return qualities }
let filtered = qualities.filter { allowed.contains($0.id) } let filtered = qualities.filter { allowed.contains($0.id) }
return filtered.isEmpty ? qualities : filtered return filtered.isEmpty ? qualities : filtered
} }
/// Whether `qualityID` is usable with `mediaID` — `true` whenever /// Whether `qualityID` is usable with `mediaID` and an optional
/// the driver exposes no constraint for that media (#214). A /// tray token (#218). A quality absent from `qualities` entirely
/// quality absent from `qualities` entirely is still allowed: a /// is still allowed: a driver-captured token can be legitimate
/// driver-captured token can be legitimate even when `lpoptions` /// even when `lpoptions` never listed it.
/// never listed it.
public func allowsQuality( public func allowsQuality(
_ qualityID: String, forMediaType mediaID: String? _ qualityID: String,
forMediaType mediaID: String?,
trayToken: String? = nil
) -> Bool { ) -> Bool {
guard let mediaID, guard let mediaID,
let allowed = qualityIDsByMediaType[mediaID], let allowed = qualityMap(forTrayToken: trayToken)?[mediaID],
!allowed.isEmpty !allowed.isEmpty
else { return true } else { return true }
return allowed.contains(qualityID) return allowed.contains(qualityID)
+13 -1
View File
@@ -133,17 +133,29 @@ enum UITestHooks {
/// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and /// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and
/// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the /// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the
/// queue the panel was opened for). /// queue the panel was opened for).
static func printPanelResult(forQueue queue: String) -> PrintPropertiesResult? { static func printPanelResult(
forQueue queue: String,
trays: [PrinterTray] = [],
preferredTrayKey: String? = nil
) -> PrintPropertiesResult? {
switch env["ICCERY_TEST_PRINT_PANEL"] { switch env["ICCERY_TEST_PRINT_PANEL"] {
case "ok": case "ok":
let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap { let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap {
$0.isEmpty ? nil : $0 $0.isEmpty ? nil : $0
} }
let trayToken = options.flatMap {
CupsParsers.extractTray(
fromOptionsString: $0, preferredKey: preferredTrayKey)
}
let paperSource = trays.first(where: {
$0.token == trayToken
}).map(\.id)
return PrintPropertiesResult( return PrintPropertiesResult(
selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap { selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap {
$0.isEmpty ? nil : $0 $0.isEmpty ? nil : $0
} ?? queue, } ?? queue,
options: PrintOptions( options: PrintOptions(
paperSource: paperSource,
orientation: options.flatMap { orientation: options.flatMap {
CupsParsers.extractOrientation(fromOptionsString: $0) CupsParsers.extractOrientation(fromOptionsString: $0)
}, },
+30 -15
View File
@@ -181,29 +181,44 @@ struct ColorSyncSuppressor {
// MARK: - Layer ⑥ capture // MARK: - Layer ⑥ capture
/// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType)`. /// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType,
/// The malloc'd C string is freed after copying. /// tray)`. The malloc'd C string is freed after copying. `trayKey`
/// is the queue's detected tray enumeration key; if nil, the full
/// roster plus the capture-only `AP_D_InputSlot` alias is tried.
func captureOptions( func captureOptions(
from settings: PMPrintSettings from settings: PMPrintSettings,
) -> (cupsOptions: String?, mediaType: String?) { trayKey: String?
guard let toOptions = optionsResolver() else { ) -> (cupsOptions: String?, mediaType: String?, tray: String?) {
log("ColorSync: PMPrintSettingsToOptions unavailable — " guard let unfiltered = rawOptionsDump(from: settings) else {
+ "panel options not captured") return (nil, nil, nil)
return (nil, nil)
} }
var raw: UnsafeMutablePointer<CChar>?
guard toOptions(settings, &raw) == 0, let raw else {
return (nil, nil)
}
defer { free(raw) }
let unfiltered = String(cString: raw)
let filtered = CupsOptionsFilter.filter(unfiltered) let filtered = CupsOptionsFilter.filter(unfiltered)
return ( return (
filtered.isEmpty ? nil : filtered, filtered.isEmpty ? nil : filtered,
CupsParsers.extractMediaType(fromOptionsString: unfiltered) CupsParsers.extractMediaType(fromOptionsString: unfiltered),
CupsParsers.extractTray(
fromOptionsString: unfiltered,
preferredKey: trayKey)
) )
} }
/// Unfiltered `PMPrintSettingsToOptions` output — the diagnostics
/// half of the capture path (#217 `ICCERY_DEBUG_PANEL_DUMP`).
/// `nil` when the symbol or the call fails.
func rawOptionsDump(from settings: PMPrintSettings) -> String? {
guard let toOptions = optionsResolver() else {
log("ColorSync: PMPrintSettingsToOptions unavailable — "
+ "panel options not captured")
return nil
}
var raw: UnsafeMutablePointer<CChar>?
guard toOptions(settings, &raw) == 0, let raw else {
return nil
}
defer { free(raw) }
return String(cString: raw)
}
// MARK: - dlsym // MARK: - dlsym
private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? { private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? {
+190 -61
View File
@@ -21,6 +21,10 @@ struct PrintPanelInitialSelections {
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written /// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written
/// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1). /// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1).
var paperSize: String? var paperSize: String?
/// The queue's detected tray enumeration key, e.g. `EPIJ_FdSo`.
var trayKey: String?
/// The selected tray machine token.
var tray: String?
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`. /// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
var qualityKey: String? var qualityKey: String?
/// The selected quality token. /// The selected quality token.
@@ -32,6 +36,23 @@ struct PrintPanelInitialSelections {
var orientation: String? var orientation: String?
} }
/// One bound-panel invocation (#217): the queue, its `lpoptions -l`
/// key roster, the Stage 2 pre-selections, and the session's captured
/// ticket — everything `makePanelPrintInfo` needs before the modal.
struct PrintPanelRequest {
let queue: String
let displayName: String?
let optionKeys: Set<String>
let initialSelections: PrintPanelInitialSelections
/// The queue's previously captured ticket — rehydrated before the
/// Stage 2 writes so vendor PDE state survives a reopen (#217).
let ticket: PrintTicket?
/// Roster of paper-source choices used to map a captured option
/// token back to a 1-based ID. Empty for queues with no tray key
/// (#218).
let trays: [PrinterTray]
}
/// Preferences → native `NSPrintPanel` bound to the selected CUPS /// Preferences → native `NSPrintPanel` bound to the selected CUPS
/// queue (issue 13, docs/11). /// queue (issue 13, docs/11).
/// ///
@@ -58,11 +79,16 @@ struct PrintPanelService {
displayName: String?, displayName: String?,
cupsService: CupsService, cupsService: CupsService,
initialSelections: PrintPanelInitialSelections = initialSelections: PrintPanelInitialSelections =
PrintPanelInitialSelections() PrintPanelInitialSelections(),
ticket: PrintTicket? = nil,
trays: [PrinterTray] = []
) async throws -> PanelCaptureResult? { ) async throws -> PanelCaptureResult? {
#if DEBUG #if DEBUG
if UITestHooks.printPanelStubbed { if UITestHooks.printPanelStubbed {
return UITestHooks.printPanelResult(forQueue: queue).map { return UITestHooks.printPanelResult(
forQueue: queue, trays: trays,
preferredTrayKey: initialSelections.trayKey
).map {
PanelCaptureResult(properties: $0, ticket: nil) PanelCaptureResult(properties: $0, ticket: nil)
} }
} }
@@ -75,83 +101,140 @@ struct PrintPanelService {
let optionKeys = (try? await cupsService.optionKeys(for: queue)) let optionKeys = (try? await cupsService.optionKeys(for: queue))
?? [] ?? []
return try runNativePanel( return try runNativePanel(
queue: queue, displayName: display, optionKeys: optionKeys, PrintPanelRequest(
initialSelections: initialSelections) queue: queue, displayName: display,
optionKeys: optionKeys,
initialSelections: initialSelections, ticket: ticket,
trays: trays))
} }
// MARK: - Panel // MARK: - Panel
private func runNativePanel( /// The pre-modal half of `runNativePanel` — a fully configured
queue: String, /// `NSPrintInfo` plus the `boundViaPM` flag. Extracted (same
displayName: String?, /// pattern as `NativeTargetSpooler.makePrintInfo`) so unit tests
optionKeys: Set<String>, /// assert ticket restore / Stage 2 precedence without driving the
initialSelections: PrintPanelInitialSelections /// modal (#217). The caller owns `printer` — it must stay alive
) throws -> PanelCaptureResult? { /// through the modal.
func makePanelPrintInfo(
for request: PrintPanelRequest,
boundTo printer: PMPrinter?
) throws -> (printInfo: NSPrintInfo, boundViaPM: Bool) {
let printInfo = NSPrintInfo() let printInfo = NSPrintInfo()
var pmPrinter: PMPrinter?
var boundViaPM = false
// ① Bind the session to the selected CUPS queue (docs/11). // ① Bind the session to the selected CUPS queue (docs/11).
if let printer = PMTicketBridge.makePrinter(queue: queue) { if let printer {
pmPrinter = printer try PMTicketBridge.bind(printer: printer, to: printInfo)
let session = PMTicketBridge.session(printInfo) configureBoundPanel(
let settings = PMTicketBridge.settings(printInfo) printInfo, request: request, printer: printer)
do {
try PMTicketBridge.bind(printer: printer, to: printInfo)
} catch {
PMTicketBridge.release(printer)
throw error
}
// Initial selections — after `PMSessionDefault*`, before
// ColorSync suppression ②–⑤ (locked write order,
// #183/#186). Paper is TWO writes (E1): the `PageSize`
// print-settings value drivers/capture read AND the
// `PMPageFormat` paper the panel's dropdown reflects.
applyInitialSelections(
initialSelections, to: settings, optionKeys: optionKeys)
if let paperToken = initialSelections.paperSize {
PMTicketBridge.applyPaper(
token: paperToken, printer: printer,
session: session, printInfo: printInfo)
}
boundViaPM = true
} else { } else {
// Fallback: NSPrinter by display name (docs/11 §binding). // Fallback: NSPrinter by display name (docs/11 §binding).
// Warn — the display name can resolve a *different* queue // Warn — the display name can resolve a *different* queue
// (#186 E2: diagnosable, not a proven defect). // (#186 E2: diagnosable, not a proven defect).
AppLogger.shared.warn( AppLogger.shared.warn(
"Print panel: PM binding unavailable for '\(queue)' — " "Print panel: PM binding unavailable for "
+ "'\(request.queue)' — "
+ "falling back to NSPrinter(displayName)") + "falling back to NSPrinter(displayName)")
guard let displayName, guard let displayName = request.displayName,
let nsPrinter = NSPrinter(name: displayName) let nsPrinter = NSPrinter(name: displayName)
else { else {
throw PrintPanelError.noPrinterFound( throw PrintPanelError.noPrinterFound(
displayName ?? queue) request.displayName ?? request.queue)
} }
printInfo.printer = nsPrinter printInfo.printer = nsPrinter
printInfo.setUpPrintOperationDefaultValues() printInfo.setUpPrintOperationDefaultValues()
return (printInfo, false)
} }
return (printInfo, true)
}
/// Post-bind configuration in locked order (#217 — mirrors the
/// spooler's S4→S7): ticket restore → Stage 2 selections →
/// validate → paper → suppression ②–⑤. Internal so unit tests can
/// drive it with `printer: nil` — `applyPaper` and the SPI layer
/// need a bound printer and skip when it is absent, while ticket
/// restore / selections / validate still run on the session-less
/// `NSPrintInfo`.
func configureBoundPanel(
_ printInfo: NSPrintInfo,
request: PrintPanelRequest,
printer: PMPrinter?
) {
let session = PMTicketBridge.session(printInfo)
let settings = PMTicketBridge.settings(printInfo)
// Rehydrate the captured ticket *before* the Stage 2 writes
// (#217 — restore-then-override). Warn-only, unlike the
// spooler's fatal `try`: a stale/corrupt ticket must not keep
// the dialog from opening. `restore` itself refuses a
// cross-queue replay (R3).
if let ticket = request.ticket {
do {
try PMTicketBridge.restore(ticket, into: printInfo)
} catch {
AppLogger.shared.warn(
"Print panel: ticket restore failed — "
+ "opening on queue defaults "
+ "(\(error.localizedDescription))")
}
}
// Initial selections — Stage 2 wins over the restored ticket
// (D6 parity). Paper is TWO writes (E1): the `PageSize`
// print-settings value drivers/capture read AND the
// `PMPageFormat` paper the panel's dropdown reflects.
applyInitialSelections(
request.initialSelections, to: settings,
optionKeys: request.optionKeys)
// Reconcile the pre-writes into the ticket before the modal
// (#217 RC2): the vendor PDE derives pane state from a
// validated ticket. Warn-only — log when the driver adjusts
// so a companion-key desync is diagnosable.
var changed = DarwinBoolean(false)
_ = PMSessionValidatePrintSettings(session, settings, &changed)
if changed.boolValue {
AppLogger.shared.info(
"Print panel: driver adjusted the pre-selections")
}
printInfo.updateFromPMPrintSettings()
if let printer, let paperToken = request.initialSelections.paperSize {
PMTicketBridge.applyPaper(
token: paperToken, printer: printer,
session: session, printInfo: printInfo)
}
// ②–⑤ ColourSync suppression. The SPI needs a session with a
// current printer to attach to; the SetValue/mirror layers
// are warn-only and run regardless.
if printer != nil {
suppressor.applySPIMode(to: session) // ②
}
suppressor.applyLockedKeys(to: settings) // ③
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
let driverBypass = suppressor.applyDriverBypass( // ④
to: settings, optionKeys: request.optionKeys)
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
}
private func runNativePanel(
_ request: PrintPanelRequest
) throws -> PanelCaptureResult? {
// The PM printer handle is held through the modal and
// released on every path.
let pmPrinter = PMTicketBridge.makePrinter(queue: request.queue)
defer { defer {
if let printer = pmPrinter { if let printer = pmPrinter {
PMTicketBridge.release(printer) PMTicketBridge.release(printer)
} }
} }
// ②–⑤ ColourSync suppression — only on the PM path: the SPI let (printInfo, boundViaPM) = try makePanelPrintInfo(
// and PMPrintSettingsSetValue need a session with a current for: request, boundTo: pmPrinter)
// printer to attach to.
var settings = PMTicketBridge.settings(printInfo) debugDump(printInfo, when: "pre-modal")
var driverBypass: (key: String, value: String)?
if boundViaPM {
let session = PMTicketBridge.session(printInfo)
suppressor.applySPIMode(to: session) // ②
suppressor.applyLockedKeys(to: settings) // ③
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
driverBypass = suppressor.applyDriverBypass( // ④
to: settings, optionKeys: optionKeys)
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
}
let panel = NSPrintPanel() let panel = NSPrintPanel()
panel.options = [ panel.options = [
@@ -173,27 +256,35 @@ struct PrintPanelService {
// (#183); the PDE may rewrite or drop them (R12). // (#183); the PDE may rewrite or drop them (R12).
var cupsOptions: String? var cupsOptions: String?
var mediaType: String? var mediaType: String?
var trayToken: String?
if boundViaPM { if boundViaPM {
settings = PMTicketBridge.settings(printInfo) debugDump(printInfo, when: "post-OK")
let captured = suppressor.captureOptions(from: settings) let captured = suppressor.captureOptions(
from: PMTicketBridge.settings(printInfo),
trayKey: request.initialSelections.trayKey)
cupsOptions = captured.cupsOptions cupsOptions = captured.cupsOptions
mediaType = captured.mediaType mediaType = captured.mediaType
trayToken = captured.tray
} }
let capturedOptions = cupsOptions ?? "" let capturedOptions = cupsOptions ?? ""
let resolvedQueue = boundViaPM let resolvedQueue = boundViaPM
? PMTicketBridge.currentPrinterID( ? PMTicketBridge.currentPrinterID(
session: PMTicketBridge.session(printInfo), session: PMTicketBridge.session(printInfo),
fallback: queue) fallback: request.queue)
: queue : request.queue
// ⑦ Serialise the native ticket — the payload `lp -o` could // ⑦ Serialise the native ticket — the payload `lp -o` could
// never carry (#201). Warn-only via `try?`: a serialise // never carry (#201). Warn-only via `try?`: a serialise
// failure must not lose the Stage 2 mirror above. // failure must not lose the Stage 2 mirror above.
let ticket = try? PMTicketBridge.serialise( let ticket = try? PMTicketBridge.serialise(
printInfo, queue: resolvedQueue) printInfo, queue: resolvedQueue)
let paperSource = request.trays.first(where: {
$0.token == trayToken
}).map(\.id)
return PanelCaptureResult( return PanelCaptureResult(
properties: PrintPropertiesResult( properties: PrintPropertiesResult(
selectedPrinter: boundViaPM ? resolvedQueue : nil, selectedPrinter: boundViaPM ? resolvedQueue : nil,
options: PrintOptions( options: PrintOptions(
paperSource: paperSource,
orientation: CupsParsers.extractOrientation( orientation: CupsParsers.extractOrientation(
fromOptionsString: capturedOptions), fromOptionsString: capturedOptions),
paperSize: CupsParsers.extractOption( paperSize: CupsParsers.extractOption(
@@ -207,10 +298,10 @@ struct PrintPanelService {
ticket: ticket) ticket: ticket)
} }
/// Initial-selection `PMPrintSettings` writes — paper, quality, /// Initial-selection `PMPrintSettings` writes — paper, tray,
/// media type, orientation. All warn-only: a driver that ignores /// quality, media type, orientation. All warn-only: a driver that
/// a key must not keep the panel from opening (R12 surfaces via /// ignores a key must not keep the panel from opening (R12 surfaces
/// the capture echo instead). /// via the capture echo instead).
private func applyInitialSelections( private func applyInitialSelections(
_ selections: PrintPanelInitialSelections, _ selections: PrintPanelInitialSelections,
to settings: PMPrintSettings, to settings: PMPrintSettings,
@@ -221,6 +312,13 @@ struct PrintPanelService {
paperSize, forKey: "PageSize", locked: false, paperSize, forKey: "PageSize", locked: false,
in: settings, context: "Print panel") in: settings, context: "Print panel")
} }
// Paper source via the queue's detected tray roster key (#218).
if let tray = selections.tray, !tray.isEmpty,
let trayKey = selections.trayKey {
PMTicketBridge.setValue(
tray, forKey: trayKey, locked: false,
in: settings, context: "Print panel")
}
if let key = selections.qualityKey, let value = selections.quality { if let key = selections.qualityKey, let value = selections.quality {
PMTicketBridge.setValue( PMTicketBridge.setValue(
value, forKey: key, locked: false, value, forKey: key, locked: false,
@@ -243,4 +341,35 @@ struct PrintPanelService {
} }
} }
/// `ICCERY_DEBUG_PANEL_DUMP=1` — log the unfiltered
/// `PMPrintSettingsToOptions` output and the serialised
/// settings/page-format byte counts (#217 diagnostics for the
/// Epson RC3 question: does the PDE source pane state from the
/// session ticket or lpoptions?). DEBUG-only, warn-only.
private func debugDump(_ printInfo: NSPrintInfo, when phase: String) {
#if DEBUG
guard ProcessInfo.processInfo.environment[
"ICCERY_DEBUG_PANEL_DUMP"] == "1" else { return }
let raw = suppressor.rawOptionsDump(
from: PMTicketBridge.settings(printInfo)) ?? "<unavailable>"
var settingsSize = -1
var ref: Unmanaged<CFData>?
if PMPrintSettingsCreateDataRepresentation(
PMTicketBridge.settings(printInfo), &ref,
kPMDataFormatXMLDefault) == noErr, let ref {
settingsSize = (ref.takeRetainedValue() as Data).count
}
var formatSize = -1
var formatRef: Unmanaged<CFData>?
if PMPageFormatCreateDataRepresentation(
PMTicketBridge.pageFormat(printInfo), &formatRef,
kPMDataFormatXMLDefault) == noErr, let formatRef {
formatSize = (formatRef.takeRetainedValue() as Data).count
}
AppLogger.shared.info(
"Print panel dump (\(phase)): settings=\(settingsSize)B "
+ "pageFormat=\(formatSize)B options: \(raw)")
#endif
}
} }
@@ -24,7 +24,15 @@ final class PrintSessionViewModel: ObservableObject {
} }
} }
@Published var printerCaps = PrinterCapabilities() @Published var printerCaps = PrinterCapabilities()
@Published var selectedTray: Int? @Published var selectedTray: Int? {
didSet {
// A tray switch can invalidate the current quality pick
// using the tray-specific Epson constraint map (#218).
if selectedTray != oldValue {
clampQualityToMedia()
}
}
}
@Published var selectedMediaType: String? { @Published var selectedMediaType: String? {
didSet { didSet {
// A media switch can invalidate the current quality pick — // A media switch can invalidate the current quality pick —
@@ -128,8 +136,11 @@ final class PrintSessionViewModel: ObservableObject {
if selectedMediaType == nil { if selectedMediaType == nil {
selectedMediaType = printerCaps.mediaTypes.first?.id selectedMediaType = printerCaps.mediaTypes.first?.id
} }
if selectedTray == nil { if selectedTray == nil || !printerCaps.trays
selectedTray = printerCaps.trays.first?.id .contains(where: { $0.id == selectedTray }) {
selectedTray = printerCaps.trayDefault.flatMap { token in
printerCaps.trays.first { $0.token == token }?.id
} ?? printerCaps.trays.first?.id
} }
// Quality seeds inside the selected media's allowed set // Quality seeds inside the selected media's allowed set
// (#214): driver default when valid there, else the first // (#214): driver default when valid there, else the first
@@ -152,17 +163,26 @@ final class PrintSessionViewModel: ObservableObject {
// MARK: - Paper / quality selection (#183) // MARK: - Paper / quality selection (#183)
/// The quality picker's source (#214): `printerCaps.qualities` /// The raw tray token for the currently selected source (#218).
/// filtered to what the driver accepts for `selectedMediaType`. var selectedTrayToken: String? {
/// Equals the full list whenever the queue exposes no per-media guard let id = selectedTray else { return nil }
/// quality matrix — and never empty while `qualities` is not. return printerCaps.trays.first { $0.id == id }?.token
}
/// The quality picker's source (#214/#218): `printerCaps.qualities`
/// filtered to what the driver accepts for `selectedMediaType` and
/// the current tray token. Equals the full list whenever the queue
/// exposes no per-media/per-tray quality matrix — and never empty
/// while `qualities` is not.
var availableQualities: [PrinterQuality] { var availableQualities: [PrinterQuality] {
printerCaps.qualities(forMediaType: selectedMediaType) printerCaps.qualities(
forMediaType: selectedMediaType,
trayToken: selectedTrayToken)
} }
/// Keep `selectedQuality` inside the allowed set for the current /// Keep `selectedQuality` inside the allowed set for the current
/// media: keep the pick when still valid, else the driver default /// media and tray: keep the pick when still valid, else the driver
/// when valid, else the first allowed choice (#214). /// default when valid, else the first allowed choice (#214/#218).
private func clampQualityToMedia() { private func clampQualityToMedia() {
let allowed = availableQualities let allowed = availableQualities
guard !allowed.isEmpty else { return } guard !allowed.isEmpty else { return }
@@ -178,6 +198,15 @@ final class PrintSessionViewModel: ObservableObject {
} }
} }
/// The tray token for a captured option string, used both for
/// applying panel results and for synthesising unknown selections.
private func capturedTrayToken(from options: String?) -> String? {
guard let options else { return nil }
return CupsParsers.extractTray(
fromOptionsString: options,
preferredKey: printerCaps.trayKey)
}
/// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`: /// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
/// a capability whose name matches `pageSize.rawValue` → its id; /// a capability whose name matches `pageSize.rawValue` → its id;
/// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`); /// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
@@ -227,17 +256,22 @@ final class PrintSessionViewModel: ObservableObject {
let cups = environment.cupsService let cups = environment.cupsService
let selections = PrintPanelInitialSelections( let selections = PrintPanelInitialSelections(
paperSize: selectedPaperSizeToken, paperSize: selectedPaperSizeToken,
trayKey: printerCaps.trayKey,
tray: selectedTrayToken,
qualityKey: printerCaps.qualityKey, qualityKey: printerCaps.qualityKey,
quality: selectedQuality, quality: selectedQuality,
mediaType: selectedMediaType, mediaType: selectedMediaType,
orientation: printOrientation) orientation: printOrientation)
let ticket = capturedTickets[queue]
Task { @MainActor in Task { @MainActor in
do { do {
guard let result = try await PrintPanelService() guard let result = try await PrintPanelService()
.showProperties( .showProperties(
queue: queue, displayName: displayName, queue: queue, displayName: displayName,
cupsService: cups, cupsService: cups,
initialSelections: selections) initialSelections: selections,
ticket: ticket,
trays: printerCaps.trays)
else { else {
printNotice = Notice( printNotice = Notice(
kind: .info, kind: .info,
@@ -262,6 +296,28 @@ final class PrintSessionViewModel: ObservableObject {
if let media = result.properties.options.mediaType { if let media = result.properties.options.mediaType {
selectedMediaType = media selectedMediaType = media
} }
// Capture-return paper source (#218): a dialog tray
// change updates Stage 2; unknown tokens are appended
// so a driver-only source remains selectable.
if let options = result.properties.options.cupsOptions,
let trayToken = capturedTrayToken(from: options),
!trayToken.isEmpty {
if let known = printerCaps.trays
.first(where: { $0.token == trayToken }) {
selectedTray = known.id
} else {
let nextID = (printerCaps.trays.map(\.id).max() ?? 0) + 1
let synthetic = PrinterTray(
id: nextID, token: trayToken, name: trayToken)
printerCaps.trays.append(synthetic)
selectedTray = nextID
}
} else if let paperSource = result.properties.options.paperSource,
printerCaps.trays.contains(where: {
$0.id == paperSource
}) {
selectedTray = paperSource
}
// Capture-return (#183/#186): a dialog paper/quality/ // Capture-return (#183/#186): a dialog paper/quality/
// orientation change updates the Stage 2 selections — // orientation change updates the Stage 2 selections —
// never `workflow.pageSize` (printtarg layout is // never `workflow.pageSize` (printtarg layout is
@@ -410,7 +466,9 @@ final class PrintSessionViewModel: ObservableObject {
// the last gate before the driver). // the last gate before the driver).
var quality = selectedQuality var quality = selectedQuality
if let q = quality, if let q = quality,
!printerCaps.allowsQuality(q, forMediaType: selectedMediaType) { !printerCaps.allowsQuality(
q, forMediaType: selectedMediaType,
trayToken: selectedTrayToken) {
quality = availableQualities.first?.id ?? q quality = availableQualities.first?.id ?? q
} }
return TargetPrintRequest( return TargetPrintRequest(
@@ -429,6 +487,8 @@ final class PrintSessionViewModel: ObservableObject {
mediaType: selectedMediaType, mediaType: selectedMediaType,
qualityKey: printerCaps.qualityKey, qualityKey: printerCaps.qualityKey,
quality: quality, quality: quality,
trayKey: printerCaps.trayKey,
tray: selectedTrayToken,
orientation: printOrientation), orientation: printOrientation),
optionKeys: optionKeys) optionKeys: optionKeys)
} }
+18 -8
View File
@@ -8,6 +8,8 @@ struct TargetPrintOverrides: Equatable {
var mediaType: String? var mediaType: String?
var qualityKey: String? // PrinterCapabilities.qualityKey var qualityKey: String? // PrinterCapabilities.qualityKey
var quality: String? var quality: String?
var trayKey: String? // PrinterCapabilities.trayKey
var tray: String? // raw tray token (#218)
var orientation: String? // "portrait" | "landscape" var orientation: String? // "portrait" | "landscape"
} }
@@ -42,10 +44,11 @@ enum TicketWriteResolver {
/// 5 `com.apple.print.PrintSettings.PMColorMatchingMode` /// 5 `com.apple.print.PrintSettings.PMColorMatchingMode`
/// = APCustomColorMatching (unlocked) /// = APCustomColorMatching (unlocked)
/// 6 `PageSize` = overrides.paperSize /// 6 `PageSize` = overrides.paperSize
/// 7 detectMediaTypeKey(optionKeys) = overrides.mediaType /// 7 overrides.trayKey = overrides.tray
/// 8 overrides.qualityKey = overrides.quality /// 8 detectMediaTypeKey(optionKeys) = overrides.mediaType
/// 9 detectDriverColorBypass(optionKeys) (unlocked) /// 9 overrides.qualityKey = overrides.quality
/// 10 `orientation-requested` = 3 | 4 /// 10 detectDriverColorBypass(optionKeys) (unlocked)
/// 11 `orientation-requested` = 3 | 4
/// ///
/// Invariants: `raw` can never appear (there is no `lp`); nothing /// Invariants: `raw` can never appear (there is no `lp`); nothing
/// is suppressed because a captured key already exists (D6 — the /// is suppressed because a captured key already exists (D6 — the
@@ -87,7 +90,14 @@ enum TicketWriteResolver {
key: "PageSize", value: paperSize, locked: false)) key: "PageSize", value: paperSize, locked: false))
} }
// 7 — media type via the queue's detected vendor key. // 7 — paper source via the queue's detected tray roster key.
if let trayKey = overrides.trayKey, !trayKey.isEmpty,
let tray = overrides.tray, !tray.isEmpty {
writes.append(TicketWrite(
key: trayKey, value: tray, locked: false))
}
// 8 — media type via the queue's detected vendor key.
if let mediaType = overrides.mediaType, if let mediaType = overrides.mediaType,
let mediaKey = CupsParsers.detectMediaTypeKey( let mediaKey = CupsParsers.detectMediaTypeKey(
optionKeys: optionKeys) { optionKeys: optionKeys) {
@@ -95,7 +105,7 @@ enum TicketWriteResolver {
key: mediaKey, value: mediaType, locked: false)) key: mediaKey, value: mediaType, locked: false))
} }
// 8 — print quality; the detected enumeration key travels in // 9 — print quality; the detected enumeration key travels in
// the overrides (`PrinterCapabilities.qualityKey`, #183). // the overrides (`PrinterCapabilities.qualityKey`, #183).
if let qualityKey = overrides.qualityKey, if let qualityKey = overrides.qualityKey,
let quality = overrides.quality { let quality = overrides.quality {
@@ -103,7 +113,7 @@ enum TicketWriteResolver {
key: qualityKey, value: quality, locked: false)) key: qualityKey, value: quality, locked: false))
} }
// 9 — driver "no colour adjustment" bypass. Never gated on // 10 — driver "no colour adjustment" bypass. Never gated on
// `ppdUncorrectedPassthrough` — macOS always bypasses. // `ppdUncorrectedPassthrough` — macOS always bypasses.
if let bypass = CupsParsers.detectDriverColorBypass( if let bypass = CupsParsers.detectDriverColorBypass(
optionKeys: optionKeys) { optionKeys: optionKeys) {
@@ -111,7 +121,7 @@ enum TicketWriteResolver {
key: bypass.key, value: bypass.value, locked: false)) key: bypass.key, value: bypass.value, locked: false))
} }
// 10 — orientation, CUPS IPP codes portrait=3 / landscape=4. // 11 — orientation, CUPS IPP codes portrait=3 / landscape=4.
if let orientation = overrides.orientation { if let orientation = overrides.orientation {
writes.append(TicketWrite( writes.append(TicketWrite(
key: "orientation-requested", key: "orientation-requested",
+6 -10
View File
@@ -297,7 +297,7 @@ struct Stage2View: View {
// ViewBuilder ≤10 children (R13). // ViewBuilder ≤10 children (R13).
HStack(spacing: 14) { HStack(spacing: 14) {
if !workflow.print.printerCaps.trays.isEmpty { if !workflow.print.printerCaps.trays.isEmpty {
Picker("Tray", selection: $workflow.print.selectedTray) { Picker("Paper source", selection: $workflow.print.selectedTray) {
ForEach(workflow.print.printerCaps.trays, id: \.id) { ForEach(workflow.print.printerCaps.trays, id: \.id) {
Text($0.name).tag(Optional($0.id)) Text($0.name).tag(Optional($0.id))
} }
@@ -322,16 +322,12 @@ struct Stage2View: View {
if !workflow.print.printerCaps.qualities.isEmpty { if !workflow.print.printerCaps.qualities.isEmpty {
qualityGroup qualityGroup
} }
HStack(spacing: 0) { Picker("Orientation", selection: $workflow.print.printOrientation) {
Button("Portrait") { workflow.print.printOrientation = "portrait" } Text("Portrait").tag("portrait")
.buttonStyle(.bordered) Text("Landscape").tag("landscape")
.tint(workflow.print.printOrientation == "portrait" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientPortrait")
Button("Landscape") { workflow.print.printOrientation = "landscape" }
.buttonStyle(.bordered)
.tint(workflow.print.printOrientation == "landscape" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientLandscape")
} }
.frame(maxWidth: 200)
.accessibilityIdentifier("printerOrientationSelect")
Spacer() Spacer()
} }
// Stage 1 owns the custom dimensions — the caption lives // Stage 1 owns the custom dimensions — the caption lives
@@ -54,6 +54,28 @@ final class CupsOptionsFilterTests: XCTestCase {
XCTAssertNil(CupsParsers.extractMediaType( XCTAssertNil(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4")) fromOptionsString: "PageSize=A4"))
} }
/// #218 — vendor paper-source keys survive the filter, and an
/// empty `AP_D_InputSlot=` is still dropped.
func testKeepsTrayKeys() {
let raw = "InputSlot=Rear MediaSource=Front EPIJ_FdSo=Rear "
+ "EPIJ_PSrc=Front CNIJMediaSupply=1 AP_D_InputSlot=Auto"
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
let emptyAlias = "AP_D_InputSlot= InputSlot=Rear"
XCTAssertEqual(CupsOptionsFilter.filter(emptyAlias), "InputSlot=Rear")
}
func testExtractTray() {
XCTAssertEqual(CupsParsers.extractTray(
fromOptionsString: "EPIJ_FdSo=Rear EPIJ_PSrc=Front",
preferredKey: "EPIJ_FdSo"), "Rear")
XCTAssertEqual(CupsParsers.extractTray(
fromOptionsString: "AP_D_InputSlot=Auto InputSlot=Rear",
preferredKey: "InputSlot"), "Rear")
XCTAssertNil(CupsParsers.extractTray(
fromOptionsString: "PageSize=A4"))
}
} }
/// Issue 14 — the dlsym attempt order and first-success semantics. /// Issue 14 — the dlsym attempt order and first-success semantics.
+82 -4
View File
@@ -83,11 +83,13 @@ final class CupsParserTests: XCTestCase {
let caps = service.capabilities(from: listings, ppd: nil) let caps = service.capabilities(from: listings, ppd: nil)
XCTAssertEqual(caps.trays, [ XCTAssertEqual(caps.trays, [
PrinterTray(id: 1, name: "Auto"), PrinterTray(id: 1, token: "Auto", name: "Auto"),
PrinterTray(id: 2, name: "Main"), PrinterTray(id: 2, token: "Main", name: "Main"),
PrinterTray(id: 3, name: "Photo"), PrinterTray(id: 3, token: "Photo", name: "Photo"),
PrinterTray(id: 4, name: "Rear"), PrinterTray(id: 4, token: "Rear", name: "Rear"),
]) ])
XCTAssertEqual(caps.trayKey, "InputSlot")
XCTAssertEqual(caps.trayDefault, "Main")
XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5")) XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5"))
XCTAssertEqual(caps.paperSizes.count, 10) XCTAssertEqual(caps.paperSizes.count, 10)
XCTAssertEqual(caps.mediaTypes.map(\.id), [ XCTAssertEqual(caps.mediaTypes.map(\.id), [
@@ -283,6 +285,82 @@ final class CupsParserTests: XCTestCase {
XCTAssertNil(pair(["PageSize"])) XCTAssertNil(pair(["PageSize"]))
} }
// MARK: - #218 paper-source roster
func testTrayKeyPrecedence() {
// Generic keys first; `EPIJ_FdSo` preferred over `EPIJ_PSrc`.
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"InputSlot", "EPIJ_FdSo", "EPIJ_PSrc"]), "InputSlot")
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"MediaSource", "EPIJ_FdSo", "EPIJ_PSrc"]), "MediaSource")
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"EPIJ_FdSo", "EPIJ_PSrc"]), "EPIJ_FdSo")
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"EPIJ_PSrc", "CNIJMediaSupply"]), "EPIJ_PSrc")
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"CNIJMediaSupply"]), "CNIJMediaSupply")
XCTAssertNil(CupsParsers.detectTrayKey(optionKeys: ["PageSize"]))
}
func testExtractTrayPreferredKeyAndRoster() {
let preferred = CupsParsers.extractTray(
fromOptionsString: "EPIJ_FdSo=Rear EPIJ_PSrc=Front",
preferredKey: "EPIJ_FdSo")
XCTAssertEqual(preferred, "Rear")
let fallback = CupsParsers.extractTray(
fromOptionsString: "EPIJ_PSrc=Front", preferredKey: "EPIJ_FdSo")
XCTAssertEqual(fallback, "Front")
let alias = CupsParsers.extractTray(
fromOptionsString: "AP_D_InputSlot=Auto")
XCTAssertEqual(alias, "Auto")
XCTAssertNil(CupsParsers.extractTray(
fromOptionsString: "PageSize=A4"))
XCTAssertNil(CupsParsers.extractTray(
fromOptionsString: "AP_D_InputSlot="))
}
func testTrayExtractionCaseInsensitive() {
XCTAssertEqual(CupsParsers.extractTray(
fromOptionsString: "epij_fdso=Rear", preferredKey: "EPIJ_FdSo"),
"Rear")
}
func testCapabilitiesTraysEpson() {
let service = CupsService()
let listings = CupsParsers.lpoptionsList("""
EPIJ_PSrc/Page Setup: *Front Rear Cassette
EPIJ_FdSo/Paper Source: Auto *Rear Front
""")
let caps = service.capabilities(from: listings, ppd: nil)
XCTAssertEqual(caps.trayKey, "EPIJ_FdSo")
XCTAssertEqual(caps.trayDefault, "Rear")
XCTAssertEqual(caps.trays.map(\.token), ["Auto", "Rear", "Front"])
XCTAssertEqual(caps.trays.map(\.name), ["Auto", "Rear", "Front"])
}
func testCapabilitiesTraysCanon() {
let ppd = """
*CNIJMediaSupply 1/Cassette (Upper): ""
*CNIJMediaSupply 2/Manual Feed: ""
*CNIJMediaSupply 4/Rear Tray: ""
"""
let service = CupsService()
let listings = CupsParsers.lpoptionsList("""
CNIJMediaSupply/Media Supply: *1 2 4
""")
let caps = service.capabilities(from: listings, ppd: ppd)
XCTAssertEqual(caps.trayKey, "CNIJMediaSupply")
XCTAssertEqual(caps.trays.map(\.token), ["1", "2", "4"])
XCTAssertEqual(caps.trays.map(\.name), [
"Cassette (Upper)", "Manual Feed", "Rear Tray"
])
}
// MARK: - #181 Canon media locale precedence + PPD encoding // MARK: - #181 Canon media locale precedence + PPD encoding
/// The 18 Canon Pro9500 media types named in the issue — the ids /// The 18 Canon Pro9500 media types named in the issue — the ids
@@ -194,6 +194,73 @@ final class MediaQualityConstraintsTests: XCTestCase {
XCTAssertNil(map["13"]) XCTAssertNil(map["13"])
} }
// MARK: - #218 tray-aware quality constraints
/// A conditional rule keyed by the selected tray/token must use the
/// supplied override rather than the queue's lpoptions default.
func testResolveWithTrayOverride() {
let dat = """
*EPIJUIConstraint: *EPIJ_FdSo 3 *EPIJ_Medi 0|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_FdSo 12 *EPIJ_Medi 0|*EPIJ_Qual 307
"""
// The queue default is EPIJ_FdSo=2 (from `epsonListings`), so
// the default map has no forbidden qualities for media "0".
let defaultMap = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: nil)
XCTAssertNil(defaultMap["0"])
// Override to tray "3" -> 305 forbidden; override to tray "12"
// -> 307 forbidden.
let ppd = """
*EPIJDriverBasePath: "/drivers/epson"
*EPIJMachineBundleName: "EP14C0605W.data"
"""
let datURL = URL(fileURLWithPath:
"/drivers/epson/Machine/EP14C0605W.data/"
+ "Contents/Resources/PDEData.dat")
let readFile: (URL) -> Data? = {
$0 == datURL ? dat.data(using: .utf8) : nil
}
let tray3 = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
optionOverrides: ["EPIJ_FdSo": "3"], readFile: readFile)
XCTAssertEqual(tray3["0"], epsonQualityIDs.subtracting(["305"]))
let tray12 = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
optionOverrides: ["EPIJ_FdSo": "12"], readFile: readFile)
XCTAssertEqual(tray12["0"], epsonQualityIDs.subtracting(["307"]))
}
func testResolveTrayOverrideCacheReadOnlyOnce() {
let dat = """
*EPIJUIConstraint: *EPIJ_FdSo 3 *EPIJ_Medi 0|*EPIJ_Qual 305
"""
let ppd = """
*EPIJDriverBasePath: "/drivers/epson"
*EPIJMachineBundleName: "EP14C0605W.data"
"""
let datURL = URL(fileURLWithPath:
"/drivers/epson/Machine/EP14C0605W.data/"
+ "Contents/Resources/PDEData.dat")
var reads = 0
var cache: [URL: Data] = [:]
let readFile: (URL) -> Data? = {
if let cached = cache[$0] { return cached }
reads += 1
let data = $0 == datURL ? dat.data(using: .utf8) : nil
cache[$0] = data
return data
}
_ = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
optionOverrides: ["EPIJ_FdSo": "3"], readFile: readFile)
_ = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
optionOverrides: ["EPIJ_FdSo": "12"], readFile: readFile)
XCTAssertEqual(reads, 1)
}
// MARK: - Canon cnb table // MARK: - Canon cnb table
/// Builds one 20-byte Canon record: `30 00 01 00 00 00 00 00`, /// Builds one 20-byte Canon record: `30 00 01 00 00 00 00 00`,
@@ -308,4 +375,38 @@ final class MediaQualityConstraintsTests: XCTestCase {
XCTAssertEqual(caps.qualities(forMediaType: "99").count, 7) XCTAssertEqual(caps.qualities(forMediaType: "99").count, 7)
XCTAssertTrue(caps.allowsQuality("305", forMediaType: nil)) XCTAssertTrue(caps.allowsQuality("305", forMediaType: nil))
} }
func testTraySpecificQualityMap() {
var caps = PrinterCapabilities(
mediaTypes: [
PrinterMediaType(id: "0", name: "Plain"),
],
qualities: ["301", "302", "303", "304", "305"]
.map { PrinterQuality(id: $0, name: "Q\($0)") },
qualityIDsByMediaType: ["0": ["301", "302", "303"]])
caps.qualityIDsByMediaTypeByTray = [
"Rear": ["0": ["301", "302", "303"]],
"Front": ["0": ["303", "304", "305"]],
]
XCTAssertEqual(
caps.qualities(forMediaType: "0", trayToken: "Rear")
.map { $0.id }, ["301", "302", "303"])
XCTAssertEqual(
caps.qualities(forMediaType: "0", trayToken: "Front")
.map { $0.id }, ["303", "304", "305"])
// Unknown tray falls back to the default map.
XCTAssertEqual(
caps.qualities(forMediaType: "0", trayToken: "Unknown")
.map { $0.id }, ["301", "302", "303"])
// No tray token also falls back.
XCTAssertEqual(
caps.qualities(forMediaType: "0").map { $0.id },
["301", "302", "303"])
XCTAssertTrue(caps.allowsQuality(
"304", forMediaType: "0", trayToken: "Front"))
XCTAssertFalse(caps.allowsQuality(
"304", forMediaType: "0", trayToken: "Rear"))
}
} }
+63 -1
View File
@@ -44,10 +44,16 @@ final class PrintPanelStubTests: XCTestCase {
"ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear", "ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear",
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue", "ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
]) { ]) {
let result = UITestHooks.printPanelResult(forQueue: "q") let trays = [
PrinterTray(id: 1, token: "Auto", name: "Auto"),
PrinterTray(id: 2, token: "Rear", name: "Rear"),
]
let result = UITestHooks.printPanelResult(
forQueue: "q", trays: trays, preferredTrayKey: "InputSlot")
XCTAssertEqual(result?.selectedPrinter, "Other_Queue") XCTAssertEqual(result?.selectedPrinter, "Other_Queue")
XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear") XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
XCTAssertEqual(result?.options.mediaType, "Photo") XCTAssertEqual(result?.options.mediaType, "Photo")
XCTAssertEqual(result?.options.paperSource, 2)
XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true) XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
} }
} }
@@ -99,4 +105,60 @@ final class PrintPanelStubTests: XCTestCase {
XCTAssertNil(result?.options.cupsOptions) XCTAssertNil(result?.options.cupsOptions)
} }
} }
/// #218 — vendor tray keys and the `AP_D_InputSlot` capture alias
/// map back through `paperSource` when the token matches the roster.
func testOkResultExtractsTrayToken() throws {
let trays = [
PrinterTray(id: 1, token: "Auto", name: "Auto"),
PrinterTray(id: 2, token: "Rear", name: "Rear Tray"),
PrinterTray(id: 3, token: "Manual", name: "Manual Feed"),
]
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS": "PageSize=A4 EPIJ_FdSo=Rear",
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(
forQueue: "q", trays: trays,
preferredTrayKey: "EPIJ_FdSo")
XCTAssertEqual(result?.options.paperSource, 2)
XCTAssertEqual(result?.options.cupsOptions,
"PageSize=A4 EPIJ_FdSo=Rear")
}
}
func testOkResultExtractsAPDInputSlotAlias() throws {
let trays = [
PrinterTray(id: 1, token: "Auto", name: "Auto"),
PrinterTray(id: 2, token: "Manual", name: "Manual Feed"),
]
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS": "AP_D_InputSlot=Manual",
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(
forQueue: "q", trays: trays)
XCTAssertEqual(result?.options.paperSource, 2)
}
}
func testOkResultUnknownTrayNoPaperSource() throws {
let trays = [
PrinterTray(id: 1, token: "Auto", name: "Auto"),
]
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS": "InputSlot=Unknown",
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(
forQueue: "q", trays: trays)
XCTAssertNil(result?.options.paperSource)
}
}
} }
@@ -0,0 +1,180 @@
import AppKit
import Foundation
import XCTest
@testable import ICCery
@testable import ICCeryCore
/// Issue #217 — the panel's pre-modal seam
/// (`PrintPanelService.configureBoundPanel`) must rehydrate the
/// captured `PrintTicket` before the Stage 2 writes, validate the
/// result, and refuse a foreign-queue ticket — all without driving
/// the modal or needing a real queue (`printer: nil` exercises
/// restore → selections → validate on a session-less `NSPrintInfo`).
@MainActor
final class PrintPanelTicketTests: XCTestCase {
private let service = PrintPanelService()
/// The queue a destination `NSPrintInfo` will report as bound —
/// the session's current printer, else a synthetic token for a
/// queue-less environment (same convention as PrintTicketTests).
private func boundQueue(of printInfo: NSPrintInfo) -> String {
PMTicketBridge.currentPrinterID(
session: PMTicketBridge.session(printInfo),
fallback: "UnboundQueue")
}
private func makeTicket(
queue: String,
key: String = "EPIJ_Qual",
value: String = "305"
) throws -> PrintTicket {
let source = NSPrintInfo()
XCTAssertTrue(
PMTicketBridge.setValue(
value, forKey: key, locked: false,
in: PMTicketBridge.settings(source),
context: "PrintPanelTicketTests"))
return try PMTicketBridge.serialise(source, queue: queue)
}
private func request(
ticket: PrintTicket?,
selections: PrintPanelInitialSelections =
PrintPanelInitialSelections(),
trays: [PrinterTray] = []
) -> PrintPanelRequest {
PrintPanelRequest(
queue: "Panel_Q", displayName: nil, optionKeys: [],
initialSelections: selections, ticket: ticket, trays: trays)
}
/// RC1 — a captured vendor key is present in the configured
/// printInfo before the modal would open.
func testTicketRestoredPreModal() throws {
let printInfo = NSPrintInfo()
let ticket = try makeTicket(queue: boundQueue(of: printInfo))
service.configureBoundPanel(
printInfo, request: request(ticket: ticket), printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"305")
}
/// D6 parity — Stage 2 selections win over the restored ticket.
func testStage2WinsOverTicket() throws {
let printInfo = NSPrintInfo()
let ticket = try makeTicket(queue: boundQueue(of: printInfo))
var selections = PrintPanelInitialSelections()
selections.qualityKey = "EPIJ_Qual"
selections.quality = "303"
service.configureBoundPanel(
printInfo,
request: request(ticket: ticket, selections: selections),
printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"303")
}
/// R3 — a ticket captured for another queue is refused; the
/// destination's settings stay untouched and nothing throws.
func testCrossQueueTicketRefusedAtPanel() throws {
let printInfo = NSPrintInfo()
let bound = boundQueue(of: printInfo)
let foreign = "DefinitelyNot_\(bound)"
try XCTSkipIf(
bound == foreign,
"destination resolved to the foreign queue")
XCTAssertTrue(
PMTicketBridge.setValue(
"999", forKey: "EPIJ_Qual", locked: false,
in: PMTicketBridge.settings(printInfo),
context: "PrintPanelTicketTests"))
let ticket = try makeTicket(queue: foreign)
service.configureBoundPanel(
printInfo, request: request(ticket: ticket), printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"999")
}
/// #218 — the Stage 2 tray selection lands after `PageSize` and
/// before the media key in the configured `NSPrintInfo`.
func testStage2TrayWrittenPreModal() {
let printInfo = NSPrintInfo()
var selections = PrintPanelInitialSelections()
selections.paperSize = "A4"
selections.trayKey = "EPIJ_FdSo"
selections.tray = "Rear"
selections.mediaType = "Photo"
var req = request(ticket: nil, selections: selections)
req = PrintPanelRequest(
queue: req.queue, displayName: req.displayName,
optionKeys: ["MediaType"], initialSelections: selections,
ticket: nil, trays: [])
service.configureBoundPanel(
printInfo, request: req, printer: nil)
let settings = PMTicketBridge.settings(printInfo)
XCTAssertEqual(
PMTicketBridge.stringValue(forKey: "PageSize", in: settings),
"A4")
XCTAssertEqual(
PMTicketBridge.stringValue(forKey: "EPIJ_FdSo", in: settings),
"Rear")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "MediaType", in: settings), "Photo")
}
/// First-ever open — no ticket, selections still land and the
/// post-selections validate keeps them readable.
func testNoTicketKeepsSelections() {
let printInfo = NSPrintInfo()
var selections = PrintPanelInitialSelections()
selections.paperSize = "A4"
selections.qualityKey = "EPIJ_Qual"
selections.quality = "303"
selections.orientation = "landscape"
service.configureBoundPanel(
printInfo,
request: request(ticket: nil, selections: selections),
printer: nil)
let settings = PMTicketBridge.settings(printInfo)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "PageSize", in: settings), "A4")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual", in: settings), "303")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "orientation-requested", in: settings), "4")
// The suppression writes still land on the ticket so the PDE
// sees the locked AP_* pair (③) and Quartz vocabulary (⑤′).
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "AP_ColorMatchingMode", in: settings),
"AP_ApplicationColorMatching")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "PMColorMatchingMode", in: settings),
"APCustomColorMatching")
}
}
@@ -50,7 +50,7 @@ final class PrintSessionViewModelTests: XCTestCase {
done done
if [ "$list" = "1" ]; then if [ "$list" = "1" ]; then
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal Custom.WIDTHxHEIGHT\\n' printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal Custom.WIDTHxHEIGHT\\n'
printf 'InputSlot/Media Source: Auto *Main Rear\\n' printf 'InputSlot/Media Source: Auto Main *Rear\\n'
printf 'MediaType/Media Type: *Stationery Glossy Matte\\n' printf 'MediaType/Media Type: *Stationery Glossy Matte\\n'
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\\n' printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\\n'
exit 0 exit 0
@@ -99,6 +99,10 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertEqual(workflow.pageSize, .a4) XCTAssertEqual(workflow.pageSize, .a4)
XCTAssertEqual(workflow.print.selectedPaperSize, 3) XCTAssertEqual(workflow.print.selectedPaperSize, 3)
// Tray seeds from the `*` default on caps load, not the first
// list element (#218).
XCTAssertEqual(workflow.print.selectedTray, 3)
XCTAssertEqual(workflow.print.selectedTrayToken, "Rear")
// Quality seeds from the `*` default on caps load. // Quality seeds from the `*` default on caps load.
XCTAssertEqual(workflow.print.selectedQuality, "303") XCTAssertEqual(workflow.print.selectedQuality, "303")
// All seven Epson codes enumerate in driver order (#180). // All seven Epson codes enumerate in driver order (#180).
@@ -209,6 +213,7 @@ final class PrintSessionViewModelTests: XCTestCase {
let log = await waitForFile(spoolLogURL) let log = await waitForFile(spoolLogURL)
XCTAssertTrue(log.contains("PageSize=Letter"), log) XCTAssertTrue(log.contains("PageSize=Letter"), log)
XCTAssertTrue(log.contains("EPIJ_Qual=301"), log) XCTAssertTrue(log.contains("EPIJ_Qual=301"), log)
XCTAssertTrue(log.contains("InputSlot=Rear"), log)
} }
/// #201 — the stubbed panel never produces a `PrintTicket`, so a /// #201 — the stubbed panel never produces a `PrintTicket`, so a
@@ -241,6 +246,7 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertTrue(log.contains("PageSize=A4"), log) XCTAssertTrue(log.contains("PageSize=A4"), log)
XCTAssertTrue(log.contains("EPIJ_Qual=303"), log) XCTAssertTrue(log.contains("EPIJ_Qual=303"), log)
XCTAssertTrue(log.contains("MediaType=Stationery"), log) XCTAssertTrue(log.contains("MediaType=Stationery"), log)
XCTAssertTrue(log.contains("InputSlot=Rear"), log)
XCTAssertTrue(log.contains("orientation-requested=3"), log) XCTAssertTrue(log.contains("orientation-requested=3"), log)
XCTAssertTrue(log.contains( XCTAssertTrue(log.contains(
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), log) "AP_ColorMatchingMode=AP_ApplicationColorMatching"), log)
@@ -307,6 +313,52 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertEqual(workflow.pageSize, .a4) XCTAssertEqual(workflow.pageSize, .a4)
} }
/// #218 — a captured `InputSlot=` (or any roster key) maps back to
/// `selectedTray` and populates `PrintOptions.paperSource`.
func testPanelResultAppliesBackTray() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "InputSlot=Auto", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
XCTAssertEqual(workflow.print.selectedTray, 3)
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.selectedTray, 1)
}
/// #218 — a captured tray token absent from the roster is appended
/// as a synthetic entry and selected.
func testPanelResultUnknownTrayAppended() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "InputSlot=OnlyInDialog", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
let originalCount = workflow.print.printerCaps.trays.count
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.printerCaps.trays.count,
originalCount + 1)
XCTAssertEqual(workflow.print.selectedTrayToken, "OnlyInDialog")
}
/// #186 — a captured `PageSize` token with no capability match /// #186 — a captured `PageSize` token with no capability match
/// leaves `selectedPaperSize` unchanged (never a guessed id). /// leaves `selectedPaperSize` unchanged (never a guessed id).
func testPanelResultUnknownPaperLeavesSelection() async throws { func testPanelResultUnknownPaperLeavesSelection() async throws {
@@ -456,6 +508,35 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertEqual(workflow.print.selectedQuality, "303") XCTAssertEqual(workflow.print.selectedQuality, "303")
} }
/// #218 — a per-tray quality map is selected when the current tray
/// token has an entry; otherwise it falls back to the default map.
func testTraySpecificQualityMapSelected() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
var caps = workflow.print.printerCaps
caps.qualityIDsByMediaType = [
"Stationery": ["301", "302", "303"],
"Glossy": ["305", "307"],
]
caps.qualityIDsByMediaTypeByTray = [
"Auto": ["Stationery": ["301", "302"]],
"Rear": ["Stationery": ["301", "302", "303", "304"]],
]
workflow.print.printerCaps = caps
XCTAssertEqual(workflow.print.selectedTrayToken, "Rear")
// Non-empty tray map for Rear wins over the default media map.
XCTAssertEqual(workflow.print.availableQualities.map { $0.id },
["301", "302", "303", "304"])
workflow.print.selectedTray = 1 // Auto
XCTAssertEqual(workflow.print.availableQualities.map { $0.id },
["301", "302"])
workflow.print.selectedTray = 999 // invalid -> fallback
XCTAssertEqual(workflow.print.availableQualities.map { $0.id },
["301", "302", "303"])
}
/// Seeding lands inside the allowed set even when the driver /// Seeding lands inside the allowed set even when the driver
/// default quality is invalid for the first media — end to end /// default quality is invalid for the first media — end to end
/// through `CupsService.capabilities(for:)` + a fixture /// through `CupsService.capabilities(for:)` + a fixture
@@ -2,9 +2,9 @@ import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Issue #201 Phase 4 — the resolved ticket-write list that replaces /// Issue #201 Phase 4 / #218 — the resolved ticket-write list that
/// the `lp` argv goldens: locked order 1–10, D6 (Stage 2 always /// replaces the `lp` argv goldens: locked order 1–11, D6 (Stage 2
/// wins), `raw` can never appear. /// always wins), `raw` can never appear.
final class TicketWriteResolverTests: XCTestCase { final class TicketWriteResolverTests: XCTestCase {
private func resolve( private func resolve(
@@ -63,6 +63,7 @@ final class TicketWriteResolverTests: XCTestCase {
overrides: TargetPrintOverrides( overrides: TargetPrintOverrides(
paperSize: "raw", mediaType: "raw", paperSize: "raw", mediaType: "raw",
qualityKey: "raw", quality: "raw", qualityKey: "raw", quality: "raw",
trayKey: "raw", tray: "raw",
orientation: "raw"), orientation: "raw"),
optionKeys: optionKeys) optionKeys: optionKeys)
XCTAssertFalse(r.writes.contains { XCTAssertFalse(r.writes.contains {
@@ -144,8 +145,8 @@ final class TicketWriteResolverTests: XCTestCase {
XCTAssertNil(value("orientation-requested", in: resolve())) XCTAssertNil(value("orientation-requested", in: resolve()))
} }
/// A `nil` override emits no write for that key; quality needs /// A `nil` override emits no write for that key; quality and tray
/// **both** the detected key and the value. /// need **both** the detected key and the value.
func testNilOverridesEmitNoWrite() { func testNilOverridesEmitNoWrite() {
let r = resolve(optionKeys: ["MediaType", "EPIJ_Qual"]) let r = resolve(optionKeys: ["MediaType", "EPIJ_Qual"])
XCTAssertNil(value("PageSize", in: r)) XCTAssertNil(value("PageSize", in: r))
@@ -168,14 +169,42 @@ final class TicketWriteResolverTests: XCTestCase {
overrides: TargetPrintOverrides(paperSize: "")) overrides: TargetPrintOverrides(paperSize: ""))
XCTAssertNil(value("PageSize", in: emptyPaper)) XCTAssertNil(value("PageSize", in: emptyPaper))
XCTAssertNil(emptyPaper.paperToken) XCTAssertNil(emptyPaper.paperToken)
// Tray: key or value alone, or an empty value, emits no write.
let trayKeyOnly = resolve(
overrides: TargetPrintOverrides(trayKey: "EPIJ_FdSo"),
optionKeys: ["EPIJ_FdSo"])
XCTAssertNil(value("EPIJ_FdSo", in: trayKeyOnly))
let trayValueOnly = resolve(
overrides: TargetPrintOverrides(tray: "Rear"),
optionKeys: ["EPIJ_FdSo"])
XCTAssertNil(value("EPIJ_FdSo", in: trayValueOnly))
let emptyTray = resolve(
overrides: TargetPrintOverrides(trayKey: "EPIJ_FdSo", tray: ""),
optionKeys: ["EPIJ_FdSo"])
XCTAssertNil(value("EPIJ_FdSo", in: emptyTray))
} }
/// The write list equals the locked 1–10 order, exactly. /// #218 paper source is emitted with its canonical key and the raw
/// token; the display label must never reach the ticket.
func testTrayWrite() {
let r = resolve(
overrides: TargetPrintOverrides(
trayKey: "EPIJ_FdSo", tray: "Rear"),
optionKeys: ["EPIJ_FdSo"])
XCTAssertEqual(value("EPIJ_FdSo", in: r), "Rear")
XCTAssertEqual(r.writes.first { $0.key == "EPIJ_FdSo" }?.locked,
false)
XCTAssertNil(value("InputSlot", in: r))
}
/// The write list equals the locked 1–11 order, exactly.
func testLockedWriteOrder() { func testLockedWriteOrder() {
let r = resolve( let r = resolve(
overrides: TargetPrintOverrides( overrides: TargetPrintOverrides(
paperSize: "A4", mediaType: "Photo", paperSize: "A4", mediaType: "Photo",
qualityKey: "EPIJ_Qual", quality: "305", qualityKey: "EPIJ_Qual", quality: "305",
trayKey: "EPIJ_FdSo", tray: "Rear",
orientation: "landscape"), orientation: "landscape"),
optionKeys: ["MediaType", "EPIJ_Qual", "EPIJ_CMat"]) optionKeys: ["MediaType", "EPIJ_Qual", "EPIJ_CMat"])
XCTAssertEqual(keys(r), [ XCTAssertEqual(keys(r), [
@@ -185,6 +214,7 @@ final class TicketWriteResolverTests: XCTestCase {
"PMCustomColorMatchingProfile", "PMCustomColorMatchingProfile",
"com.apple.print.PrintSettings.PMColorMatchingMode", "com.apple.print.PrintSettings.PMColorMatchingMode",
"PageSize", "PageSize",
"EPIJ_FdSo",
"MediaType", "MediaType",
"EPIJ_Qual", "EPIJ_Qual",
"EPIJ_CMat", "EPIJ_CMat",
+1 -1
View File
@@ -15,7 +15,7 @@ for arg in "$@"; do
done done
if [ "$list" = "1" ]; then if [ "$list" = "1" ]; then
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n' printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n'
printf 'InputSlot/Media Source: Auto *Main Rear\n' printf 'EPIJ_FdSo/Paper Source: Auto *Main Rear\n'
printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n' printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n'
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n' printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n'
printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n' printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n'
@@ -124,8 +124,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
XCTAssertTrue(element("printerSelect").exists) XCTAssertTrue(element("printerSelect").exists)
XCTAssertTrue(element("printerTraySelect").exists) XCTAssertTrue(element("printerTraySelect").exists)
XCTAssertTrue(element("printerMediaTypeSelect").exists) XCTAssertTrue(element("printerMediaTypeSelect").exists)
XCTAssertTrue(element("btnOrientPortrait").exists) XCTAssertTrue(element("printerOrientationSelect").exists)
XCTAssertTrue(element("btnOrientLandscape").exists)
XCTAssertTrue(element("btnPrinterProperties").exists) XCTAssertTrue(element("btnPrinterProperties").exists)
} }
+3 -3
View File
@@ -172,8 +172,7 @@ final class Milestone3UITests: XCTestCase {
.waitForExistence(timeout: 10)) .waitForExistence(timeout: 10))
XCTAssertTrue(element("printerTraySelect").exists) XCTAssertTrue(element("printerTraySelect").exists)
XCTAssertTrue(element("printerMediaTypeSelect").exists) XCTAssertTrue(element("printerMediaTypeSelect").exists)
XCTAssertTrue(element("btnOrientPortrait").exists) XCTAssertTrue(element("printerOrientationSelect").exists)
XCTAssertTrue(element("btnOrientLandscape").exists)
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled) XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
} }
@@ -202,7 +201,7 @@ final class Milestone3UITests: XCTestCase {
func testPanelSelectionsApplyBackToSpoolWrites() throws { func testPanelSelectionsApplyBackToSpoolWrites() throws {
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
"InputSlot=Rear MediaType=PhotographicGlossy" "EPIJ_FdSo=Rear MediaType=PhotographicGlossy"
launchApp() launchApp()
reachPrintPanel() reachPrintPanel()
_ = waitFor("printerStatusBadge") _ = waitFor("printerStatusBadge")
@@ -220,6 +219,7 @@ final class Milestone3UITests: XCTestCase {
XCTAssertTrue(log.contains( XCTAssertTrue(log.contains(
"AP.ColorMatchingMode=AP_ApplicationColorMatching"), log) "AP.ColorMatchingMode=AP_ApplicationColorMatching"), log)
XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log) XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log)
XCTAssertTrue(log.contains("EPIJ_FdSo=Rear"), log)
// Detected bypass for the mock queue (EPIJ_CMat present in // Detected bypass for the mock queue (EPIJ_CMat present in
// lpoptions -l) is always written. // lpoptions -l) is always written.
XCTAssertTrue(log.contains("EPIJ_CMat=3"), log) XCTAssertTrue(log.contains("EPIJ_CMat=3"), log)
+4 -3
View File
@@ -43,8 +43,9 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor
| Field | Type | Meaning | | Field | Type | Meaning |
|-------|------|---------| |-------|------|---------|
| `id` | `u16` | Windows: Win32 bin ID from `DC_BINS`. Unix: 1-based index of the `InputSlot` / `MediaSource` value (not a PPD code). | | `id` | `u16` | Windows: Win32 bin ID from `DC_BINS`. Unix/macOS: 1-based index of the detected tray listing. |
| `name` | `String` | Windows: 24-WCHAR `DC_BINNAMES` string, or `"Tray {id}"` if empty. Unix: PPD choice with leading `*` stripped (e.g. `Auto`, `Upper`). | | `token` | `String` | **v2** (#218): the exact CUPS/PPD machine value (`Auto`, `Rear`, `1`, ...). |
| `name` | `String` | Windows: 24-WCHAR `DC_BINNAMES` string, or `"Tray {id}"` if empty. Unix/macOS: PPD-enriched human label; if no PPD label is available, the raw token is used for display. |
### `PrinterPaperSize` (mod.rs:22–25) ### `PrinterPaperSize` (mod.rs:22–25)
@@ -78,7 +79,7 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor
| Field | Type | Windows | Linux | macOS | | Field | Type | Windows | Linux | macOS |
|-------|------|---------|-------|-------| |-------|------|---------|-------|-------|
| `paper_source` | `Option<u16>` | `dmDefaultSource` (tray id) | **ignored** (unix `build_lp_args` never emits `InputSlot`) | **ignored** unless present in captured `cups_options` | | `paper_source` | `Option<u16>` | `dmDefaultSource` (tray id) | **ignored** (unix `build_lp_args` never emits `InputSlot`) | v2 (#218): 1-based tray id driven by the Stage 2 picker; written to the detected tray key (`InputSlot`/`MediaSource`/`EPIJ_FdSo`/`EPIJ_PSrc`/`CNIJMediaSupply`) on the native ticket. |
| `orientation` | `Option<String>` | `"landscape"` → `DMORIENT_LANDSCAPE` (2), else portrait (1) | `-o orientation-requested=4` (landscape) or `=3` (portrait) | same, skipped if `cups_options` already has the key | | `orientation` | `Option<String>` | `"landscape"` → `DMORIENT_LANDSCAPE` (2), else portrait (1) | `-o orientation-requested=4` (landscape) or `=3` (portrait) | same, skipped if `cups_options` already has the key |
| `paper_size` | `Option<String>` | **ignored** | `-o PageSize=<value>` | same, skipped if `pagesize` already added | | `paper_size` | `Option<String>` | **ignored** | `-o PageSize=<value>` | same, skipped if `pagesize` already added |
| `media_type` | `Option<String>` | parsed as `u32` → `dmMediaType` | `-o MediaType=<value>` (generic key only) | uses `detect_media_type_key` (`CNIJMediaType` / `EPIJ_Medi` / `StpMediaType` / `MediaType`); skipped if any of those keys already in `cups_options` | | `media_type` | `Option<String>` | parsed as `u32` → `dmMediaType` | `-o MediaType=<value>` (generic key only) | uses `detect_media_type_key` (`CNIJMediaType` / `EPIJ_Medi` / `StpMediaType` / `MediaType`); skipped if any of those keys already in `cups_options` |
+9 -6
View File
@@ -236,7 +236,8 @@ After OK:
4. Copy via `CStr`, `free(opts_ptr)`. 4. Copy via `CStr`, `free(opts_ptr)`.
5. `filter_cups_options_string` → `cups_options`. 5. `filter_cups_options_string` → `cups_options`.
6. `extract_media_type_from_options` → `media_type`. 6. `extract_media_type_from_options` → `media_type`.
7. Return `PrintPropertiesResult { selected_printer, options: PrintOptions { media_type, cups_options, ppd_uncorrected_passthrough: Some(true), ..Default } }`. 7. `extract_tray_from_options` → the selected tray token and a 1-based `paper_source` id when it matches the roster.
8. Return `PrintPropertiesResult { selected_printer, options: PrintOptions { media_type, cups_options, paper_source, ppd_uncorrected_passthrough: Some(true), ..Default } }`.
Failure of `PMPrintSettingsToOptions` is a hard `Err`. Failure of `PMPrintSettingsToOptions` is a hard `Err`.
@@ -264,7 +265,7 @@ Captured from the panel into the Stage 2 mirror (v1 forwarded them to `lp`):
``` ```
Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType
Tray: InputSlot, AP_D_InputSlot Tray: InputSlot, MediaSource, AP_D_InputSlot, EPIJ_FdSo, EPIJ_PSrc, CNIJMediaSupply
Size: PageSize Size: PageSize
Color: CNIJIntent2, CNIJIntent, EPIJ_CMat, EPIJ_CCor, EPIJ_OSColMat, Color: CNIJIntent2, CNIJIntent, EPIJ_CMat, EPIJ_CCor, EPIJ_OSColMat,
ColorCorrection, StpColorCorrection, EpsonColorMode, ColorModel ColorCorrection, StpColorCorrection, EpsonColorMode, ColorModel
@@ -306,8 +307,9 @@ S14 PMRelease the printer on every path (defer)
`TicketWriteResolver` produces the resolved write list as a pure value, in a `TicketWriteResolver` produces the resolved write list as a pure value, in a
locked order: both `AP_*` keys (locked) → the three Quartz keys (⑤′) → locked order: both `AP_*` keys (locked) → the three Quartz keys (⑤′) →
`PageSize` → the detected media key → the detected quality key → the driver `PageSize` → the detected tray key → the detected media key → the detected
colour bypass → `orientation-requested`. Stage 2 overrides **always win** 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); over the rehydrated ticket (D6 — the v1 captured-wins inversion is gone);
`raw` can never appear because there is no `lp`. Panels stay off `raw` can never appear because there is no `lp`. Panels stay off
(`showsPrintPanel` / `showsProgressPanel` false, `canSpawnSeparateThread` (`showsPrintPanel` / `showsProgressPanel` false, `canSpawnSeparateThread`
@@ -407,8 +409,9 @@ AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPan
### PPD keys (detection) ### PPD keys (detection)
``` ```
Media: CNIJMediaType, EPIJ_Medi, StpMediaType, MediaType, MediaSource, InputSlot, PageSize, MediaSize Media: CNIJMediaType, EPIJ_Medi, StpMediaType, MediaType, PageSize, MediaSize
Color: CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, EpsonColorMode Tray: InputSlot, MediaSource, EPIJ_FdSo, EPIJ_PSrc, CNIJMediaSupply (EPIJ_FdSo preferred over EPIJ_PSrc when both present)
Color: CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, EpsonColorMode
``` ```
--- ---
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -130,7 +130,7 @@ Grouped by subsystem. Issue numbers are always cited.
**Sister-product traps (ICCery-CPU `#1`, `#2`, `#5`) that also apply to any CUPS rewrite:** **Sister-product traps (ICCery-CPU `#1`, `#2`, `#5`) that also apply to any CUPS rewrite:**
- PPD `ChoiceName/TranslationString` — UI must show the title, send the code (`MediaType=13` not `MediaType=Epson Premium Glossy`). - PPD `ChoiceName/TranslationString` — UI must show the title, send the code (`MediaType=13` not `MediaType=Epson Premium Glossy`).
- Tray keywords are vendor-specific (`InputSlot` vs Epson `EPIJ_FdSo` vs Canon `CNIJMediaSupply`). Empty arrays are not `nil` — coalescing `?? ["Auto"]` does not fire. - Tray keywords are vendor-specific (`InputSlot`/`MediaSource` vs Epson `EPIJ_FdSo`/`EPIJ_PSrc` vs Canon `CNIJMediaSupply`). When both Epson keys are present, `EPIJ_FdSo` is the feed-source control and takes precedence over `EPIJ_PSrc`. Empty arrays are not `nil` — coalescing `?? ["Auto"]` does not fire.
- Canon Super Fine is three coordinated keys (`CNIJPrintQuality=0`, `CNIJPrintMode2=5`, `CNIJPQualitySlider=5`), not `cupsPrintQuality=High`. - Canon Super Fine is three coordinated keys (`CNIJPrintQuality=0`, `CNIJPrintMode2=5`, `CNIJPQualitySlider=5`), not `cupsPrintQuality=High`.
--- ---
+1 -1
View File
@@ -61,7 +61,7 @@ targets:
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0" INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
INFOPLIST_KEY_NSPrincipalClass: NSApplication INFOPLIST_KEY_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3." INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.3" MARKETING_VERSION: "2.0.4"
CURRENT_PROJECT_VERSION: "1" CURRENT_PROJECT_VERSION: "1"
# Stamped by scripts/version.sh at packaging/CI time (release tag or # Stamped by scripts/version.sh at packaging/CI time (release tag or
# `git describe` string); About shows it next to the marketing version. # `git describe` string); About shows it next to the marketing version.