Printer enumeration & capabilities (#12) #42
@@ -0,0 +1,243 @@
|
||||
import Foundation
|
||||
|
||||
/// One `lpoptions -l` line: `Key/Human Label: *default choice choice`.
|
||||
public struct CupsOptionListing: Equatable, Sendable {
|
||||
/// Machine key before `/`, e.g. `InputSlot` or `CNIJMediaType`.
|
||||
public var key: String
|
||||
/// Human label after `/`, e.g. `Media Source`.
|
||||
public var label: String
|
||||
/// All choices, `*` stripped.
|
||||
public var choices: [String]
|
||||
/// The `*`-prefixed default choice, if any.
|
||||
public var defaultChoice: String?
|
||||
|
||||
public init(key: String, label: String, choices: [String], defaultChoice: String?) {
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.choices = choices
|
||||
self.defaultChoice = defaultChoice
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure parsers for `lpstat` / `lpoptions` / PPD text (issue 12,
|
||||
/// docs/10–11). Recorded fixtures drive the tests — no live CUPS.
|
||||
public enum CupsParsers {
|
||||
|
||||
// MARK: - lpstat
|
||||
|
||||
/// `lpstat -e` — one CUPS destination name per line.
|
||||
public static func lpstatDestinations(_ output: String) -> [String] {
|
||||
output.split(separator: "\n")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
/// `lpstat -p` — `printer NAME is idle. enabled since …`,
|
||||
/// `printer NAME now printing NAME-1. …`, `printer NAME disabled
|
||||
/// since …` → queue → status.
|
||||
public static func lpstatStatuses(_ output: String) -> [String: PrinterStatus] {
|
||||
var result: [String: PrinterStatus] = [:]
|
||||
for line in output.split(separator: "\n") {
|
||||
let text = line.trimmingCharacters(in: .whitespaces)
|
||||
guard text.hasPrefix("printer ") else { continue }
|
||||
let rest = text.dropFirst("printer ".count)
|
||||
guard let sep = rest.firstIndex(of: " ") else { continue }
|
||||
let name = String(rest[..<sep])
|
||||
let desc = rest[sep...].lowercased()
|
||||
if desc.contains("now printing") {
|
||||
result[name] = .printing
|
||||
} else if desc.contains("idle") {
|
||||
result[name] = .idle
|
||||
} else if desc.contains("disabled") || desc.contains("stopped") {
|
||||
result[name] = .stopped
|
||||
} else {
|
||||
result[name] = .unknown
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// `lpstat -d` — `system default destination: NAME`, or
|
||||
/// `no system default destination` → nil.
|
||||
public static func lpstatDefault(_ output: String) -> String? {
|
||||
for line in output.split(separator: "\n") {
|
||||
let text = line.trimmingCharacters(in: .whitespaces)
|
||||
guard let colon = text.firstIndex(of: ":") else { continue }
|
||||
let name = text[text.index(after: colon)...]
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
if text.lowercased().hasPrefix("system default destination"),
|
||||
!name.isEmpty {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - lpoptions -p <queue>
|
||||
|
||||
/// `lpoptions -p` — `key=value` pairs, values may be
|
||||
/// single-quoted (`printer-info='EPSON XP-55 Series'`); bare
|
||||
/// flags (`printer-location`) parse as present-with-empty-value.
|
||||
public static func lpoptions(_ output: String) -> [(key: String, value: String)] {
|
||||
var pairs: [(String, String)] = []
|
||||
var index = output.startIndex
|
||||
while index < output.endIndex {
|
||||
while index < output.endIndex && output[index].isWhitespace {
|
||||
index = output.index(after: index)
|
||||
}
|
||||
guard index < output.endIndex else { break }
|
||||
let tokenStart = index
|
||||
while index < output.endIndex && output[index] != "=" && !output[index].isWhitespace {
|
||||
index = output.index(after: index)
|
||||
}
|
||||
let key = String(output[tokenStart..<index])
|
||||
guard !key.isEmpty else { break }
|
||||
if index < output.endIndex && output[index] == "=" {
|
||||
index = output.index(after: index)
|
||||
if index < output.endIndex && output[index] == "'" {
|
||||
// Single-quoted value — scan to closing quote.
|
||||
index = output.index(after: index)
|
||||
let valueStart = index
|
||||
while index < output.endIndex && output[index] != "'" {
|
||||
index = output.index(after: index)
|
||||
}
|
||||
pairs.append((key, String(output[valueStart..<index])))
|
||||
if index < output.endIndex { index = output.index(after: index) }
|
||||
} else {
|
||||
let valueStart = index
|
||||
while index < output.endIndex && !output[index].isWhitespace {
|
||||
index = output.index(after: index)
|
||||
}
|
||||
pairs.append((key, String(output[valueStart..<index])))
|
||||
}
|
||||
} else {
|
||||
pairs.append((key, ""))
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
/// Display name from `printer-info` in `lpoptions -p` output.
|
||||
public static func lpoptionsDisplayName(_ output: String) -> String? {
|
||||
guard let value = lpoptions(output)
|
||||
.first(where: { $0.key == "printer-info" })?.value,
|
||||
!value.isEmpty
|
||||
else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
// MARK: - lpoptions -l
|
||||
|
||||
/// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`.
|
||||
/// A missing `/` label reuses the key.
|
||||
public static func lpoptionsList(_ output: String) -> [CupsOptionListing] {
|
||||
output.split(separator: "\n").compactMap { raw in
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
guard let colon = line.firstIndex(of: ":") else { return nil }
|
||||
let head = String(line[..<colon])
|
||||
let body = line[line.index(after: colon)...]
|
||||
let headParts = head.split(separator: "/", maxSplits: 1)
|
||||
let key = headParts[0].trimmingCharacters(in: .whitespaces)
|
||||
guard !key.isEmpty else { return nil }
|
||||
let label = headParts.count > 1
|
||||
? headParts[1].trimmingCharacters(in: .whitespaces)
|
||||
: key
|
||||
var choices: [String] = []
|
||||
var defaultChoice: String?
|
||||
for token in body.split(separator: " ") {
|
||||
if token.hasPrefix("*") {
|
||||
let value = String(token.dropFirst())
|
||||
defaultChoice = value
|
||||
choices.append(value)
|
||||
} else {
|
||||
choices.append(String(token))
|
||||
}
|
||||
}
|
||||
return CupsOptionListing(
|
||||
key: key, label: label,
|
||||
choices: choices, defaultChoice: defaultChoice)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PPD enrichment
|
||||
|
||||
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
||||
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
|
||||
public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] {
|
||||
var map: [String: String] = [:]
|
||||
for rawLine in ppd.split(separator: "\n") {
|
||||
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
||||
line = String(line.dropFirst())
|
||||
// Optional locale qualifier: `en_US.InputSlot` → `InputSlot`.
|
||||
// Only strip when the part before the first `.` looks like
|
||||
// a locale (short `xx`/`xx_YY`); real keys containing dots
|
||||
// are left alone.
|
||||
if let dot = line.firstIndex(of: ".") {
|
||||
let prefix = line[..<dot]
|
||||
let looksLikeLocale = (2...5).contains(prefix.count)
|
||||
&& prefix.allSatisfy { $0.isLetter || $0 == "_" }
|
||||
&& (prefix.count == 2 || prefix.contains("_"))
|
||||
let candidate = line[line.index(after: dot)...]
|
||||
if looksLikeLocale && candidate.hasPrefix(key) {
|
||||
line = String(candidate)
|
||||
}
|
||||
}
|
||||
guard line.hasPrefix(key) else { continue }
|
||||
var rest = line[line.index(line.startIndex, offsetBy: key.count)...]
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard let colon = rest.firstIndex(of: ":") else { continue }
|
||||
rest = String(rest[..<colon])
|
||||
// `<id>/<Human label>` — human label after the last `/`.
|
||||
guard let slash = rest.firstIndex(of: "/") else { continue }
|
||||
let id = String(rest[..<slash])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
let human = String(rest[rest.index(after: slash)...])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
if !id.isEmpty { map[id] = human.isEmpty ? id : human }
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// MARK: - Detection (docs/11)
|
||||
|
||||
/// Media-type option key in preference order — used both to read a
|
||||
/// captured value and to emit `-o <key>=<media>`.
|
||||
public static let mediaTypeKeys = [
|
||||
"CNIJMediaType", "EPIJ_Medi", "StpMediaType", "MediaType"
|
||||
]
|
||||
|
||||
public static func detectMediaTypeKey(optionKeys: Set<String>) -> String? {
|
||||
mediaTypeKeys.first { optionKeys.contains($0) }
|
||||
}
|
||||
|
||||
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
|
||||
/// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`;
|
||||
/// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`;
|
||||
/// Gutenprint `StpColorCorrection=Uncorrected`; generic
|
||||
/// `ColorCorrection=Uncorrected`; `EpsonColorMode=Off`.
|
||||
public static func detectDriverColorBypass(
|
||||
optionKeys: Set<String>
|
||||
) -> (key: String, value: String)? {
|
||||
if optionKeys.contains("CNIJIntent2") { return ("CNIJIntent2", "4") }
|
||||
if optionKeys.contains("CNIJIntent") { return ("CNIJIntent", "4") }
|
||||
if optionKeys.contains("EPIJ_CCor") { return ("EPIJ_CCor", "0") }
|
||||
if optionKeys.contains("EPIJ_CMat") { return ("EPIJ_CMat", "3") }
|
||||
if optionKeys.contains("StpColorCorrection") {
|
||||
return ("StpColorCorrection", "Uncorrected")
|
||||
}
|
||||
if optionKeys.contains("ColorCorrection") {
|
||||
return ("ColorCorrection", "Uncorrected")
|
||||
}
|
||||
if optionKeys.contains("EpsonColorMode") { return ("EpsonColorMode", "Off") }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The key=value pairs of colour-bypass keys — used to detect
|
||||
/// whether captured options already carry a bypass.
|
||||
public static let bypassKeys: Set<String> = [
|
||||
"CNIJIntent2", "CNIJIntent", "EPIJ_CMat", "EPIJ_CCor",
|
||||
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
||||
"EpsonColorMode",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import Foundation
|
||||
|
||||
/// Errors from CUPS tool invocations.
|
||||
public enum CupsError: LocalizedError, Equatable {
|
||||
case toolFailed(tool: String, code: Int32, stderr: String)
|
||||
case tiffMissing(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .toolFailed(let tool, let code, let stderr):
|
||||
let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return detail.isEmpty
|
||||
? "\(tool) failed with exit code \(code)"
|
||||
: "\(tool) failed (\(code)): \(detail)"
|
||||
case .tiffMissing(let path):
|
||||
return "Target TIFF does not exist: \(path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CUPS command surface (issue 12): enumerates queues and reads
|
||||
/// per-queue capabilities via `/usr/bin/lpstat` and
|
||||
/// `/usr/bin/lpoptions`. Spawning goes through
|
||||
/// `ProcessManager.runCaptured` so spawns are logged, get killAll
|
||||
/// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are
|
||||
/// injectable so tests use fixture scripts and never touch real CUPS.
|
||||
public struct CupsService: Sendable {
|
||||
public let processManager: ProcessManager
|
||||
/// Directory containing `lpstat`/`lpoptions`/`lp` — `/usr/bin` in
|
||||
/// production, a fixture dir under test.
|
||||
public let binaryDir: URL
|
||||
/// `/etc/cups/ppd` in production.
|
||||
public let ppdDir: URL
|
||||
|
||||
public init(
|
||||
processManager: ProcessManager = .shared,
|
||||
binaryDir: URL = URL(fileURLWithPath: "/usr/bin"),
|
||||
ppdDir: URL = URL(fileURLWithPath: "/etc/cups/ppd")
|
||||
) {
|
||||
self.processManager = processManager
|
||||
self.binaryDir = binaryDir
|
||||
self.ppdDir = ppdDir
|
||||
}
|
||||
|
||||
// MARK: - Enumeration (lpstat -e/-p/-d)
|
||||
|
||||
/// All CUPS destinations with status and default flag. An empty
|
||||
/// list is a valid result, not an error.
|
||||
public func listPrinters() async throws -> [Printer] {
|
||||
// lpstat exits non-zero when no destinations exist — an empty
|
||||
// queue list is a valid result, not a failure (issue 12).
|
||||
let destinationsOut = try await run(
|
||||
"lpstat", ["-e"], id: ProcessID.lpstat("e"), tolerateFailure: true)
|
||||
let statusOut = try await run(
|
||||
"lpstat", ["-p"], id: ProcessID.lpstat("p"), tolerateFailure: true)
|
||||
let defaultOut = try await run(
|
||||
"lpstat", ["-d"], id: ProcessID.lpstat("d"), tolerateFailure: true)
|
||||
|
||||
let names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
|
||||
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
|
||||
let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout)
|
||||
|
||||
var printers: [Printer] = []
|
||||
for name in names {
|
||||
let displayName = try? await displayName(for: name)
|
||||
printers.append(Printer(
|
||||
name: name,
|
||||
status: statuses[name] ?? .unknown,
|
||||
isDefault: name == defaultName,
|
||||
displayName: displayName
|
||||
))
|
||||
}
|
||||
return printers
|
||||
}
|
||||
|
||||
/// `lpoptions -p <queue>` → `printer-info` (the NSPrinter fallback
|
||||
/// display name, docs/11 §binding).
|
||||
public func displayName(for queue: String) async throws -> String? {
|
||||
let result = try await run(
|
||||
"lpoptions", ["-p", queue], id: ProcessID.lpoptions(queue))
|
||||
return CupsParsers.lpoptionsDisplayName(result.stdout)
|
||||
}
|
||||
|
||||
// MARK: - Capabilities (lpoptions -l + PPD)
|
||||
|
||||
/// Raw `Key/Label: choices` listings for a queue — also the input
|
||||
/// to media-key and colour-bypass detection (docs/11 layer ④).
|
||||
public func optionListings(for queue: String) async throws -> [CupsOptionListing] {
|
||||
let result = try await run(
|
||||
"lpoptions", ["-p", queue, "-l"], id: ProcessID.lpoptions("\(queue)-l"))
|
||||
return CupsParsers.lpoptionsList(result.stdout)
|
||||
}
|
||||
|
||||
/// Trays / paper sizes / media types for a queue, with PPD
|
||||
/// `*Key id/Human:` enrichment when the queue's PPD is readable.
|
||||
public func capabilities(for queue: String) async throws -> PrinterCapabilities {
|
||||
let listings = try await optionListings(for: queue)
|
||||
return capabilities(from: listings, ppd: loadPPD(for: queue))
|
||||
}
|
||||
|
||||
/// Pure mapping — extracted so fixture tests need no process.
|
||||
public func capabilities(
|
||||
from listings: [CupsOptionListing],
|
||||
ppd: String?
|
||||
) -> PrinterCapabilities {
|
||||
var trays: [PrinterTray] = []
|
||||
var sizes: [PrinterPaperSize] = []
|
||||
var media: [PrinterMediaType] = []
|
||||
|
||||
for listing in listings {
|
||||
switch listing.key {
|
||||
case "InputSlot", "MediaSource":
|
||||
trays = listing.choices.enumerated().map {
|
||||
PrinterTray(id: $0.offset + 1, name: $0.element)
|
||||
}
|
||||
case "PageSize", "MediaSize":
|
||||
sizes = listing.choices.enumerated().map {
|
||||
PrinterPaperSize(id: $0.offset + 1, name: $0.element)
|
||||
}
|
||||
case let key where CupsParsers.mediaTypeKeys.contains(key):
|
||||
guard media.isEmpty else { continue }
|
||||
let labels = ppd.map {
|
||||
CupsParsers.ppdChoiceLabels($0, key: key)
|
||||
} ?? [:]
|
||||
media = listing.choices.map {
|
||||
PrinterMediaType(id: $0, name: labels[$0] ?? $0)
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return PrinterCapabilities(
|
||||
trays: trays, paperSizes: sizes, mediaTypes: media)
|
||||
}
|
||||
|
||||
/// The set of option keys a queue advertises — input to
|
||||
/// `detectDriverColorBypass` / `detectMediaTypeKey`.
|
||||
public func optionKeys(for queue: String) async throws -> Set<String> {
|
||||
Set(try await optionListings(for: queue).map(\.key))
|
||||
}
|
||||
|
||||
// MARK: - PPD
|
||||
|
||||
private func loadPPD(for queue: String) -> String? {
|
||||
let url = ppdDir.appendingPathComponent("\(queue).ppd")
|
||||
return try? String(contentsOf: url, encoding: .utf8)
|
||||
}
|
||||
|
||||
// MARK: - Spawn
|
||||
|
||||
@discardableResult
|
||||
func run(
|
||||
_ tool: String,
|
||||
_ arguments: [String],
|
||||
id: String,
|
||||
tolerateFailure: Bool = false
|
||||
) async throws -> CapturedResult {
|
||||
let binary = binaryDir.appendingPathComponent(tool)
|
||||
let result = try await processManager.runCaptured(
|
||||
id: id, binary: binary, arguments: arguments)
|
||||
if result.exitCode != 0, !tolerateFailure {
|
||||
throw CupsError.toolFailed(
|
||||
tool: tool, code: result.exitCode, stderr: result.stderr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
|
||||
/// Queue status reported by `lpstat -p` (docs/10 §Printer).
|
||||
public enum PrinterStatus: String, Codable, Sendable, CaseIterable {
|
||||
case idle = "Idle"
|
||||
case printing = "Printing"
|
||||
case stopped = "Stopped"
|
||||
case unknown = "Unknown"
|
||||
}
|
||||
|
||||
/// A CUPS destination. `name` is the queue id sent back to every
|
||||
/// subsequent print command; `displayName` is the human label from
|
||||
/// `printer-info` (used as the `NSPrinter` fallback when PM binding
|
||||
/// fails — #188).
|
||||
public struct Printer: Codable, Equatable, Sendable {
|
||||
public var name: String
|
||||
public var status: PrinterStatus
|
||||
public var isDefault: Bool
|
||||
public var displayName: String?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
status: PrinterStatus = .unknown,
|
||||
isDefault: Bool = false,
|
||||
displayName: String? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.status = status
|
||||
self.isDefault = isDefault
|
||||
self.displayName = displayName
|
||||
}
|
||||
}
|
||||
|
||||
/// Paper source. `id` is the 1-based index of the `InputSlot` /
|
||||
/// `MediaSource` choice (not a PPD code) — docs/10.
|
||||
public struct PrinterTray: Codable, Equatable, Sendable {
|
||||
public var id: Int
|
||||
public var name: String
|
||||
|
||||
public init(id: Int, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
/// Media size from `PageSize` / `MediaSize` choices (1-based index).
|
||||
public struct PrinterPaperSize: Codable, Equatable, Sendable {
|
||||
public var id: Int
|
||||
public var name: String
|
||||
|
||||
public init(id: Int, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
/// Media type: `id` is the PPD machine token, `name` the human label
|
||||
/// after `/` when a readable PPD enriches it (docs/10 §PPD id/Human).
|
||||
public struct PrinterMediaType: Codable, Equatable, Sendable {
|
||||
public var id: String
|
||||
public var name: String
|
||||
|
||||
public init(id: String, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
||||
public var trays: [PrinterTray]
|
||||
public var paperSizes: [PrinterPaperSize]
|
||||
public var mediaTypes: [PrinterMediaType]
|
||||
/// Always `true` on macOS (spec parity — CUPS honours
|
||||
/// `orientation-requested`).
|
||||
public var supportsOrientation: Bool
|
||||
|
||||
public init(
|
||||
trays: [PrinterTray] = [],
|
||||
paperSizes: [PrinterPaperSize] = [],
|
||||
mediaTypes: [PrinterMediaType] = [],
|
||||
supportsOrientation: Bool = true
|
||||
) {
|
||||
self.trays = trays
|
||||
self.paperSizes = paperSizes
|
||||
self.mediaTypes = mediaTypes
|
||||
self.supportsOrientation = supportsOrientation
|
||||
}
|
||||
}
|
||||
|
||||
/// Options carried into `lp` (docs/10 §PrintOptions). On macOS
|
||||
/// `paperSource` is ignored unless already present inside captured
|
||||
/// `cupsOptions`; `ppdUncorrectedPassthrough` is stored (the panel sets
|
||||
/// it on OK) but never gates the argv — macOS always bypasses driver
|
||||
/// colour management.
|
||||
public struct PrintOptions: Codable, Equatable, Sendable {
|
||||
public var paperSource: Int?
|
||||
/// `"portrait"` / `"landscape"` → `orientation-requested=3|4`.
|
||||
public var orientation: String?
|
||||
/// printtarg layout page size → `PageSize=` (skipped if captured).
|
||||
public var paperSize: String?
|
||||
public var mediaType: String?
|
||||
public var ppdUncorrectedPassthrough: Bool?
|
||||
/// Space-separated `key=value` captured from
|
||||
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥).
|
||||
public var cupsOptions: String?
|
||||
|
||||
public init(
|
||||
paperSource: Int? = nil,
|
||||
orientation: String? = nil,
|
||||
paperSize: String? = nil,
|
||||
mediaType: String? = nil,
|
||||
ppdUncorrectedPassthrough: Bool? = nil,
|
||||
cupsOptions: String? = nil
|
||||
) {
|
||||
self.paperSource = paperSource
|
||||
self.orientation = orientation
|
||||
self.paperSize = paperSize
|
||||
self.mediaType = mediaType
|
||||
self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough
|
||||
self.cupsOptions = cupsOptions
|
||||
}
|
||||
}
|
||||
|
||||
/// Returned by the printer-properties panel (docs/10 §PrintPropertiesResult).
|
||||
/// `nil` from the service means the user cancelled — never an error.
|
||||
public struct PrintPropertiesResult: Codable, Equatable, Sendable {
|
||||
/// CUPS printer id the panel ended on (`PMPrinterGetID`), or `nil`
|
||||
/// when the `NSPrinter` fallback ran.
|
||||
public var selectedPrinter: String?
|
||||
public var options: PrintOptions
|
||||
|
||||
public init(selectedPrinter: String?, options: PrintOptions) {
|
||||
self.selectedPrinter = selectedPrinter
|
||||
self.options = options
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,10 @@ public enum ProcessID {
|
||||
public static func iccgamut(stem: String) -> String { "iccgamut_\(stem)" }
|
||||
public static func printcal(_ stem: String) -> String { "printcal_\(stem)" }
|
||||
public static func applycal(_ stem: String) -> String { "applycal_\(stem)" }
|
||||
|
||||
/// CUPS system tools (`/usr/bin/…`) — captured one-shots, not
|
||||
/// streaming Argyll children.
|
||||
public static func lpstat(_ mode: String) -> String { "lpstat_\(mode)" }
|
||||
public static func lpoptions(_ queue: String) -> String { "lpoptions_\(queue)" }
|
||||
public static func lp(_ queue: String, page: Int) -> String { "lp_\(queue)_\(page)" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// Issue 12 — CUPS enumeration parsers on recorded fixtures
|
||||
/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here.
|
||||
@Suite("CupsParsers")
|
||||
struct CupsParsersTests {
|
||||
|
||||
// Recorded on an Epson XP-55 + Canon Pro9500 host.
|
||||
private let lpstatE = """
|
||||
Canon_Pro9500_II_series_XPS
|
||||
Epson_XP_55_LPD
|
||||
EPSON_XP_55_Series
|
||||
"""
|
||||
|
||||
private let lpstatP = """
|
||||
printer Canon_Pro9500_II_series_XPS is idle. enabled since Mon Sep 7 22:51:30 2026
|
||||
printer Epson_XP_55_LPD now printing Epson_XP_55_LPD-42. enabled since Mon Sep 7 21:50:25 2026
|
||||
printer EPSON_XP_55_Series disabled since Tue Sep 8 09:00:00 2026 -
|
||||
Paused
|
||||
"""
|
||||
|
||||
private let lpoptionsP = """
|
||||
device-uri=ipp://EPSON%20XP-55%20Series._ipp._tcp.local./ printer-info='EPSON XP-55 Series' printer-location printer-make-and-model='EPSON EPSON XP-55 Series' printer-type=16781340
|
||||
"""
|
||||
|
||||
private let lpoptionsL = """
|
||||
PageSize/Media Size: 3.5x5 4x6 5x7 8x10 *A4 A5 B5 Letter Legal Custom.WIDTHxHEIGHT
|
||||
InputSlot/Media Source: Auto *Main Photo Rear
|
||||
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope
|
||||
ColorModel/Output Mode: *RGB Gray
|
||||
Duplex/Duplex: *None DuplexNoTumble DuplexTumble
|
||||
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
|
||||
"""
|
||||
|
||||
@Test("lpstat -e: one destination per line; empty = success")
|
||||
func destinations() {
|
||||
#expect(CupsParsers.lpstatDestinations(lpstatE) == [
|
||||
"Canon_Pro9500_II_series_XPS",
|
||||
"Epson_XP_55_LPD",
|
||||
"EPSON_XP_55_Series",
|
||||
])
|
||||
#expect(CupsParsers.lpstatDestinations("") == [])
|
||||
}
|
||||
|
||||
@Test("lpstat -p: idle / now-printing / disabled statuses")
|
||||
func statuses() {
|
||||
let s = CupsParsers.lpstatStatuses(lpstatP)
|
||||
#expect(s["Canon_Pro9500_II_series_XPS"] == .idle)
|
||||
#expect(s["Epson_XP_55_LPD"] == .printing)
|
||||
#expect(s["EPSON_XP_55_Series"] == .stopped)
|
||||
}
|
||||
|
||||
@Test("lpstat -d: default destination or none")
|
||||
func defaultDestination() {
|
||||
#expect(CupsParsers.lpstatDefault(
|
||||
"system default destination: Canon_Pro9500_II_series_XPS\n")
|
||||
== "Canon_Pro9500_II_series_XPS")
|
||||
#expect(CupsParsers.lpstatDefault("no system default destination\n") == nil)
|
||||
}
|
||||
|
||||
@Test("lpoptions -p: quoted printer-info, bare flags ignored")
|
||||
func displayName() {
|
||||
#expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series")
|
||||
#expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil)
|
||||
}
|
||||
|
||||
@Test("lpoptions -l: key/label split, * marks the default")
|
||||
func optionListings() {
|
||||
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
||||
#expect(listings.count == 6)
|
||||
|
||||
let page = listings[0]
|
||||
#expect(page.key == "PageSize")
|
||||
#expect(page.label == "Media Size")
|
||||
#expect(page.defaultChoice == "A4")
|
||||
#expect(page.choices.contains("Custom.WIDTHxHEIGHT"))
|
||||
#expect(!page.choices.contains("*A4"))
|
||||
|
||||
let slot = listings[1]
|
||||
#expect(slot.key == "InputSlot")
|
||||
#expect(slot.choices == ["Auto", "Main", "Photo", "Rear"])
|
||||
#expect(slot.defaultChoice == "Main")
|
||||
}
|
||||
|
||||
@Test("capabilities: trays/sizes index 1-based, media uses detected key")
|
||||
func capabilities() {
|
||||
let service = CupsService()
|
||||
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
||||
let caps = service.capabilities(from: listings, ppd: nil)
|
||||
|
||||
#expect(caps.trays == [
|
||||
PrinterTray(id: 1, name: "Auto"),
|
||||
PrinterTray(id: 2, name: "Main"),
|
||||
PrinterTray(id: 3, name: "Photo"),
|
||||
PrinterTray(id: 4, name: "Rear"),
|
||||
])
|
||||
#expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5"))
|
||||
#expect(caps.paperSizes.count == 10)
|
||||
#expect(caps.mediaTypes.map(\.id) == [
|
||||
"Stationery", "PhotographicHighGloss", "Photographic",
|
||||
"PhotographicMatte", "Envelope",
|
||||
])
|
||||
#expect(caps.supportsOrientation)
|
||||
}
|
||||
|
||||
@Test("PPD enrichment maps id → human label")
|
||||
func ppdLabels() {
|
||||
let ppd = """
|
||||
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>"
|
||||
*CNIJMediaType 0/Plain Paper: ""
|
||||
*en_US.CNIJMediaType 13/Envelope: ""
|
||||
"""
|
||||
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||
#expect(labels["42"] == "Photo Paper Plus Semi-gloss")
|
||||
#expect(labels["0"] == "Plain Paper")
|
||||
#expect(labels["13"] == "Envelope")
|
||||
}
|
||||
|
||||
@Test("detectMediaTypeKey prefers vendor keys in order")
|
||||
func mediaTypeKey() {
|
||||
#expect(CupsParsers.detectMediaTypeKey(
|
||||
optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType")
|
||||
#expect(CupsParsers.detectMediaTypeKey(
|
||||
optionKeys: ["PageSize", "MediaType"]) == "MediaType")
|
||||
#expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil)
|
||||
}
|
||||
|
||||
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat")
|
||||
func driverBypass() {
|
||||
#expect(CupsParsers.detectDriverColorBypass(
|
||||
optionKeys: ["CNIJIntent2", "CNIJIntent"])
|
||||
== ("CNIJIntent2", "4"))
|
||||
#expect(CupsParsers.detectDriverColorBypass(optionKeys: ["CNIJIntent"])
|
||||
== ("CNIJIntent", "4"))
|
||||
#expect(CupsParsers.detectDriverColorBypass(
|
||||
optionKeys: ["EPIJ_CCor", "EPIJ_CMat"]) == ("EPIJ_CCor", "0"))
|
||||
#expect(CupsParsers.detectDriverColorBypass(optionKeys: ["EPIJ_CMat"])
|
||||
== ("EPIJ_CMat", "3"))
|
||||
#expect(CupsParsers.detectDriverColorBypass(
|
||||
optionKeys: ["StpColorCorrection"]) == ("StpColorCorrection", "Uncorrected"))
|
||||
#expect(CupsParsers.detectDriverColorBypass(
|
||||
optionKeys: ["ColorCorrection"]) == ("ColorCorrection", "Uncorrected"))
|
||||
#expect(CupsParsers.detectDriverColorBypass(
|
||||
optionKeys: ["EpsonColorMode"]) == ("EpsonColorMode", "Off"))
|
||||
#expect(CupsParsers.detectDriverColorBypass(optionKeys: ["PageSize"]) == nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user