diff --git a/Sources/Controllers/PreviewWindowController.swift b/Sources/Controllers/PreviewWindowController.swift index 716d366..ecb4045 100644 --- a/Sources/Controllers/PreviewWindowController.swift +++ b/Sources/Controllers/PreviewWindowController.swift @@ -103,6 +103,7 @@ final class PreviewWindowController: NSWindowController { engine.mediaSize = parsed.printSettings.mediaSize engine.mediaType = parsed.printSettings.mediaType engine.paperSource = parsed.printSettings.paperSource + engine.printQuality = parsed.printSettings.printQuality engine.resolution = parsed.printSettings.resolution engine.scaling = parsed.printSettings.scaling engine.centered = parsed.printSettings.centered diff --git a/Sources/Models/TargetJob.swift b/Sources/Models/TargetJob.swift index bb6b6e7..7926a9d 100644 --- a/Sources/Models/TargetJob.swift +++ b/Sources/Models/TargetJob.swift @@ -8,9 +8,32 @@ struct PrintSettings: Equatable { var mediaType: String? var paperSource: String? var resolution: String? + var printQuality: String? var scaling: Double var centered: Bool var forceUnmanagedColor: Bool + + init( + printerName: String, + mediaSize: String, + mediaType: String? = nil, + paperSource: String? = nil, + resolution: String? = nil, + printQuality: String? = nil, + scaling: Double, + centered: Bool, + forceUnmanagedColor: Bool + ) { + self.printerName = printerName + self.mediaSize = mediaSize + self.mediaType = mediaType + self.paperSource = paperSource + self.resolution = resolution + self.printQuality = printQuality + self.scaling = scaling + self.centered = centered + self.forceUnmanagedColor = forceUnmanagedColor + } } struct UIPolicy: Equatable { @@ -117,6 +140,7 @@ enum TargetJobParser { mediaType: stringValue(obj["mediaType"]), paperSource: stringValue(obj["paperSource"]), resolution: stringValue(obj["resolution"]), + printQuality: stringValue(obj["printQuality"]), scaling: scaling, centered: centered, forceUnmanagedColor: force @@ -144,6 +168,7 @@ enum TargetJobParser { if let v = job.printSettings.mediaType { ps["mediaType"] = v } if let v = job.printSettings.paperSource { ps["paperSource"] = v } if let v = job.printSettings.resolution { ps["resolution"] = v } + if let v = job.printSettings.printQuality { ps["printQuality"] = v } let obj: [String: Any] = [ "version": 1, "jobTitle": job.jobTitle, diff --git a/Sources/Printing/CUPSManager.swift b/Sources/Printing/CUPSManager.swift index 6c97760..1a30b8a 100644 --- a/Sources/Printing/CUPSManager.swift +++ b/Sources/Printing/CUPSManager.swift @@ -24,7 +24,10 @@ struct PrinterQueue: Equatable { 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( @@ -42,7 +45,10 @@ struct PrinterQueue: Equatable { trays: [String], trayChoices: [PPDChoice] = [], trayKeyword: String? = nil, + qualityChoices: [PPDChoice] = [], + qualityKeyword: String? = nil, resolutions: [String], + resolutionChoices: [PPDChoice] = [], ppdText: String ) { self.name = name @@ -59,7 +65,10 @@ struct PrinterQueue: Equatable { self.trays = trays self.trayChoices = trayChoices self.trayKeyword = trayKeyword + self.qualityChoices = qualityChoices + self.qualityKeyword = qualityKeyword self.resolutions = resolutions + self.resolutionChoices = resolutionChoices self.ppdText = ppdText } } @@ -112,7 +121,10 @@ enum CUPSManager { trays: options.trays, trayChoices: options.trayChoices, trayKeyword: options.trayKeyword, + qualityChoices: options.qualityChoices, + qualityKeyword: options.qualityKeyword, resolutions: options.resolutions, + resolutionChoices: options.resolutionChoices, ppdText: ppdText )) } @@ -146,7 +158,10 @@ enum CUPSManager { 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)] = [] } @@ -172,7 +187,16 @@ enum CUPSManager { out.trayChoices = trays.choices out.trays = trays.choices.map { $0.title } - out.resolutions = optionChoices(in: ppdText, keyword: "Resolution") + 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? diff --git a/Sources/Printing/PrintEngine.swift b/Sources/Printing/PrintEngine.swift index 1acfe45..371e2b3 100644 --- a/Sources/Printing/PrintEngine.swift +++ b/Sources/Printing/PrintEngine.swift @@ -10,6 +10,7 @@ final class PrintEngine { var mediaSize: String var mediaType: String? var paperSource: String? + var printQuality: String? var resolution: String? var scaling: Double var centered: Bool @@ -24,6 +25,7 @@ final class PrintEngine { mediaSize = job.printSettings.mediaSize mediaType = job.printSettings.mediaType paperSource = job.printSettings.paperSource + printQuality = job.printSettings.printQuality resolution = job.printSettings.resolution scaling = job.printSettings.scaling centered = job.printSettings.centered @@ -39,6 +41,7 @@ final class PrintEngine { mediaSize = "A4" mediaType = nil paperSource = nil + printQuality = nil resolution = nil scaling = 1.0 centered = true @@ -48,6 +51,7 @@ final class PrintEngine { allowBasicDriverChanges = true } + /// Configures `NSPrintInfo` for `NSPrintOperation` (SPEC §4.4, §10.3). func makePrintInfo() throws -> NSPrintInfo { let info = NSPrintInfo.shared.copy() as! NSPrintInfo info.jobDisposition = .spool @@ -106,6 +110,44 @@ final class PrintEngine { extras[key] = paperSource } } + if let printQuality = printQuality { + let key = queue?.qualityKeyword ?? "PrintQuality" + let resolvedCode: String + if let match = queue?.qualityChoices.first(where: { $0.name == printQuality || $0.title.caseInsensitiveCompare(printQuality) == .orderedSame }) { + resolvedCode = match.name + } else { + resolvedCode = printQuality + } + + if key == "CNIJPrintQuality" { + switch resolvedCode { + case "0": // Super Fine (notch 5 — Custom mode) + extras["CNIJPrintQuality"] = "0" + extras["CNIJPrintMode2"] = "5" + extras["CNIJPQualitySlider"] = "5" + case "5": // Fine (notch 4 — High preset) + extras["CNIJPrintQuality"] = "5" + extras["CNIJPrintMode2"] = "1" + extras["CNIJPQualitySlider"] = "4" + case "10": // Normal(Fine) (notch 3 — Standard preset) + extras["CNIJPrintQuality"] = "10" + extras["CNIJPrintMode2"] = "2" + extras["CNIJPQualitySlider"] = "3" + case "15": // Normal(Fast) (notch 2) + extras["CNIJPrintQuality"] = "15" + extras["CNIJPrintMode2"] = "5" + extras["CNIJPQualitySlider"] = "2" + case "20": // Fast (notch 1 — Fast preset) + extras["CNIJPrintQuality"] = "20" + extras["CNIJPrintMode2"] = "3" + extras["CNIJPQualitySlider"] = "1" + default: + extras["CNIJPrintQuality"] = resolvedCode + } + } else if resolvedCode.lowercased() != "auto" || key == "cupsPrintQuality" { + extras[key] = resolvedCode + } + } if let resolution = resolution { extras["Resolution"] = resolution } if extras.isEmpty { return } CUPSManager.applyVendorBypass(extras, to: info) diff --git a/Sources/Views/InspectorView.swift b/Sources/Views/InspectorView.swift index 94fcc28..1fa4e07 100644 --- a/Sources/Views/InspectorView.swift +++ b/Sources/Views/InspectorView.swift @@ -22,6 +22,7 @@ final class InspectorView: NSView { let mediaPop = NSPopUpButton() let typePop = NSPopUpButton() let trayPop = NSPopUpButton() + let qualityPop = NSPopUpButton() let resolutionPop = NSPopUpButton() let scaleSlider = NSSlider() let scaleLabel = NSTextField(labelWithString: "100%") @@ -46,6 +47,7 @@ final class InspectorView: NSView { mediaPop.target = self; mediaPop.action = #selector(changed) typePop.target = self; typePop.action = #selector(changed) trayPop.target = self; trayPop.action = #selector(changed) + qualityPop.target = self; qualityPop.action = #selector(changed) resolutionPop.target = self; resolutionPop.action = #selector(changed) scaleSlider.minValue = 0.5 scaleSlider.maxValue = 1.5 @@ -79,6 +81,7 @@ final class InspectorView: NSView { section("Media size", mediaPop), section("Media type", typePop), section("Tray", trayPop), + section("Print quality", qualityPop), section("Resolution", resolutionPop), section("Scale", scaleRow()), centeredCheck, @@ -101,6 +104,7 @@ final class InspectorView: NSView { mediaPop.widthAnchor.constraint(equalTo: stack.widthAnchor), typePop.widthAnchor.constraint(equalTo: stack.widthAnchor), trayPop.widthAnchor.constraint(equalTo: stack.widthAnchor), + qualityPop.widthAnchor.constraint(equalTo: stack.widthAnchor), resolutionPop.widthAnchor.constraint(equalTo: stack.widthAnchor), colorSeg.widthAnchor.constraint(equalTo: stack.widthAnchor), ]) @@ -126,6 +130,7 @@ final class InspectorView: NSView { select(mediaPop, engine.mediaSize) if let t = engine.mediaType { selectChoice(typePop, t) } if let t = engine.paperSource { selectChoice(trayPop, t) } + if let q = engine.printQuality { selectChoice(qualityPop, q) } if let r = engine.resolution { select(resolutionPop, r) } scaleSlider.doubleValue = engine.scaling scaleLabel.stringValue = String(format: "%.0f%%", engine.scaling * 100) @@ -145,6 +150,7 @@ final class InspectorView: NSView { engine.mediaSize = mediaPop.titleOfSelectedItem ?? engine.mediaSize engine.mediaType = (typePop.selectedItem?.representedObject as? String) ?? emptyToNil(typePop.titleOfSelectedItem) engine.paperSource = (trayPop.selectedItem?.representedObject as? String) ?? emptyToNil(trayPop.titleOfSelectedItem) + engine.printQuality = (qualityPop.selectedItem?.representedObject as? String) ?? emptyToNil(qualityPop.titleOfSelectedItem) engine.resolution = emptyToNil(resolutionPop.titleOfSelectedItem) engine.scaling = scaleSlider.doubleValue engine.centered = centeredCheck.state == .on @@ -161,6 +167,7 @@ final class InspectorView: NSView { mediaPop.isEnabled = mediaEnabled typePop.isEnabled = mediaEnabled trayPop.isEnabled = mediaEnabled + qualityPop.isEnabled = mediaEnabled && (selectedQueue?.qualityChoices.isEmpty == false) resolutionPop.isEnabled = !jobLocked landscapeCheck.isEnabled = mediaEnabled scaleSlider.isEnabled = !jobLocked @@ -188,6 +195,9 @@ final class InspectorView: NSView { refillChoices(typePop, mediaChoices) let trayChoices = (q?.trayChoices.isEmpty == false) ? q!.trayChoices : [PPDChoice(name: "Auto", title: "Auto")] refillChoices(trayPop, trayChoices) + let qualityChoices = (q?.qualityChoices.isEmpty == false) ? q!.qualityChoices : [PPDChoice(name: "Auto", title: "Auto")] + refillChoices(qualityPop, qualityChoices) + qualityPop.isEnabled = allowBasicDriverChanges && !jobLocked && (q?.qualityChoices.isEmpty == false) refill(resolutionPop, q?.resolutions ?? []) updateAirPrint() } @@ -215,7 +225,8 @@ final class InspectorView: NSView { pop.addItem(withTitle: choice.title) pop.lastItem?.representedObject = choice.name } - if let current = currentChoice { + if let current = currentChoice, + choices.contains(where: { $0.name == current || $0.title == current }) { selectChoice(pop, current) } } diff --git a/Tests/PPDOptionsTests.swift b/Tests/PPDOptionsTests.swift index fb6c907..8b46405 100644 --- a/Tests/PPDOptionsTests.swift +++ b/Tests/PPDOptionsTests.swift @@ -220,6 +220,106 @@ final class PPDOptionsTests: XCTestCase { let canonSettings = canonPrintInfo.dictionary()[settingsKey] as? NSDictionary XCTAssertEqual(canonSettings?["CNIJMediaType"] as? String, "92") XCTAssertEqual(canonSettings?["CNIJMediaSupply"] as? String, "7") + + // Canon 5-notch Custom quality tests + let canonQualityPPD = """ + *OpenUI *CNIJPrintQuality/Print Quality: PickOne + *CNIJPrintQuality 0/Super Fine: "" + *CNIJPrintQuality 5/Fine: "" + *CNIJPrintQuality 10/Normal(Fine): "" + *CNIJPrintQuality 15/Normal(Fast): "" + *CNIJPrintQuality 20/Fast: "" + *CloseUI: *CNIJPrintQuality + """ + let canonQOpts = CUPSManager.discoverPPDOptions(canonQualityPPD) + XCTAssertEqual(canonQOpts.qualityKeyword, "CNIJPrintQuality") + XCTAssertEqual(canonQOpts.qualityChoices, [ + PPDChoice(name: "0", title: "Super Fine"), + PPDChoice(name: "5", title: "Fine"), + PPDChoice(name: "10", title: "Normal(Fine)"), + PPDChoice(name: "15", title: "Normal(Fast)"), + PPDChoice(name: "20", title: "Fast") + ]) + + let canonQQueue = PrinterQueue( + name: "TestCanonQ", + displayName: "Test Canon Quality", + uri: "usb://canon", + make: "Canon", + model: "Pro9500", + isAirPrint: false, + mediaSizes: ["A4"], + mediaTypes: [], + mediaTypeChoices: [], + mediaTypeKeyword: nil, + trays: [], + trayChoices: [], + trayKeyword: nil, + qualityChoices: canonQOpts.qualityChoices, + qualityKeyword: canonQOpts.qualityKeyword, + resolutions: [], + ppdText: canonQualityPPD + ) + + // Super Fine: custom mode 5, slider notch 5, quality 0 + let superFineEngine = PrintEngine() + superFineEngine.printQuality = "Super Fine" + let superFineInfo = NSPrintInfo() + superFineEngine.applyOptionalPPDKeys(to: superFineInfo, queue: canonQQueue) + let superFineSettings = superFineInfo.dictionary()[settingsKey] as? NSDictionary + XCTAssertEqual(superFineSettings?["CNIJPrintQuality"] as? String, "0") + XCTAssertEqual(superFineSettings?["CNIJPrintMode2"] as? String, "5") + XCTAssertEqual(superFineSettings?["CNIJPQualitySlider"] as? String, "5") + + // Fine: preset mode 1, slider notch 4, quality 5 + let fineEngine = PrintEngine() + fineEngine.printQuality = "Fine" + let fineInfo = NSPrintInfo() + fineEngine.applyOptionalPPDKeys(to: fineInfo, queue: canonQQueue) + let fineSettings = fineInfo.dictionary()[settingsKey] as? NSDictionary + XCTAssertEqual(fineSettings?["CNIJPrintQuality"] as? String, "5") + XCTAssertEqual(fineSettings?["CNIJPrintMode2"] as? String, "1") + XCTAssertEqual(fineSettings?["CNIJPQualitySlider"] as? String, "4") + + // Epson quality test + let epsonQualityPPD = """ + *OpenUI *EPIJ_Qual/Quality: PickOne + *EPIJ_Qual 307/Best Quality: "" + *EPIJ_Qual 305/Quality: "" + *EPIJ_Qual 304/Fine: "" + *CloseUI: *EPIJ_Qual + """ + let epsonQOpts = CUPSManager.discoverPPDOptions(epsonQualityPPD) + XCTAssertEqual(epsonQOpts.qualityKeyword, "EPIJ_Qual") + XCTAssertEqual(epsonQOpts.qualityChoices.count, 3) + XCTAssertEqual(epsonQOpts.qualityChoices.first?.name, "307") + XCTAssertEqual(epsonQOpts.qualityChoices.first?.title, "Best Quality") + + let epsonQQueue = PrinterQueue( + name: "TestEpsonQ", + displayName: "Test Epson Quality", + uri: "usb://epson", + make: "Epson", + model: "XP-55", + isAirPrint: false, + mediaSizes: ["A4"], + mediaTypes: [], + mediaTypeChoices: [], + mediaTypeKeyword: nil, + trays: [], + trayChoices: [], + trayKeyword: nil, + qualityChoices: epsonQOpts.qualityChoices, + qualityKeyword: epsonQOpts.qualityKeyword, + resolutions: [], + ppdText: epsonQualityPPD + ) + let epsonQEngine = PrintEngine() + epsonQEngine.printQuality = "Best Quality" + let epsonQInfo = NSPrintInfo() + epsonQEngine.applyOptionalPPDKeys(to: epsonQInfo, queue: epsonQQueue) + let epsonQSettings = epsonQInfo.dictionary()[settingsKey] as? NSDictionary + XCTAssertEqual(epsonQSettings?["EPIJ_Qual"] as? String, "307") } } #endif diff --git a/Tests/TestRunnerMain.swift b/Tests/TestRunnerMain.swift index c76be21..4a11cd5 100644 --- a/Tests/TestRunnerMain.swift +++ b/Tests/TestRunnerMain.swift @@ -199,6 +199,106 @@ struct TestRunner { expectEqual(canonSettings?["CNIJMediaType"] as? String, Optional("92")) expectEqual(canonSettings?["CNIJMediaSupply"] as? String, Optional("7")) + // Canon 5-notch Custom quality tests + let canonQualityPPD = """ + *OpenUI *CNIJPrintQuality/Print Quality: PickOne + *CNIJPrintQuality 0/Super Fine: "" + *CNIJPrintQuality 5/Fine: "" + *CNIJPrintQuality 10/Normal(Fine): "" + *CNIJPrintQuality 15/Normal(Fast): "" + *CNIJPrintQuality 20/Fast: "" + *CloseUI: *CNIJPrintQuality + """ + let canonQOpts = CUPSManager.discoverPPDOptions(canonQualityPPD) + expectEqual(canonQOpts.qualityKeyword, "CNIJPrintQuality") + expectEqual(canonQOpts.qualityChoices, [ + PPDChoice(name: "0", title: "Super Fine"), + PPDChoice(name: "5", title: "Fine"), + PPDChoice(name: "10", title: "Normal(Fine)"), + PPDChoice(name: "15", title: "Normal(Fast)"), + PPDChoice(name: "20", title: "Fast") + ]) + + let canonQQueue = PrinterQueue( + name: "TestCanonQ", + displayName: "Test Canon Quality", + uri: "usb://canon", + make: "Canon", + model: "Pro9500", + isAirPrint: false, + mediaSizes: ["A4"], + mediaTypes: [], + mediaTypeChoices: [], + mediaTypeKeyword: nil, + trays: [], + trayChoices: [], + trayKeyword: nil, + qualityChoices: canonQOpts.qualityChoices, + qualityKeyword: canonQOpts.qualityKeyword, + resolutions: [], + ppdText: canonQualityPPD + ) + + // Super Fine: custom mode 5, slider notch 5, quality 0 + let superFineEngine = PrintEngine() + superFineEngine.printQuality = "Super Fine" + let superFineInfo = NSPrintInfo() + superFineEngine.applyOptionalPPDKeys(to: superFineInfo, queue: canonQQueue) + let superFineSettings = superFineInfo.dictionary()[settingsKey] as? NSDictionary + expectEqual(superFineSettings?["CNIJPrintQuality"] as? String, Optional("0")) + expectEqual(superFineSettings?["CNIJPrintMode2"] as? String, Optional("5")) + expectEqual(superFineSettings?["CNIJPQualitySlider"] as? String, Optional("5")) + + // Fine: preset mode 1, slider notch 4, quality 5 + let fineEngine = PrintEngine() + fineEngine.printQuality = "Fine" + let fineInfo = NSPrintInfo() + fineEngine.applyOptionalPPDKeys(to: fineInfo, queue: canonQQueue) + let fineSettings = fineInfo.dictionary()[settingsKey] as? NSDictionary + expectEqual(fineSettings?["CNIJPrintQuality"] as? String, Optional("5")) + expectEqual(fineSettings?["CNIJPrintMode2"] as? String, Optional("1")) + expectEqual(fineSettings?["CNIJPQualitySlider"] as? String, Optional("4")) + + // Epson quality test + let epsonQualityPPD = """ + *OpenUI *EPIJ_Qual/Quality: PickOne + *EPIJ_Qual 307/Best Quality: "" + *EPIJ_Qual 305/Quality: "" + *EPIJ_Qual 304/Fine: "" + *CloseUI: *EPIJ_Qual + """ + let epsonQOpts = CUPSManager.discoverPPDOptions(epsonQualityPPD) + expectEqual(epsonQOpts.qualityKeyword, "EPIJ_Qual") + expectEqual(epsonQOpts.qualityChoices.count, 3) + expectEqual(epsonQOpts.qualityChoices.first?.name, "307") + expectEqual(epsonQOpts.qualityChoices.first?.title, "Best Quality") + + let epsonQQueue = PrinterQueue( + name: "TestEpsonQ", + displayName: "Test Epson Quality", + uri: "usb://epson", + make: "Epson", + model: "XP-55", + isAirPrint: false, + mediaSizes: ["A4"], + mediaTypes: [], + mediaTypeChoices: [], + mediaTypeKeyword: nil, + trays: [], + trayChoices: [], + trayKeyword: nil, + qualityChoices: epsonQOpts.qualityChoices, + qualityKeyword: epsonQOpts.qualityKeyword, + resolutions: [], + ppdText: epsonQualityPPD + ) + let epsonQEngine = PrintEngine() + epsonQEngine.printQuality = "Best Quality" + let epsonQInfo = NSPrintInfo() + epsonQEngine.applyOptionalPPDKeys(to: epsonQInfo, queue: epsonQQueue) + let epsonQSettings = epsonQInfo.dictionary()[settingsKey] as? NSDictionary + expectEqual(epsonQSettings?["EPIJ_Qual"] as? String, Optional("307")) + print(" [PASS] PPDOptionsTests") // MARK: - 2. AirPrintTests @@ -249,6 +349,7 @@ struct TestRunner { "mediaType": "PremiumGlossy", "paperSource": "Auto", "resolution": "5760x1440dpi", + "printQuality": "Super Fine", "scaling": 1.0, "centered": true, "forceUnmanagedColor": true @@ -266,9 +367,11 @@ struct TestRunner { expectEqual(parsed.jobTitle, "Epson_XP55_IlfordLustre_Target_P1-2") expectEqual(parsed.files.count, 2) expectEqual(parsed.printSettings.mediaType, "PremiumGlossy") + expectEqual(parsed.printSettings.printQuality, "Super Fine") let serialized = try TargetJobParser.serialize(parsed) let again = try TargetJobParser.parse(data: serialized) expectEqual(parsed, again) + expectEqual(again.printSettings.printQuality, "Super Fine") } catch { fputs("TargetJob parsing error: \(error)\n", stderr) exit(1)