Xerox and Konica Minolta driver profiles; optional second quick action value

Xerox: XROutputColor with XRColorCorrection fallback for color/black & white; user code
sets XRAccountingSystem=XSA + AcctUserID, and an optional account ID (AcctAccountID)
via a profile-declared second input, in the sheet and as
'cupsadmin quick usercode <queue> <user> [account]'. Konica Minolta: KMDuplex,
PageSize=8.5x11, color from the generic ColorModel; user code unavailable with the
reason. Offline tests sweep every Xerox and bizhub PPD found in CUPSKIT_PPD_DIRS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kfattic
2026-09-16 21:15:14 -05:00
parent 795ab7a981
commit 37a64af25a
10 changed files with 269 additions and 28 deletions

View File

@@ -40,7 +40,8 @@ Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored).
PPD's `*Manufacturer`/`*NickName`, vendor profiles first and `generic` (standard PPD keywords, IPP
Everywhere `*-default` attributes) last. A pair is used only if that queue supports the keyword and
value; otherwise the action is unavailable with the reason. A profile's `unavailable` map gives
the reason an action can't be done with that driver (e.g. Canon department IDs). Use Letter Paper is
the reason an action can't be done with that driver (e.g. Canon department IDs); its `inputs` map relabels
the typed value and can add an optional second value (`{value2}`, e.g. the Xerox account ID). Use Letter Paper is
titled ", Fit to Nearest Size" only when the profile also sets a fit option. `cupsadmin ppdreport
<queue | file.ppd[.gz]>` shows a driver's keywords and which actions its profile enables.

View File

@@ -48,6 +48,7 @@ The things admins actually do, without knowing the driver's option keywords:
cupsadmin quick color <queue> # always print in color
cupsadmin quick bw <queue> # always print black & white
cupsadmin quick usercode <queue> 12345 # set a Ricoh user code (enables it too)
cupsadmin quick usercode <queue> jdoe ART101 # Xerox Standard Accounting: user ID, optional account ID
cupsadmin quick duplex <queue> # default to two-sided
cupsadmin quick simplex <queue>
cupsadmin quick letter <queue> # Letter paper; also fit to nearest size where the driver can (Ricoh)
@@ -67,13 +68,15 @@ Which keywords an action writes comes from a driver profile (`Sources/CupsKit/Re
| Any other PPD, IPP Everywhere / AirPrint | ✓ color, black & white, duplex, Letter where the queue supports them (generic) |
| Canon UFR II / LIPSLX / CARPS2 | ✓ color, black & white (color models), duplex, Letter (no fit-to-page option); department IDs are set in the Canon driver, not the PPD |
| HP (`HPColorAsGray` or `HPColorMode` drivers) | ✓ color, black & white, duplex, Letter (no fit-to-page option); no accounting code option |
| Xerox, Konica Minolta and others | generic only — add a profile (below) |
| Xerox (AltaLink, VersaLink and others) | ✓ color, black & white, duplex, Letter (no fit-to-page option), user ID + optional account ID (Xerox Standard Accounting) |
| Konica Minolta bizhub C-series i / xi | ✓ color, black & white, duplex, Letter (no fit-to-page option); Account Track codes are set in the driver, not the PPD |
| Others | generic only — add a profile (below) |
### Adding your printer's driver
1. `cupsadmin ppdreport <queue>` (or `ppdreport <file.ppd.gz>` for a PPD from a driver package you haven't installed — `pkgutil --expand-full` it first) lists every option your driver has; find the color, duplex, paper and accounting/user-code keywords and their choice values.
2. Copy the `ricoh` entry in `Sources/CupsKit/Resources/driver-profiles.json`, give it an `id`, and set `match` to regular expressions for your PPD's `Manufacturer` and `NickName` (both are printed at the top of the report).
3. Replace the `keyword=value` pairs. `{value}` is the code the user types; typed options take `Custom.{value}`. Leave out actions your driver can't do, or explain why under `unavailable`. Keep your profile above `generic`.
3. Replace the `keyword=value` pairs. `{value}` is the code the user types; typed options take `Custom.{value}`. Leave out actions your driver can't do, or explain why under `unavailable`. `inputs` relabels the typed value and can add an optional second one (see the `xerox` entry). Keep your profile above `generic`.
4. `swift build && .build/debug/cupsadmin ppdreport <queue>` shows which quick actions are now available; try one with `cupsadmin quick`, then open a pull request.
## How it works

View File

@@ -14,6 +14,8 @@ final class QuickActionModel: Identifiable {
let action: QuickAction
let queue: String
var code = ""
/// Optional second value (Xerox account ID), when the driver profile has one.
var secondValue = ""
var showCurrentCode = false
private(set) var phase: Phase = .loading
private(set) var state: OptionState?
@@ -50,9 +52,15 @@ final class QuickActionModel: Identifiable {
var validationError: String? { action.validationError(code, context: context) }
var inputLabel: String { action.inputLabel(context) }
var secondInputLabel: String? { action.secondInputLabel(context) }
var currentSecondValue: String? { action.currentSecondValue(context) }
var secondValidationError: String? { action.secondValidationError(secondValue, context: context) }
/// What will be written, or why the action can't run here.
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
action.resolve(context, value: clearing ? nil : code, clearing: clearing).map(\.changes)
action.resolve(context, value: clearing ? nil : code, secondValue: clearing ? nil : secondValue,
clearing: clearing).map(\.changes)
}
/// "Ricoh", "Generic (…)" — which driver profile supplied the keywords.
@@ -83,7 +91,9 @@ final class QuickActionModel: Identifiable {
phase = .running
let started = Date()
do {
let outcome = try await action.run(queue: queue, value: action.input == .userCode && !clearing ? code : nil,
let typed = action.input == .userCode && !clearing
let outcome = try await action.run(queue: queue, value: typed ? code : nil,
secondValue: typed && !secondValue.isEmpty ? secondValue : nil,
clearing: clearing, client: store.client)
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
state = try? await OptionState.load(client: store.client, queue: queue)
@@ -161,7 +171,7 @@ struct QuickActionSheet: View {
private var canApply: Bool {
guard model.phase == .ready else { return false }
if model.action.input == .userCode {
return !model.code.isEmpty && model.validationError == nil
return !model.code.isEmpty && model.validationError == nil && model.secondValidationError == nil
}
if case .failure = model.plannedChanges() { return false }
return true
@@ -170,7 +180,7 @@ struct QuickActionSheet: View {
@ViewBuilder private var content: some View {
if model.action.input == .userCode {
Form {
LabeledContent("Current code") {
LabeledContent("Current \(model.inputLabel.lowercased())") {
HStack(spacing: 8) {
if let current = model.currentCode {
Text(model.showCurrentCode ? current : String(repeating: "•", count: current.count))
@@ -184,22 +194,39 @@ struct QuickActionSheet: View {
}
}
LabeledContent {
TextField("User code", text: $model.code, prompt: Text("Digits"))
TextField(model.inputLabel, text: $model.code,
prompt: model.action.digitsOnly(model.context) ? Text("Digits") : nil)
.labelsHidden()
.textFieldStyle(.roundedBorder)
.monospacedDigit()
.frame(width: 160)
.disabled(model.phase != .ready)
} label: {
Text("New code")
Text("New \(model.inputLabel.lowercased())")
if let error = model.validationError {
Text(error).foregroundStyle(.red)
}
}
if let secondLabel = model.secondInputLabel {
LabeledContent {
TextField(secondLabel, text: $model.secondValue, prompt: model.currentSecondValue.map { Text($0) })
.labelsHidden()
.textFieldStyle(.roundedBorder)
.frame(width: 160)
.disabled(model.phase != .ready)
} label: {
Text(secondLabel)
if let error = model.secondValidationError {
Text(error).foregroundStyle(.red)
} else if model.currentSecondValue != nil {
Text("Current value shown in the field").foregroundStyle(.secondary)
}
}
}
}
.formStyle(.grouped)
.scrollDisabled(true)
.frame(height: 130)
.frame(height: model.secondInputLabel == nil ? 130 : 190)
}
if model.action.isDefaultPrinter {

View File

@@ -54,8 +54,25 @@ public struct DriverProfile: Decodable, Identifiable {
public let actions: [String: [[String]]]
/// Action id -> why this driver can't do it (e.g. accounting codes set outside the PPD).
public let unavailable: [String: String]
/// Action id -> how its typed value is labeled and checked, plus an optional second value.
public let inputs: [String: Input]
enum CodingKeys: String, CodingKey { case id, name, match, actions, unavailable }
public struct Input: Decodable {
/// "User ID"; nil keeps the default "User code".
public let label: String?
/// Default true (Ricoh user codes). Xerox user IDs are strings.
public let digitsOnly: Bool?
public let second: SecondInput?
}
/// An optional extra value, e.g. Xerox Standard Accounting's account ID. Its pairs use `{value2}`
/// and are added only when the user fills it in.
public struct SecondInput: Decodable {
public let label: String
public let pairs: [String]
}
enum CodingKeys: String, CodingKey { case id, name, match, actions, unavailable, inputs }
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
@@ -68,6 +85,7 @@ public struct DriverProfile: Decodable, Identifiable {
for (action, pairs) in raw { actions[action] = pairs.alternatives }
self.actions = actions
unavailable = try c.decodeIfPresent([String: String].self, forKey: .unavailable) ?? [:]
inputs = try c.decodeIfPresent([String: Input].self, forKey: .inputs) ?? [:]
}
private struct FlexiblePairs: Decodable {

View File

@@ -25,7 +25,7 @@ public struct QuickAction: Identifiable, Hashable {
QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none,
summary: "Black & white by default"),
QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode,
summary: "User/department code sent with every job (--clear to remove)"),
summary: "User/department code sent with every job; Xerox also takes an optional account ID (--clear to remove)"),
QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none,
summary: "Two-sided, long edge"),
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
@@ -51,7 +51,7 @@ public struct QuickAction: Identifiable, Hashable {
static let paperSizeKeys: Set<String> = ["PageSize", "media-default"]
/// The profile and option changes this action would write on a queue, or why it can't.
public func resolve(_ context: DriverContext, value: String? = nil, clearing: Bool = false,
public func resolve(_ context: DriverContext, value: String? = nil, secondValue: String? = nil, clearing: Bool = false,
profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result<ResolvedQuickAction, QuickActionUnavailable> {
let key = clearing ? id + "-clear" : id
var firstMissing: String?
@@ -67,7 +67,19 @@ public struct QuickAction: Identifiable, Hashable {
if firstMissing == nil { firstMissing = "\(missing.0)=\(parsed.first { $0.key == missing.0 }!.value)" }
continue
}
let changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) }
var changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) }
if !clearing, let secondValue, !secondValue.isEmpty {
guard let second = profile.inputs[id]?.second else {
return .failure(QuickActionUnavailable(reason: "\(profile.name) takes one value for \(id), not two"))
}
for pair in second.pairs.compactMap(Self.parse) {
let value = pair.value.replacingOccurrences(of: "{value2}", with: secondValue)
guard context.supports(key: pair.key, value: value) else {
return .failure(QuickActionUnavailable(reason: "Not available for \(context.driverName) (no \(pair.key)=\(pair.value))"))
}
changes.append(OptionChange(key: pair.key, value: value, isSecret: false))
}
}
return .success(ResolvedQuickAction(profile: profile, changes: changes))
}
}
@@ -86,13 +98,40 @@ public struct QuickAction: Identifiable, Hashable {
public func isAvailable(in context: DriverContext) -> Bool { unavailableReason(in: context) == nil }
/// For Set User Code: digits only, within the PPD's limit for the code option. nil when valid.
/// The profile's input settings for this action on this queue (label, digits, second value).
public func inputSpec(_ context: DriverContext) -> DriverProfile.Input? {
guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil }
return resolved.profile.inputs[id]
}
/// "User code" (Ricoh) or the profile's label ("User ID" for Xerox).
public func inputLabel(_ context: DriverContext) -> String { inputSpec(context)?.label ?? "User code" }
/// The optional second value's label ("Account ID (optional)"), or nil when the profile has none.
public func secondInputLabel(_ context: DriverContext) -> String? { inputSpec(context)?.second?.label }
public func digitsOnly(_ context: DriverContext) -> Bool { inputSpec(context)?.digitsOnly ?? true }
/// For Set User Code: digits only unless the profile allows text, within the PPD's limit. nil when valid.
public func validationError(_ value: String, context: DriverContext) -> String? {
guard input == .userCode, !value.isEmpty else { return nil }
guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" }
if let keyword = codeKeyword(context),
let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max {
return "At most \(max) digits"
return Self.valueError(value, digitsOnly: digitsOnly(context), keyword: codeKeyword(context), context: context)
}
/// For the optional second value (text, within the PPD's limit). nil when valid.
public func secondValidationError(_ value: String, context: DriverContext) -> String? {
guard input == .userCode, !value.isEmpty else { return nil }
return Self.valueError(value, digitsOnly: false, keyword: secondKeyword(context), context: context)
}
private static func valueError(_ value: String, digitsOnly: Bool, keyword: String?, context: DriverContext) -> String? {
if digitsOnly {
guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" }
} else if value.contains(where: { $0 == "\"" || $0 == "'" || $0 == "\\" || $0.isNewline }) {
return "No quotes, backslashes or line breaks"
}
if let keyword, let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max {
return "At most \(max) \(digitsOnly ? "digits" : "characters")"
}
return nil
}
@@ -104,6 +143,17 @@ public struct QuickAction: Identifiable, Hashable {
return String(value.dropFirst("Custom.".count))
}
/// The second value currently set on the queue (e.g. the Xerox account ID), without Custom.
public func currentSecondValue(_ context: DriverContext) -> String? {
guard let keyword = secondKeyword(context),
let value = context.ppd?.option(keyword)?.defaultChoice, value.hasPrefix("Custom.") else { return nil }
return String(value.dropFirst("Custom.".count))
}
private func secondKeyword(_ context: DriverContext) -> String? {
inputSpec(context)?.second?.pairs.compactMap(Self.parse).first { $0.value.contains("{value2}") }?.key
}
/// The option that receives `{value}` for this queue's profile (e.g. RIUserCode).
private func codeKeyword(_ context: DriverContext) -> String? {
guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil }
@@ -139,14 +189,15 @@ public struct QuickActionOutcome {
extension QuickAction {
/// Runs the action on a queue with read-back. Throws QuickActionUnavailable when no profile fits.
public func run(queue: String, value: String? = nil, clearing: Bool = false, client: CupsClient) async throws -> QuickActionOutcome {
public func run(queue: String, value: String? = nil, secondValue: String? = nil, clearing: Bool = false,
client: CupsClient) async throws -> QuickActionOutcome {
if isDefaultPrinter {
let outcome = try await QueueActions.setDefault(queue: queue, client: client)
return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, profile: nil,
readBack: [], changes: [], message: outcome.message)
}
let context = try await DriverContext.load(client: client, queue: queue)
let resolved = try resolve(context, value: value, clearing: clearing).get()
let resolved = try resolve(context, value: value, secondValue: secondValue, clearing: clearing).get()
let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: resolved.changes)
let failed = readBack.filter { !$0.applied }
return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: resolved.changes),

View File

@@ -1,5 +1,5 @@
{
"comment": "Quick Action mappings per driver. First matching profile that defines an action AND whose keyword=value pairs this queue supports wins; 'generic' (empty match) must stay last. An action is either one list of pairs (all applied together) or a list of alternative lists. {value} is replaced with the user's input. 'unavailable' maps an action id to the reason it can't be done with this driver (no later profile is tried). Find keywords with: cupsadmin ppdreport <queue | file.ppd>",
"comment": "Quick Action mappings per driver. First matching profile that defines an action AND whose keyword=value pairs this queue supports wins; 'generic' (empty match) must stay last. An action is either one list of pairs (all applied together) or a list of alternative lists. {value} is replaced with the user's input. 'inputs' can relabel an action's typed value (digitsOnly defaults to true) and add an optional second value ({value2}) with its own pairs. 'unavailable' maps an action id to the reason it can't be done with this driver (no later profile is tried). Find keywords with: cupsadmin ppdreport <queue | file.ppd>",
"profiles": [
{
"id": "ricoh-m-c251fw",
@@ -54,6 +54,37 @@
"usercode": "HP drivers have no accounting code option"
}
},
{
"id": "xerox",
"name": "Xerox",
"match": { "manufacturer": "^Xerox$" },
"actions": {
"color": [["XROutputColor=PrintAsColor"], ["XRColorCorrection=Auto"]],
"bw": [["XROutputColor=PrintAsGrayscale"], ["XRColorCorrection=Gray"]],
"usercode": ["XRAccountingSystem=XSA", "AcctUserID=Custom.{value}"],
"usercode-clear": ["XRAccountingSystem=None", "AcctUserID=None", "AcctAccountID=None"]
},
"inputs": {
"usercode": {
"label": "User ID",
"digitsOnly": false,
"second": { "label": "Account ID (optional)", "pairs": ["AcctAccountID=Custom.{value2}"] }
}
}
},
{
"id": "konica-minolta",
"name": "Konica Minolta",
"match": { "manufacturer": "^KONICA MINOLTA$" },
"actions": {
"duplex": ["KMDuplex=Double"],
"simplex": ["KMDuplex=Single"],
"letter": ["PageSize=8.5x11"]
},
"unavailable": {
"usercode": "Konica Minolta Account Track codes are set by the driver, not the PPD"
}
},
{
"id": "generic",
"name": "Generic (standard PPD keywords, IPP Everywhere)",

View File

@@ -5,7 +5,7 @@ enum QuickCommand {
static func run(client: CupsClient, args: [String], clear: Bool) async throws -> String {
guard let name = args.first else {
printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map {
[$0.id, $0.input == .userCode ? "<code> | --clear" : "", $0.summary]
[$0.id, $0.input == .userCode ? "<code> [account] | --clear" : "", $0.summary]
})
print("\nDriver profiles (first match wins, generic last): "
+ DriverProfiles.builtIn.map(\.id).joined(separator: ", "))
@@ -18,6 +18,7 @@ enum QuickCommand {
guard args.count >= 2 else { throw CupsAdminError.usage("quick \(name) needs a queue name") }
let queue = args[1]
let value: String?
var secondValue: String?
switch action.input {
case .none:
guard args.count == 2, !clear else { throw CupsAdminError.usage("quick \(name) takes only a queue name") }
@@ -27,8 +28,11 @@ enum QuickCommand {
guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") }
value = nil
} else {
guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") }
guard args.count == 3 || args.count == 4 else {
throw CupsAdminError.usage("quick \(name) needs a code (and, for Xerox, an optional account ID) or --clear")
}
value = args[2]
secondValue = args.count == 4 ? args[3] : nil
}
}
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
@@ -48,10 +52,13 @@ enum QuickCommand {
let before = try await OptionState.load(client: client, queue: queue)
let context = DriverContext(ppd: before.ppd, attributes: before.snapshot.attributes)
if let value, let error = action.validationError(value, context: context) {
throw CupsAdminError.usage("user code \(value): \(error)")
throw CupsAdminError.usage("\(action.inputLabel(context).lowercased()) \(value): \(error)")
}
if let secondValue, let error = action.secondValidationError(secondValue, context: context) {
throw CupsAdminError.usage("second value \(secondValue): \(error)")
}
let resolved: ResolvedQuickAction
switch action.resolve(context, value: value, clearing: clear) {
switch action.resolve(context, value: value, secondValue: secondValue, clearing: clear) {
case .success(let r): resolved = r
case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) on \(queue): \(unavailable.reason)")
}

View File

@@ -40,7 +40,7 @@ let usage = """
set <queue> [-o key=value ...] [-v uri] [-D description] [-L location] [-m model | -P ppd]
change a queue via lpadmin, then show before/after
quick list quick actions (task-named queue defaults)
quick <action> <queue> [code | --clear]
quick <action> <queue> [code [account] | --clear]
apply one via lpadmin, then verify
ppdreport <queue | path.ppd[.gz]>
every PPD group and option: keyword, label, type, default, choices

View File

@@ -18,13 +18,17 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
@Suite struct DriverProfileFile {
@Test func builtInProfilesAreWellFormed() {
let profiles = DriverProfiles.builtIn
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "canon", "hp", "generic"])
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "canon", "hp", "xerox", "konica-minolta", "generic"])
#expect(profiles.last?.isGeneric == true)
#expect(profiles.dropLast().allSatisfy { !$0.isGeneric })
let known = Set(QuickAction.all.map(\.id)).union(["usercode-clear"])
for profile in profiles {
#expect(Set(profile.actions.keys).isSubset(of: known), "\(profile.id): \(profile.actions.keys.sorted())")
#expect(Set(profile.unavailable.keys).isSubset(of: known), "\(profile.id): \(profile.unavailable.keys.sorted())")
#expect(Set(profile.inputs.keys).isSubset(of: Set(QuickAction.all.filter { $0.input != .none }.map(\.id))))
for input in profile.inputs.values {
#expect(input.second.map { !$0.pairs.isEmpty && $0.pairs.allSatisfy { QuickAction.parse($0) != nil } } ?? true)
}
for alternatives in profile.actions.values {
#expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } })
}
@@ -51,6 +55,10 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
#expect(code.map(\.value) == ["True", "Custom.4321"])
let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
#expect(clear.map(\.value) == ["False", "None"])
// Ricoh takes one value: digits, no second input.
#expect(QuickAction.named("usercode")!.inputLabel(c) == "User code")
#expect(QuickAction.named("usercode")!.secondInputLabel(c) == nil)
#expect(throws: QuickActionUnavailable.self) { try QuickAction.named("usercode")!.resolve(c, value: "4321", secondValue: "9").get() }
}
@Test(.enabled(if: hasVendorPPD("RICOH MP 5055")))

View File

@@ -146,3 +146,98 @@ private func hasChoice(_ c: DriverContext, _ keyword: String, _ choice: String)
#expect(hpColor > 0)
}
}
@Suite struct XeroxProfile {
static let altaLink = "Xerox AltaLink C8170.gz"
static let monoVersaLink = "Xerox VersaLink B620 Printer.gz"
@Test(.enabled(if: ExtraPPDs.path(altaLink) != nil))
func altaLinkColorAndStandardAccounting() throws {
let c = try #require(ExtraPPDs.context(Self.altaLink))
#expect(DriverProfiles.matching(c).map(\.id) == ["xerox", "generic"])
#expect(pairs("color", c) == ["XROutputColor=PrintAsColor"])
#expect(pairs("bw", c) == ["XROutputColor=PrintAsGrayscale"])
#expect(resolved("duplex", c)?.profile.id == "generic" && pairs("duplex", c) == ["Duplex=DuplexNoTumble"])
#expect(resolved("letter", c)?.profile.id == "generic" && pairs("letter", c) == ["PageSize=Letter"])
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
let usercode = QuickAction.named("usercode")!
#expect(usercode.inputLabel(c) == "User ID")
#expect(usercode.secondInputLabel(c) == "Account ID (optional)")
#expect(!usercode.digitsOnly(c))
#expect(try usercode.resolve(c, value: "jdoe").get().changes.map { "\($0.key)=\($0.value)" }
== ["XRAccountingSystem=XSA", "AcctUserID=Custom.jdoe"])
#expect(try usercode.resolve(c, value: "jdoe", secondValue: "").get().changes.count == 2)
#expect(try usercode.resolve(c, value: "jdoe", secondValue: "ART101").get().changes.map { "\($0.key)=\($0.value)" }
== ["XRAccountingSystem=XSA", "AcctUserID=Custom.jdoe", "AcctAccountID=Custom.ART101"])
#expect(try usercode.resolve(c, clearing: true).get().changes.map { "\($0.key)=\($0.value)" }
== ["XRAccountingSystem=None", "AcctUserID=None", "AcctAccountID=None"])
#expect(usercode.validationError("jdoe", context: c) == nil)
#expect(usercode.validationError(String(repeating: "a", count: 33), context: c) == "At most 32 characters")
#expect(usercode.validationError("j\"doe", context: c) == "No quotes, backslashes or line breaks")
#expect(usercode.secondValidationError("ART101", context: c) == nil)
#expect(usercode.secondValidationError(String(repeating: "9", count: 33), context: c) == "At most 32 characters")
}
@Test(.enabled(if: ExtraPPDs.path(monoVersaLink) != nil))
func versaLinkWithoutXROutputColorFallsBackToColorCorrection() throws {
let c = try #require(ExtraPPDs.context(Self.monoVersaLink))
#expect(pairs("bw", c) == ["XRColorCorrection=Gray"])
#expect(resolved("bw", c)?.profile.id == "xerox")
#expect(!QuickAction.named("color")!.isAvailable(in: c)) // mono: Gray is the only choice
#expect(QuickAction.named("usercode")!.isAvailable(in: c))
}
/// Every Xerox PPD in the package: each action is offered exactly when the PPD has what the profile needs.
@Test(.enabled(if: ExtraPPDs.files(prefix: "Xerox ").count > 100))
func everyXeroxPPD() throws {
var accounting = 0
for file in ExtraPPDs.files(prefix: "Xerox ") {
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
let name = (file as NSString).lastPathComponent
#expect(DriverProfiles.matching(c).first?.id == "xerox", "\(name)")
#expect(QuickAction.named("color")!.isAvailable(in: c)
== (hasChoice(c, "XROutputColor", "PrintAsColor") || hasChoice(c, "XRColorCorrection", "Auto")), "\(name)")
#expect(QuickAction.named("bw")!.isAvailable(in: c)
== (hasChoice(c, "XROutputColor", "PrintAsGrayscale") || hasChoice(c, "XRColorCorrection", "Gray")), "\(name)")
let xsa = hasChoice(c, "XRAccountingSystem", "XSA") && c.ppd?.option("AcctUserID")?.customParameters.isEmpty == false
#expect(QuickAction.named("usercode")!.isAvailable(in: c) == xsa, "\(name)")
if xsa { accounting += 1 }
}
#expect(accounting > 0)
}
}
@Suite struct KonicaMinoltaProfile {
static let bizhub = "KONICAMINOLTAC651i.gz"
static let reason = "Konica Minolta Account Track codes are set by the driver, not the PPD"
@Test(.enabled(if: ExtraPPDs.path(bizhub) != nil))
func bizhubC651i() throws {
let c = try #require(ExtraPPDs.context(Self.bizhub))
#expect(DriverProfiles.matching(c).map(\.id) == ["konica-minolta", "generic"])
#expect(pairs("color", c) == ["ColorModel=CMYK"] && resolved("color", c)?.profile.id == "generic")
#expect(pairs("bw", c) == ["ColorModel=Gray"])
#expect(pairs("duplex", c) == ["KMDuplex=Double"] && resolved("duplex", c)?.profile.id == "konica-minolta")
#expect(pairs("simplex", c) == ["KMDuplex=Single"])
#expect(pairs("letter", c) == ["PageSize=8.5x11"])
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason)
}
/// Every bizhub PPD found (i and xi, plus the 1-sided "S" variants).
@Test(.enabled(if: !ExtraPPDs.files(prefix: "KONICAMINOLTA").isEmpty))
func everyKonicaMinoltaPPD() throws {
for file in ExtraPPDs.files(prefix: "KONICAMINOLTA") {
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
let name = (file as NSString).lastPathComponent
#expect(DriverProfiles.matching(c).first?.id == "konica-minolta", "\(name)")
#expect(QuickAction.named("duplex")!.isAvailable(in: c) == hasChoice(c, "KMDuplex", "Double"), "\(name)")
#expect(QuickAction.named("simplex")!.isAvailable(in: c) == hasChoice(c, "KMDuplex", "Single"), "\(name)")
#expect(QuickAction.named("letter")!.isAvailable(in: c) == hasChoice(c, "PageSize", "8.5x11"), "\(name)")
#expect(QuickAction.named("bw")!.isAvailable(in: c) == hasChoice(c, "ColorModel", "Gray"), "\(name)")
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason, "\(name)")
}
}
}