Files
ICCery-CPU/Sources/Printing/CUPSManager.swift
gronod 474888541b
Build macOS Packages / Build macOS (Intel) (pull_request) Successful in 32s
Build macOS Packages / Build macOS (Apple Silicon) (pull_request) Successful in 37s
Build macOS Packages / Build macOS (Universal) (pull_request) Failing after 10s
feat: add hardware print quality option supporting Canon 5-notch Custom slider and Epson modes (Closes #5)
2026-09-07 23:37:01 +01:00

438 lines
17 KiB
Swift

import Foundation
import AppKit
import Darwin
/// SPEC §10 — libcups queue inspection, AirPrint detection, vendor PPD injection.
struct PPDChoice: Equatable, Hashable {
var name: String // Computer-readable identifier (e.g. "13", "2", "Plain")
var title: String // Human-readable display label (e.g. "Epson Premium Glossy", "Cassette 1")
}
struct PrinterQueue: Equatable {
var name: String
var instance: String?
var displayName: String
var uri: String
var make: String
var model: String
var isAirPrint: Bool
var mediaSizes: [String]
var mediaTypes: [String]
var mediaTypeChoices: [PPDChoice]
var mediaTypeKeyword: String?
var trays: [String]
var trayChoices: [PPDChoice]
var trayKeyword: String?
var qualityChoices: [PPDChoice]
var qualityKeyword: String?
var resolutions: [String]
var resolutionChoices: [PPDChoice]
var ppdText: String
init(
name: String,
instance: String? = nil,
displayName: String,
uri: String,
make: String,
model: String,
isAirPrint: Bool,
mediaSizes: [String],
mediaTypes: [String],
mediaTypeChoices: [PPDChoice] = [],
mediaTypeKeyword: String? = nil,
trays: [String],
trayChoices: [PPDChoice] = [],
trayKeyword: String? = nil,
qualityChoices: [PPDChoice] = [],
qualityKeyword: String? = nil,
resolutions: [String],
resolutionChoices: [PPDChoice] = [],
ppdText: String
) {
self.name = name
self.instance = instance
self.displayName = displayName
self.uri = uri
self.make = make
self.model = model
self.isAirPrint = isAirPrint
self.mediaSizes = mediaSizes
self.mediaTypes = mediaTypes
self.mediaTypeChoices = mediaTypeChoices
self.mediaTypeKeyword = mediaTypeKeyword
self.trays = trays
self.trayChoices = trayChoices
self.trayKeyword = trayKeyword
self.qualityChoices = qualityChoices
self.qualityKeyword = qualityKeyword
self.resolutions = resolutions
self.resolutionChoices = resolutionChoices
self.ppdText = ppdText
}
}
enum CUPSManager {
static let airPrintWarning =
"Selected printer uses an AirPrint / driverless queue. Hardware colour management cannot be reliably disabled. Install the official OEM driver (Epson, Canon, etc.) for accurate profiling."
// MARK: - Queue listing
static func listQueues() throws -> [PrinterQueue] {
var dests: UnsafeMutablePointer<cups_dest_t>? = nil
let count = cupsGetDests(&dests)
defer { if let dests = dests { cupsFreeDests(count, dests) } }
guard count > 0, let dests = dests else {
return []
}
var result: [PrinterQueue] = []
for i in 0..<Int(count) {
let dest = dests.advanced(by: i).pointee
guard let cName = dest.name else { continue }
let name = String(cString: cName)
let instance = dest.instance.map { String(cString: $0) }
let uri = destValue(dest, key: "printer-uri-supported")
?? destValue(dest, key: "device-uri")
?? ""
let makeModel = destValue(dest, key: "printer-make-and-model") ?? ""
let (make, model) = splitMakeModel(makeModel)
let ppdText = ppdContents(forQueue: name) ?? ""
let options = discoverPPDOptions(ppdText)
let air = AirPrintDetector.isAirPrint(
uri: uri,
ppdText: ppdText,
make: make,
model: model
)
result.append(PrinterQueue(
name: name,
instance: instance,
displayName: instance.map { "\(name)/\($0)" } ?? name,
uri: uri,
make: make,
model: model,
isAirPrint: air,
mediaSizes: options.pageSizes,
mediaTypes: options.mediaTypes,
mediaTypeChoices: options.mediaTypeChoices,
mediaTypeKeyword: options.mediaTypeKeyword,
trays: options.trays,
trayChoices: options.trayChoices,
trayKeyword: options.trayKeyword,
qualityChoices: options.qualityChoices,
qualityKeyword: options.qualityKeyword,
resolutions: options.resolutions,
resolutionChoices: options.resolutionChoices,
ppdText: ppdText
))
}
return result
}
static func namedQueue(_ name: String) throws -> PrinterQueue? {
try listQueues().first { $0.name == name }
}
// MARK: - PPD
static func ppdPath(forQueue name: String) -> String? {
name.withCString { cName in
guard let cPath = TPCupsGetPPD(cName) else { return nil }
return String(cString: cPath)
}
}
static func ppdContents(forQueue name: String) -> String? {
guard let path = ppdPath(forQueue: name) else { return nil }
defer { unlink(path) }
return try? String(contentsOfFile: path, encoding: .isoLatin1)
}
struct PPDOptions {
var pageSizes: [String] = []
var mediaTypes: [String] = []
var mediaTypeChoices: [PPDChoice] = []
var mediaTypeKeyword: String? = nil
var trays: [String] = []
var trayChoices: [PPDChoice] = []
var trayKeyword: String? = nil
var qualityChoices: [PPDChoice] = []
var qualityKeyword: String? = nil
var resolutions: [String] = []
var resolutionChoices: [PPDChoice] = []
var colorChoices: [(keyword: String, choice: String)] = []
}
static func discoverPPDOptions(_ ppdText: String) -> PPDOptions {
var out = PPDOptions()
out.pageSizes = optionChoices(in: ppdText, keyword: "PageSize")
let media = discoverOptionChoices(
in: ppdText,
candidateKeywords: ["MediaType", "CNIJMediaType"],
openUITargets: ["media type", "mediatype"]
)
out.mediaTypeKeyword = media.keyword
out.mediaTypeChoices = media.choices
out.mediaTypes = media.choices.map { $0.title }
let trays = discoverOptionChoices(
in: ppdText,
candidateKeywords: ["InputSlot", "EPIJ_FdSo", "CNIJMediaSupply"],
openUITargets: ["paper source", "media source", "input slot", "paper feed", "feed source"]
)
out.trayKeyword = trays.keyword
out.trayChoices = trays.choices
out.trays = trays.choices.map { $0.title }
let quality = discoverOptionChoices(
in: ppdText,
candidateKeywords: ["CNIJPrintQuality", "EPIJ_Qual", "cupsPrintQuality", "PrintQuality", "CNIJPrintMode2", "Quality", "StpQuality"],
openUITargets: ["print quality", "quality"]
)
out.qualityKeyword = quality.keyword
out.qualityChoices = quality.choices
out.resolutionChoices = optionDetails(in: ppdText, keyword: "Resolution")
out.resolutions = out.resolutionChoices.map { $0.name }
// Walk every *OpenUI for Colour/Color keys (SPEC §10.3 generic).
let lines = ppdText.split(whereSeparator: \.isNewline)
var currentKeyword: String?
for line in lines {
let s = String(line)
if s.hasPrefix("*OpenUI") {
if let kw = openUIKeyword(s) { currentKeyword = kw }
} else if s.hasPrefix("*CloseUI") {
currentKeyword = nil
} else if let kw = currentKeyword, looksLikeColorKeyword(kw) {
if let choice = choiceName(s, keyword: kw) {
out.colorChoices.append((kw, choice))
}
}
}
return out
}
/// SPEC §10.3 vendor keys for “off”.
static func vendorColorBypass(make: String, model: String, ppdText: String) -> [String: String] {
let vendor = make.lowercased()
if vendor.contains("epson") {
return ["ColorModel": "RGB", "EPSONColorControls": "Off"]
}
if vendor.contains("canon") {
return ["CNColorMatching": "None"]
}
if vendor.contains("hp") || vendor.contains("hewlett") {
return ["ColorModel": "RGB", "HPColorControl": "Off"]
}
// Generic: prefer any Color/Colour option whose choice is None / Off / No.
let options = discoverPPDOptions(ppdText)
var found: [String: String] = [:]
for pair in options.colorChoices {
if ["none", "off", "no", "nocoloradjustment"].contains(pair.choice.lowercased()) {
found[pair.keyword] = pair.choice
}
}
return found
}
static func applyVendorBypass(_ map: [String: String], to printInfo: NSPrintInfo) {
let dict = printInfo.dictionary()
let settingsKey = NSPrintInfo.AttributeKey(rawValue: "com.apple.print.printSettings")
let nested: NSMutableDictionary
if let existing = dict[settingsKey] as? NSMutableDictionary {
nested = existing
} else {
nested = NSMutableDictionary()
dict[settingsKey] = nested
}
for (k, v) in map {
nested[k] = v
dict[NSPrintInfo.AttributeKey(rawValue: k)] = v
TPLog.info("PPD bypass \(k)=\(v)")
}
}
// MARK: - helpers
private static func destValue(_ dest: cups_dest_t, key: String) -> String? {
guard let cKey = key.cString(using: .utf8) else { return nil }
guard let raw = cupsGetOption(cKey, dest.num_options, dest.options) else { return nil }
return String(cString: raw)
}
private static func splitMakeModel(_ makeModel: String) -> (String, String) {
let parts = makeModel.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
if parts.count == 2 { return (String(parts[0]), String(parts[1])) }
if parts.count == 1 { return (String(parts[0]), "") }
return ("", makeModel)
}
/// Decodes Adobe PPD hex escape sequences (e.g. `<2F>` -> `/`, `<2E>` -> `.`, `<3A>` -> `:`).
static func decodePPDString(_ s: String) -> String {
var result = ""
var idx = s.startIndex
while idx < s.endIndex {
if s[idx] == "<" {
if let closeIdx = s[idx...].firstIndex(of: ">") {
let hexContent = s[s.index(after: idx)..<closeIdx]
let cleanHex = hexContent.filter { $0.isHexDigit }
if !cleanHex.isEmpty && cleanHex.count % 2 == 0 {
var bytes: [UInt8] = []
var hexIdx = cleanHex.startIndex
var valid = true
while hexIdx < cleanHex.endIndex {
let nextHexIdx = cleanHex.index(hexIdx, offsetBy: 2)
let byteStr = cleanHex[hexIdx..<nextHexIdx]
if let b = UInt8(byteStr, radix: 16) {
bytes.append(b)
} else {
valid = false
break
}
hexIdx = nextHexIdx
}
if valid, let decoded = String(bytes: bytes, encoding: .utf8) ?? String(bytes: bytes, encoding: .isoLatin1) {
result.append(decoded)
idx = s.index(after: closeIdx)
continue
}
}
}
}
result.append(s[idx])
idx = s.index(after: idx)
}
return result
}
/// Parses option lines for a keyword, extracting both internal choice code (`name`) and human-readable label (`title`).
static func optionDetails(in ppdText: String, keyword: String) -> [PPDChoice] {
var choices: [PPDChoice] = []
let prefix = "*\(keyword) "
for raw in ppdText.split(whereSeparator: \.isNewline) {
let line = String(raw).trimmingCharacters(in: .whitespaces)
guard line.hasPrefix(prefix) else { continue }
let rest = String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
guard let colonIdx = rest.firstIndex(of: ":") else { continue }
let choicePart = rest[..<colonIdx].trimmingCharacters(in: .whitespaces)
let name: String
let title: String
if let slashIdx = choicePart.firstIndex(of: "/") {
name = String(choicePart[..<slashIdx]).trimmingCharacters(in: .whitespaces)
var rawTitle = String(choicePart[choicePart.index(after: slashIdx)...]).trimmingCharacters(in: .whitespaces)
if rawTitle.hasPrefix("\"") && rawTitle.hasSuffix("\"") && rawTitle.count >= 2 {
rawTitle = String(rawTitle.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces)
}
let decoded = decodePPDString(rawTitle).trimmingCharacters(in: .whitespaces)
title = decoded.isEmpty ? name : decoded
} else {
name = choicePart
title = choicePart
}
guard !name.isEmpty else { continue }
if !choices.contains(where: { $0.name == name }) {
choices.append(PPDChoice(name: name, title: title))
}
}
return choices
}
static func optionChoices(in ppdText: String, keyword: String) -> [String] {
optionDetails(in: ppdText, keyword: keyword).map { $0.name }
}
static func discoverOptionChoices(
in ppdText: String,
candidateKeywords: [String],
openUITargets: [String] = []
) -> (keyword: String?, choices: [PPDChoice]) {
for kw in candidateKeywords {
let choices = optionDetails(in: ppdText, keyword: kw)
if !choices.isEmpty {
return (kw, choices)
}
}
// If not found in candidate keywords, scan *OpenUI for matching translations
let lines = ppdText.split(whereSeparator: \.isNewline)
for raw in lines {
let s = String(raw).trimmingCharacters(in: .whitespaces)
guard s.hasPrefix("*OpenUI") else { continue }
guard let (kw, trans) = openUIKeywordAndTranslation(s) else { continue }
let lowerTrans = trans.lowercased()
let lowerKw = kw.lowercased()
if openUITargets.contains(where: { lowerTrans.contains($0) || lowerKw.contains($0) }) {
let choices = optionDetails(in: ppdText, keyword: kw)
if !choices.isEmpty {
return (kw, choices)
}
}
}
return (nil, [])
}
private static func openUIKeywordAndTranslation(_ line: String) -> (keyword: String, translation: String)? {
// e.g. *OpenUI *PageSize/Media Size: PickOne
guard let star = line.firstIndex(of: "*") else { return nil }
let afterFirstStar = line[star...].dropFirst()
guard let secondStar = afterFirstStar.firstIndex(of: "*") else { return nil }
let rest = afterFirstStar[secondStar...].dropFirst()
guard let colonIdx = rest.firstIndex(of: ":") else { return nil }
let target = rest[..<colonIdx].trimmingCharacters(in: .whitespaces)
if let slashIdx = target.firstIndex(of: "/") {
let kw = String(target[..<slashIdx]).trimmingCharacters(in: .whitespaces)
let trans = decodePPDString(String(target[target.index(after: slashIdx)...]).trimmingCharacters(in: .whitespaces))
return (kw, trans)
} else {
let kw = String(target)
return (kw, kw)
}
}
private static func openUIKeyword(_ line: String) -> String? {
// *OpenUI *PageSize/Media Size: PickOne
guard let star = line.firstIndex(of: "*") else { return nil }
let after = line[star...].dropFirst()
guard let second = after.firstIndex(of: "*") else { return nil }
let rest = after[second...].dropFirst()
var kw = ""
for ch in rest {
if ch == "/" || ch == ":" || ch == " " { break }
kw.append(ch)
}
return kw.isEmpty ? nil : kw
}
private static func choiceName(_ line: String, keyword: String) -> String? {
let prefix = "*\(keyword) "
guard line.hasPrefix(prefix) else { return nil }
let rest = String(line.dropFirst(prefix.count))
return rest.split(whereSeparator: { $0 == "/" || $0 == ":" || $0 == " " }).first.map(String.init)
}
private static func looksLikeColorKeyword(_ kw: String) -> Bool {
let l = kw.lowercased()
return l.contains("color") || l.contains("colour")
}
}
/// SPEC §10.2 — pure function so unit tests do not need libcups.
enum AirPrintDetector {
static func isAirPrint(uri: String, ppdText: String, make: String, model: String) -> Bool {
let u = uri.lowercased()
if u.hasPrefix("apple-airprint://") { return true }
if ppdText.range(of: "*APAirPrint:\\s*True", options: [.regularExpression, .caseInsensitive]) != nil {
return true
}
if make.caseInsensitiveCompare("Apple") == .orderedSame && model.lowercased().contains("airprint") {
return true
}
if u.hasPrefix("ipps://") && ppdText.lowercased().contains("airprint") {
return true
}
return false
}
}