diff --git a/Scripts/build_macos.sh b/Scripts/build_macos.sh old mode 100644 new mode 100755 diff --git a/Scripts/build_universal.sh b/Scripts/build_universal.sh old mode 100644 new mode 100755 diff --git a/Scripts/create_dmg.sh b/Scripts/create_dmg.sh old mode 100644 new mode 100755 diff --git a/Scripts/lib.sh b/Scripts/lib.sh old mode 100644 new mode 100755 diff --git a/Scripts/make_universal.sh b/Scripts/make_universal.sh old mode 100644 new mode 100755 diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh old mode 100644 new mode 100755 diff --git a/Scripts/package_release.sh b/Scripts/package_release.sh old mode 100644 new mode 100755 diff --git a/Scripts/select_xcode.sh b/Scripts/select_xcode.sh old mode 100644 new mode 100755 diff --git a/Scripts/swiftc_build.sh b/Scripts/swiftc_build.sh old mode 100644 new mode 100755 diff --git a/Scripts/test_runner.sh b/Scripts/test_runner.sh new file mode 100755 index 0000000..f988b34 --- /dev/null +++ b/Scripts/test_runner.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Standalone test runner for Command Line Tools (no Xcode.app required) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SDKROOT="${SDKROOT:-$(xcrun --sdk macosx --show-sdk-path)}" +SWIFT="${SWIFT:-$(xcrun --find swiftc)}" +ARCH="$(uname -m)" + +SOURCES=() +while IFS= read -r src; do + if [[ "$(basename "$src")" != "main.swift" ]]; then + SOURCES+=("$src") + fi +done < <(find "$ROOT/Sources" -name '*.swift' | LC_ALL=C sort) + +SOURCES+=("$ROOT/Tests/TestRunnerMain.swift") + +BIN="$(mktemp -t tp-test-XXXXXX)" +trap 'rm -f "$BIN"' EXIT + +"$SWIFT" \ + -sdk "$SDKROOT" \ + -target "${ARCH}-apple-macos10.15" \ + -swift-version 5 \ + -Onone -g \ + -import-objc-header "$ROOT/Bridging-Header.h" \ + -Xcc "-I${SDKROOT}/usr/include" \ + -framework AppKit \ + -framework Foundation \ + -framework CoreGraphics \ + -framework ImageIO \ + -framework ApplicationServices \ + -lcups \ + -o "$BIN" \ + "${SOURCES[@]}" + +"$BIN" diff --git a/Scripts/vendor_bundle.sh b/Scripts/vendor_bundle.sh old mode 100644 new mode 100755 diff --git a/Sources/Printing/CUPSManager.swift b/Sources/Printing/CUPSManager.swift index aadbf56..6c97760 100644 --- a/Sources/Printing/CUPSManager.swift +++ b/Sources/Printing/CUPSManager.swift @@ -4,6 +4,11 @@ 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? @@ -14,9 +19,49 @@ struct PrinterQueue: Equatable { 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 resolutions: [String] 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, + resolutions: [String], + 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.resolutions = resolutions + self.ppdText = ppdText + } } enum CUPSManager { @@ -62,7 +107,11 @@ enum CUPSManager { isAirPrint: air, mediaSizes: options.pageSizes, mediaTypes: options.mediaTypes, + mediaTypeChoices: options.mediaTypeChoices, + mediaTypeKeyword: options.mediaTypeKeyword, trays: options.trays, + trayChoices: options.trayChoices, + trayKeyword: options.trayKeyword, resolutions: options.resolutions, ppdText: ppdText )) @@ -92,7 +141,11 @@ enum CUPSManager { 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 resolutions: [String] = [] var colorChoices: [(keyword: String, choice: String)] = [] } @@ -100,8 +153,25 @@ enum CUPSManager { static func discoverPPDOptions(_ ppdText: String) -> PPDOptions { var out = PPDOptions() out.pageSizes = optionChoices(in: ppdText, keyword: "PageSize") - out.mediaTypes = optionChoices(in: ppdText, keyword: "MediaType") - out.trays = optionChoices(in: ppdText, keyword: "InputSlot") + + 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 } + out.resolutions = optionChoices(in: ppdText, keyword: "Resolution") // Walk every *OpenUI for Colour/Color keys (SPEC §10.3 generic). let lines = ppdText.split(whereSeparator: \.isNewline) @@ -176,22 +246,127 @@ enum CUPSManager { return ("", makeModel) } - static func optionChoices(in ppdText: String, keyword: String) -> [String] { - var choices: [String] = [] + /// 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).. [PPDChoice] { + var choices: [PPDChoice] = [] let prefix = "*\(keyword) " for raw in ppdText.split(whereSeparator: \.isNewline) { - let line = String(raw) + let line = String(raw).trimmingCharacters(in: .whitespaces) guard line.hasPrefix(prefix) else { continue } - let rest = String(line.dropFirst(prefix.count)) - let token = rest.split(whereSeparator: { $0 == "/" || $0 == ":" || $0 == " " }).first - if let token = token { - let name = String(token) - if !choices.contains(name) { choices.append(name) } + let rest = String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) + guard let colonIdx = rest.firstIndex(of: ":") else { continue } + let choicePart = rest[..= 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[.. String? { // *OpenUI *PageSize/Media Size: PickOne guard let star = line.firstIndex(of: "*") else { return nil } diff --git a/Sources/Printing/PrintEngine.swift b/Sources/Printing/PrintEngine.swift index 3dd2877..1acfe45 100644 --- a/Sources/Printing/PrintEngine.swift +++ b/Sources/Printing/PrintEngine.swift @@ -72,8 +72,10 @@ final class PrintEngine { ColorMatching.apply(colorMode, to: info) + var resolvedQueue: PrinterQueue? = nil do { if let queue = try CUPSManager.namedQueue(printerName) { + resolvedQueue = queue let bypass = CUPSManager.vendorColorBypass(make: queue.make, model: queue.model, ppdText: queue.ppdText) CUPSManager.applyVendorBypass(bypass, to: info) } @@ -82,14 +84,28 @@ final class PrintEngine { throw AppError.printer(String(describing: error)) } - applyOptionalPPDKeys(to: info) + applyOptionalPPDKeys(to: info, queue: resolvedQueue) return info } - private func applyOptionalPPDKeys(to info: NSPrintInfo) { + func applyOptionalPPDKeys(to info: NSPrintInfo, queue: PrinterQueue?) { var extras: [String: String] = [:] - if let mediaType = mediaType { extras["MediaType"] = mediaType } - if let paperSource = paperSource { extras["InputSlot"] = paperSource } + if let mediaType = mediaType { + let key = queue?.mediaTypeKeyword ?? "MediaType" + if let match = queue?.mediaTypeChoices.first(where: { $0.name == mediaType || $0.title.caseInsensitiveCompare(mediaType) == .orderedSame }) { + extras[key] = match.name + } else { + extras[key] = mediaType + } + } + if let paperSource = paperSource { + let key = queue?.trayKeyword ?? "InputSlot" + if let match = queue?.trayChoices.first(where: { $0.name == paperSource || $0.title.caseInsensitiveCompare(paperSource) == .orderedSame }) { + extras[key] = match.name + } else if paperSource.lowercased() != "auto" || key == "InputSlot" { + extras[key] = paperSource + } + } 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 a04e36a..94fcc28 100644 --- a/Sources/Views/InspectorView.swift +++ b/Sources/Views/InspectorView.swift @@ -124,8 +124,8 @@ final class InspectorView: NSView { } refreshDependent() select(mediaPop, engine.mediaSize) - if let t = engine.mediaType { select(typePop, t) } - if let t = engine.paperSource { select(trayPop, t) } + if let t = engine.mediaType { selectChoice(typePop, t) } + if let t = engine.paperSource { selectChoice(trayPop, t) } if let r = engine.resolution { select(resolutionPop, r) } scaleSlider.doubleValue = engine.scaling scaleLabel.stringValue = String(format: "%.0f%%", engine.scaling * 100) @@ -143,8 +143,8 @@ final class InspectorView: NSView { func push(into engine: PrintEngine) { if let q = selectedQueue { engine.printerName = q.name } engine.mediaSize = mediaPop.titleOfSelectedItem ?? engine.mediaSize - engine.mediaType = emptyToNil(typePop.titleOfSelectedItem) - engine.paperSource = emptyToNil(trayPop.titleOfSelectedItem) + engine.mediaType = (typePop.selectedItem?.representedObject as? String) ?? emptyToNil(typePop.titleOfSelectedItem) + engine.paperSource = (trayPop.selectedItem?.representedObject as? String) ?? emptyToNil(trayPop.titleOfSelectedItem) engine.resolution = emptyToNil(resolutionPop.titleOfSelectedItem) engine.scaling = scaleSlider.doubleValue engine.centered = centeredCheck.state == .on @@ -184,8 +184,10 @@ final class InspectorView: NSView { private func refreshDependent() { let q = selectedQueue refill(mediaPop, q?.mediaSizes.isEmpty == false ? q!.mediaSizes : Geometry.papers.map { $0.name }) - refill(typePop, q?.mediaTypes ?? ["Plain"]) - refill(trayPop, q?.trays ?? ["Auto"]) + let mediaChoices = (q?.mediaTypeChoices.isEmpty == false) ? q!.mediaTypeChoices : [PPDChoice(name: "Plain", title: "Plain")] + refillChoices(typePop, mediaChoices) + let trayChoices = (q?.trayChoices.isEmpty == false) ? q!.trayChoices : [PPDChoice(name: "Auto", title: "Auto")] + refillChoices(trayPop, trayChoices) refill(resolutionPop, q?.resolutions ?? []) updateAirPrint() } @@ -206,6 +208,32 @@ final class InspectorView: NSView { if let current = current { pop.selectItem(withTitle: current) } } + private func refillChoices(_ pop: NSPopUpButton, _ choices: [PPDChoice]) { + let currentChoice = (pop.selectedItem?.representedObject as? String) ?? pop.titleOfSelectedItem + pop.removeAllItems() + for choice in choices { + pop.addItem(withTitle: choice.title) + pop.lastItem?.representedObject = choice.name + } + if let current = currentChoice { + selectChoice(pop, current) + } + } + + private func selectChoice(_ pop: NSPopUpButton, _ value: String) { + if let item = pop.itemArray.first(where: { ($0.representedObject as? String) == value }) { + pop.select(item) + return + } + if let item = pop.itemArray.first(where: { $0.title == value }) { + pop.select(item) + return + } + pop.addItem(withTitle: value) + pop.lastItem?.representedObject = value + pop.selectItem(withTitle: value) + } + private func select(_ pop: NSPopUpButton, _ title: String) { pop.selectItem(withTitle: title) if pop.indexOfSelectedItem < 0 { diff --git a/TargetPrint.xcodeproj/project.pbxproj b/TargetPrint.xcodeproj/project.pbxproj index f1f764e..2fb80f3 100644 --- a/TargetPrint.xcodeproj/project.pbxproj +++ b/TargetPrint.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 6CC9ACBD3E08666338305F82 /* TargetJobTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 240466A1B8F60F23F51D0A75 /* TargetJobTests.swift */; }; 37A78C4C83D7DB7ED2278F1E /* GeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A6DBFC8F9430081786B20C /* GeometryTests.swift */; }; C723596BBFFD3559C97683FA /* AirPrintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4F405305D559131407E062C /* AirPrintTests.swift */; }; + F812A34B2CA7679A4143984A /* PPDOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E812A34F2CA7679A4143984B /* PPDOptionsTests.swift */; }; 627BBC9C62B0C159B656E415 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9CCD11486CB5CFFBAC839C56 /* Assets.xcassets */; }; 177F2AAF9AFD18C5EAF84162 /* ICCery-logo.svg in Resources */ = {isa = PBXBuildFile; fileRef = FEF2A34F2CA7679A4143984A /* ICCery-logo.svg */; }; FC7AF16B3A0F19874BD56B43 /* app-icon.svg in Resources */ = {isa = PBXBuildFile; fileRef = 0E068D0260049363CDFEBCFD /* app-icon.svg */; }; @@ -59,6 +60,7 @@ 240466A1B8F60F23F51D0A75 /* TargetJobTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetJobTests.swift; sourceTree = ""; }; 79A6DBFC8F9430081786B20C /* GeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeometryTests.swift; sourceTree = ""; }; A4F405305D559131407E062C /* AirPrintTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirPrintTests.swift; sourceTree = ""; }; + E812A34F2CA7679A4143984B /* PPDOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PPDOptionsTests.swift; sourceTree = ""; }; 1507F021100E808DBE2FAA00 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 67629214EEC199752DF68F2F /* TargetPrint.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TargetPrint.entitlements; sourceTree = ""; }; 096EF18F1A2CF6F3694EDF95 /* Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; fileEncoding = 4; path = Bridging-Header.h; sourceTree = ""; }; @@ -159,6 +161,7 @@ 240466A1B8F60F23F51D0A75 /* TargetJobTests.swift */, 79A6DBFC8F9430081786B20C /* GeometryTests.swift */, A4F405305D559131407E062C /* AirPrintTests.swift */, + E812A34F2CA7679A4143984B /* PPDOptionsTests.swift */, ); path = Tests; sourceTree = ""; @@ -325,6 +328,7 @@ 6CC9ACBD3E08666338305F82 /* TargetJobTests.swift in Sources */, 37A78C4C83D7DB7ED2278F1E /* GeometryTests.swift in Sources */, C723596BBFFD3559C97683FA /* AirPrintTests.swift in Sources */, + F812A34B2CA7679A4143984A /* PPDOptionsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Tests/PPDOptionsTests.swift b/Tests/PPDOptionsTests.swift new file mode 100644 index 0000000..fb6c907 --- /dev/null +++ b/Tests/PPDOptionsTests.swift @@ -0,0 +1,225 @@ +import Foundation +#if canImport(XCTest) +import XCTest +#endif +@testable import TargetPrint + +#if canImport(XCTest) +final class PPDOptionsTests: XCTestCase { + + func testDecodePPDString() { + XCTAssertEqual(CUPSManager.decodePPDString("plain"), "plain") + XCTAssertEqual(CUPSManager.decodePPDString("CD<2F>DVD"), "CD/DVD") + XCTAssertEqual(CUPSManager.decodePPDString("Photo<20>Paper<2E> Glossy"), "Photo Paper. Glossy") + XCTAssertEqual(CUPSManager.decodePPDString("Colon<3A>Test"), "Colon:Test") + XCTAssertEqual(CUPSManager.decodePPDString("Unclosed<2F"), "Unclosed<2F") + XCTAssertEqual(CUPSManager.decodePPDString("Invalid"), "Invalid") + } + + func testStandardPPDMediaTypeAndTray() { + let ppd = """ + *OpenUI *PageSize/Media Size: PickOne + *PageSize A4/A4: "" + *PageSize Letter/US Letter: "" + *CloseUI: *PageSize + *OpenUI *MediaType/Media Type: PickOne + *MediaType Plain/Plain Paper: "" + *MediaType Glossy/Photo Glossy: "" + *CloseUI: *MediaType + *OpenUI *InputSlot/Paper Source: PickOne + *InputSlot Auto/Automatic Selection: "" + *InputSlot Upper/Upper Cassette: "" + *CloseUI: *InputSlot + """ + let options = CUPSManager.discoverPPDOptions(ppd) + XCTAssertEqual(options.pageSizes, ["A4", "Letter"]) + XCTAssertEqual(options.mediaTypeKeyword, "MediaType") + XCTAssertEqual(options.mediaTypeChoices, [ + PPDChoice(name: "Plain", title: "Plain Paper"), + PPDChoice(name: "Glossy", title: "Photo Glossy") + ]) + XCTAssertEqual(options.mediaTypes, ["Plain Paper", "Photo Glossy"]) + + XCTAssertEqual(options.trayKeyword, "InputSlot") + XCTAssertEqual(options.trayChoices, [ + PPDChoice(name: "Auto", title: "Automatic Selection"), + PPDChoice(name: "Upper", title: "Upper Cassette") + ]) + XCTAssertEqual(options.trays, ["Automatic Selection", "Upper Cassette"]) + } + + func testEpsonPPDMediaTypeAndTray() { + let ppd = """ + *OpenUI *MediaType/Media Type: PickOne + *MediaType 0/plain papers: "" + *MediaType 13/Epson Premium Glossy: "" + *MediaType 26/CD<2F>DVD: "" + *CloseUI: *MediaType + *OpenUI *EPIJ_FdSo/Paper Source: PickOne + *EPIJ_FdSo 2/Cassette 1: "" + *EPIJ_FdSo 3/Cassette 2: "" + *EPIJ_FdSo 12/Rear Paper Feed Slot: "" + *CloseUI: *EPIJ_FdSo + """ + let options = CUPSManager.discoverPPDOptions(ppd) + XCTAssertEqual(options.mediaTypeKeyword, "MediaType") + XCTAssertEqual(options.mediaTypeChoices, [ + PPDChoice(name: "0", title: "plain papers"), + PPDChoice(name: "13", title: "Epson Premium Glossy"), + PPDChoice(name: "26", title: "CD/DVD") + ]) + XCTAssertEqual(options.mediaTypes, ["plain papers", "Epson Premium Glossy", "CD/DVD"]) + + XCTAssertEqual(options.trayKeyword, "EPIJ_FdSo") + XCTAssertEqual(options.trayChoices, [ + PPDChoice(name: "2", title: "Cassette 1"), + PPDChoice(name: "3", title: "Cassette 2"), + PPDChoice(name: "12", title: "Rear Paper Feed Slot") + ]) + XCTAssertEqual(options.trays, ["Cassette 1", "Cassette 2", "Rear Paper Feed Slot"]) + } + + func testCanonPPDMediaTypeAndTray() { + let ppd = """ + *OpenUI *CNIJMediaType/Media Type: PickOne + *CNIJMediaType 0/Plain Paper: "" + *CNIJMediaType 92/Photo Paper Plus Glossy II: "" + *CloseUI: *CNIJMediaType + *OpenUI *CNIJMediaSupply/Paper Source: PickOne + *CNIJMediaSupply 7/Rear Tray: "" + *CNIJMediaSupply 33/Manual Feed: "" + *CloseUI: *CNIJMediaSupply + """ + let options = CUPSManager.discoverPPDOptions(ppd) + XCTAssertEqual(options.mediaTypeKeyword, "CNIJMediaType") + XCTAssertEqual(options.mediaTypeChoices, [ + PPDChoice(name: "0", title: "Plain Paper"), + PPDChoice(name: "92", title: "Photo Paper Plus Glossy II") + ]) + XCTAssertEqual(options.mediaTypes, ["Plain Paper", "Photo Paper Plus Glossy II"]) + + XCTAssertEqual(options.trayKeyword, "CNIJMediaSupply") + XCTAssertEqual(options.trayChoices, [ + PPDChoice(name: "7", title: "Rear Tray"), + PPDChoice(name: "33", title: "Manual Feed") + ]) + XCTAssertEqual(options.trays, ["Rear Tray", "Manual Feed"]) + } + + func testEmptyPPDYieldsNilKeywordsAndEmptyChoices() { + let options = CUPSManager.discoverPPDOptions("") + XCTAssertNil(options.mediaTypeKeyword) + XCTAssertTrue(options.mediaTypeChoices.isEmpty) + XCTAssertTrue(options.mediaTypes.isEmpty) + XCTAssertNil(options.trayKeyword) + XCTAssertTrue(options.trayChoices.isEmpty) + XCTAssertTrue(options.trays.isEmpty) + } + + func testQuotesStrippedFromTranslation() { + let ppd = """ + *OpenUI *MediaType/Media Type: PickOne + *MediaType Custom/"My Custom Paper": "" + *CloseUI: *MediaType + """ + let options = CUPSManager.discoverPPDOptions(ppd) + XCTAssertEqual(options.mediaTypeChoices, [ + PPDChoice(name: "Custom", title: "My Custom Paper") + ]) + } + + func testChoiceWithoutTranslationDefaultsTitleToName() { + let ppd = """ + *MediaType Plain: "" + """ + let choices = CUPSManager.optionDetails(in: ppd, keyword: "MediaType") + XCTAssertEqual(choices, [ + PPDChoice(name: "Plain", title: "Plain") + ]) + } + + func testPrintEngineDynamicPPDKeysAndResolution() { + let epsonPPD = """ + *OpenUI *MediaType/Media Type: PickOne + *MediaType 0/plain papers: "" + *MediaType 13/Epson Premium Glossy: "" + *CloseUI: *MediaType + *OpenUI *EPIJ_FdSo/Paper Source: PickOne + *EPIJ_FdSo 2/Cassette 1: "" + *EPIJ_FdSo 12/Rear Paper Feed Slot: "" + *CloseUI: *EPIJ_FdSo + """ + let options = CUPSManager.discoverPPDOptions(epsonPPD) + let queue = PrinterQueue( + name: "TestEpson", + displayName: "Test Epson", + uri: "usb://epson", + make: "Epson", + model: "XP-55", + isAirPrint: false, + mediaSizes: ["A4"], + mediaTypes: options.mediaTypes, + mediaTypeChoices: options.mediaTypeChoices, + mediaTypeKeyword: options.mediaTypeKeyword, + trays: options.trays, + trayChoices: options.trayChoices, + trayKeyword: options.trayKeyword, + resolutions: [], + ppdText: epsonPPD + ) + + let engine = PrintEngine() + engine.mediaType = "Epson Premium Glossy" // passed by human display title + engine.paperSource = "Cassette 1" // passed by human display title + + let printInfo = NSPrintInfo() + engine.applyOptionalPPDKeys(to: printInfo, queue: queue) + + let dict = printInfo.dictionary() + let settingsKey = NSPrintInfo.AttributeKey(rawValue: "com.apple.print.printSettings") + let settings = dict[settingsKey] as? NSDictionary + + XCTAssertEqual(settings?["MediaType"] as? String, "13") + XCTAssertEqual(settings?["EPIJ_FdSo"] as? String, "2") + + // Canon test + let canonPPD = """ + *OpenUI *CNIJMediaType/Media Type: PickOne + *CNIJMediaType 92/Photo Paper Plus Glossy II: "" + *CloseUI: *CNIJMediaType + *OpenUI *CNIJMediaSupply/Paper Source: PickOne + *CNIJMediaSupply 7/Rear Tray: "" + *CloseUI: *CNIJMediaSupply + """ + let canonOpts = CUPSManager.discoverPPDOptions(canonPPD) + let canonQueue = PrinterQueue( + name: "TestCanon", + displayName: "Test Canon", + uri: "usb://canon", + make: "Canon", + model: "Pro9500", + isAirPrint: false, + mediaSizes: ["A4"], + mediaTypes: canonOpts.mediaTypes, + mediaTypeChoices: canonOpts.mediaTypeChoices, + mediaTypeKeyword: canonOpts.mediaTypeKeyword, + trays: canonOpts.trays, + trayChoices: canonOpts.trayChoices, + trayKeyword: canonOpts.trayKeyword, + resolutions: [], + ppdText: canonPPD + ) + + let canonEngine = PrintEngine() + canonEngine.mediaType = "Photo Paper Plus Glossy II" + canonEngine.paperSource = "Rear Tray" + + let canonPrintInfo = NSPrintInfo() + canonEngine.applyOptionalPPDKeys(to: canonPrintInfo, queue: canonQueue) + + let canonSettings = canonPrintInfo.dictionary()[settingsKey] as? NSDictionary + XCTAssertEqual(canonSettings?["CNIJMediaType"] as? String, "92") + XCTAssertEqual(canonSettings?["CNIJMediaSupply"] as? String, "7") + } +} +#endif diff --git a/Tests/TestRunnerMain.swift b/Tests/TestRunnerMain.swift new file mode 100644 index 0000000..c76be21 --- /dev/null +++ b/Tests/TestRunnerMain.swift @@ -0,0 +1,280 @@ +import Foundation +import AppKit + +// Standalone CLI test runner for environments without full Xcode / XCTest.framework (e.g. Command Line Tools). + +func expect(_ condition: @autoclosure () -> Bool, _ message: String = "", file: StaticString = #file, line: UInt = #line) { + if !condition() { + fputs("❌ FAIL: \(file):\(line): condition was false. \(message)\n", stderr) + exit(1) + } +} + +func expectEqual(_ actual: T, _ expected: T, _ message: String = "", file: StaticString = #file, line: UInt = #line) { + if actual != expected { + fputs("❌ FAIL: \(file):\(line): expected '\(expected)', got '\(actual)'. \(message)\n", stderr) + exit(1) + } +} + +func expectNil(_ actual: T?, _ message: String = "", file: StaticString = #file, line: UInt = #line) { + if let actual = actual { + fputs("❌ FAIL: \(file):\(line): expected nil, got '\(actual)'. \(message)\n", stderr) + exit(1) + } +} + +@main +struct TestRunner { + static func main() { + print("==> Running TargetPrint test suite...") + + // MARK: - 1. PPD Options & Choice Tests (Issues #1 and #2) + print("--> PPDOptionsTests") + // Hex decoding + expectEqual(CUPSManager.decodePPDString("plain"), "plain") + expectEqual(CUPSManager.decodePPDString("CD<2F>DVD"), "CD/DVD") + expectEqual(CUPSManager.decodePPDString("Photo<20>Paper<2E> Glossy"), "Photo Paper. Glossy") + expectEqual(CUPSManager.decodePPDString("Colon<3A>Test"), "Colon:Test") + expectEqual(CUPSManager.decodePPDString("Unclosed<2F"), "Unclosed<2F") + expectEqual(CUPSManager.decodePPDString("Invalid"), "Invalid") + + // Standard PPD + let stdPPD = """ + *OpenUI *PageSize/Media Size: PickOne + *PageSize A4/A4: "" + *PageSize Letter/US Letter: "" + *CloseUI: *PageSize + *OpenUI *MediaType/Media Type: PickOne + *MediaType Plain/Plain Paper: "" + *MediaType Glossy/Photo Glossy: "" + *CloseUI: *MediaType + *OpenUI *InputSlot/Paper Source: PickOne + *InputSlot Auto/Automatic Selection: "" + *InputSlot Upper/Upper Cassette: "" + *CloseUI: *InputSlot + """ + let stdOpts = CUPSManager.discoverPPDOptions(stdPPD) + expectEqual(stdOpts.pageSizes, ["A4", "Letter"]) + expectEqual(stdOpts.mediaTypeKeyword, "MediaType") + expectEqual(stdOpts.mediaTypeChoices, [ + PPDChoice(name: "Plain", title: "Plain Paper"), + PPDChoice(name: "Glossy", title: "Photo Glossy") + ]) + expectEqual(stdOpts.mediaTypes, ["Plain Paper", "Photo Glossy"]) + expectEqual(stdOpts.trayKeyword, "InputSlot") + expectEqual(stdOpts.trayChoices, [ + PPDChoice(name: "Auto", title: "Automatic Selection"), + PPDChoice(name: "Upper", title: "Upper Cassette") + ]) + expectEqual(stdOpts.trays, ["Automatic Selection", "Upper Cassette"]) + + // Epson PPD + let epsonPPD = """ + *OpenUI *MediaType/Media Type: PickOne + *MediaType 0/plain papers: "" + *MediaType 13/Epson Premium Glossy: "" + *MediaType 26/CD<2F>DVD: "" + *CloseUI: *MediaType + *OpenUI *EPIJ_FdSo/Paper Source: PickOne + *EPIJ_FdSo 2/Cassette 1: "" + *EPIJ_FdSo 3/Cassette 2: "" + *EPIJ_FdSo 12/Rear Paper Feed Slot: "" + *CloseUI: *EPIJ_FdSo + """ + let epsonOpts = CUPSManager.discoverPPDOptions(epsonPPD) + expectEqual(epsonOpts.mediaTypeKeyword, "MediaType") + expectEqual(epsonOpts.mediaTypeChoices, [ + PPDChoice(name: "0", title: "plain papers"), + PPDChoice(name: "13", title: "Epson Premium Glossy"), + PPDChoice(name: "26", title: "CD/DVD") + ]) + expectEqual(epsonOpts.mediaTypes, ["plain papers", "Epson Premium Glossy", "CD/DVD"]) + expectEqual(epsonOpts.trayKeyword, "EPIJ_FdSo") + expectEqual(epsonOpts.trayChoices, [ + PPDChoice(name: "2", title: "Cassette 1"), + PPDChoice(name: "3", title: "Cassette 2"), + PPDChoice(name: "12", title: "Rear Paper Feed Slot") + ]) + expectEqual(epsonOpts.trays, ["Cassette 1", "Cassette 2", "Rear Paper Feed Slot"]) + + // Canon PPD + let canonPPD = """ + *OpenUI *CNIJMediaType/Media Type: PickOne + *CNIJMediaType 0/Plain Paper: "" + *CNIJMediaType 92/Photo Paper Plus Glossy II: "" + *CloseUI: *CNIJMediaType + *OpenUI *CNIJMediaSupply/Paper Source: PickOne + *CNIJMediaSupply 7/Rear Tray: "" + *CNIJMediaSupply 33/Manual Feed: "" + *CloseUI: *CNIJMediaSupply + """ + let canonOpts = CUPSManager.discoverPPDOptions(canonPPD) + expectEqual(canonOpts.mediaTypeKeyword, "CNIJMediaType") + expectEqual(canonOpts.mediaTypeChoices, [ + PPDChoice(name: "0", title: "Plain Paper"), + PPDChoice(name: "92", title: "Photo Paper Plus Glossy II") + ]) + expectEqual(canonOpts.mediaTypes, ["Plain Paper", "Photo Paper Plus Glossy II"]) + expectEqual(canonOpts.trayKeyword, "CNIJMediaSupply") + expectEqual(canonOpts.trayChoices, [ + PPDChoice(name: "7", title: "Rear Tray"), + PPDChoice(name: "33", title: "Manual Feed") + ]) + expectEqual(canonOpts.trays, ["Rear Tray", "Manual Feed"]) + + // Empty PPD + let emptyOpts = CUPSManager.discoverPPDOptions("") + expectNil(emptyOpts.mediaTypeKeyword) + expect(emptyOpts.mediaTypeChoices.isEmpty) + expect(emptyOpts.mediaTypes.isEmpty) + expectNil(emptyOpts.trayKeyword) + expect(emptyOpts.trayChoices.isEmpty) + expect(emptyOpts.trays.isEmpty) + + // Strip quotes & fallback + let quotePPD = """ + *OpenUI *MediaType/Media Type: PickOne + *MediaType Custom/"My Custom Paper": "" + *MediaType Fallback: "" + *CloseUI: *MediaType + """ + let quoteOpts = CUPSManager.discoverPPDOptions(quotePPD) + expectEqual(quoteOpts.mediaTypeChoices, [ + PPDChoice(name: "Custom", title: "My Custom Paper"), + PPDChoice(name: "Fallback", title: "Fallback") + ]) + + // PrintEngine dynamic resolution and vendor keys + let epsonQueue = PrinterQueue( + name: "TestEpson", + displayName: "Test Epson", + uri: "usb://epson", + make: "Epson", + model: "XP-55", + isAirPrint: false, + mediaSizes: ["A4"], + mediaTypes: epsonOpts.mediaTypes, + mediaTypeChoices: epsonOpts.mediaTypeChoices, + mediaTypeKeyword: epsonOpts.mediaTypeKeyword, + trays: epsonOpts.trays, + trayChoices: epsonOpts.trayChoices, + trayKeyword: epsonOpts.trayKeyword, + resolutions: [], + ppdText: epsonPPD + ) + let engine = PrintEngine() + engine.mediaType = "Epson Premium Glossy" + engine.paperSource = "Cassette 1" + let printInfo = NSPrintInfo() + engine.applyOptionalPPDKeys(to: printInfo, queue: epsonQueue) + let settingsKey = NSPrintInfo.AttributeKey(rawValue: "com.apple.print.printSettings") + let settings = printInfo.dictionary()[settingsKey] as? NSDictionary + expectEqual(settings?["MediaType"] as? String, Optional("13")) + expectEqual(settings?["EPIJ_FdSo"] as? String, Optional("2")) + + let canonQueue = PrinterQueue( + name: "TestCanon", + displayName: "Test Canon", + uri: "usb://canon", + make: "Canon", + model: "Pro9500", + isAirPrint: false, + mediaSizes: ["A4"], + mediaTypes: canonOpts.mediaTypes, + mediaTypeChoices: canonOpts.mediaTypeChoices, + mediaTypeKeyword: canonOpts.mediaTypeKeyword, + trays: canonOpts.trays, + trayChoices: canonOpts.trayChoices, + trayKeyword: canonOpts.trayKeyword, + resolutions: [], + ppdText: canonPPD + ) + let canonEngine = PrintEngine() + canonEngine.mediaType = "Photo Paper Plus Glossy II" + canonEngine.paperSource = "Rear Tray" + let canonPrintInfo = NSPrintInfo() + canonEngine.applyOptionalPPDKeys(to: canonPrintInfo, queue: canonQueue) + let canonSettings = canonPrintInfo.dictionary()[settingsKey] as? NSDictionary + expectEqual(canonSettings?["CNIJMediaType"] as? String, Optional("92")) + expectEqual(canonSettings?["CNIJMediaSupply"] as? String, Optional("7")) + + print(" [PASS] PPDOptionsTests") + + // MARK: - 2. AirPrintTests + print("--> AirPrintTests") + expect(AirPrintDetector.isAirPrint(uri: "apple-airprint://Brother%20HL._ipp._tcp.local/", ppdText: "", make: "Brother", model: "HL-L3270CDW")) + expect(AirPrintDetector.isAirPrint(uri: "ipps://living-room.local/ipp/print", ppdText: "*Manufacturer: Apple\n*APAirPrint: True\n", make: "Apple", model: "AirPrint Printer")) + expect(AirPrintDetector.isAirPrint(uri: "dnssd://Foo._ipp._tcp.local/", ppdText: "", make: "Apple", model: "Living Room AirPrint")) + expect(!AirPrintDetector.isAirPrint(uri: "usb://EPSON/XP-55%20Series?serial=X5R001", ppdText: "*Manufacturer: Epson\n*EPSONColorControls: Off\n", make: "Epson", model: "XP-55")) + expect(!AirPrintDetector.isAirPrint(uri: "ipps://office-printer.local/ipp/print", ppdText: "*Manufacturer: HP\n*HPColorControl: Off\n", make: "HP", model: "OfficeJet Pro 9010")) + + let epsonBypass = CUPSManager.vendorColorBypass(make: "Epson", model: "XP-55", ppdText: "") + expectEqual(epsonBypass["ColorModel"], "RGB") + expectEqual(epsonBypass["EPSONColorControls"], "Off") + + let canonBypass = CUPSManager.vendorColorBypass(make: "Canon", model: "PRO-100", ppdText: "") + expectEqual(canonBypass["CNColorMatching"], "None") + + let hpBypass = CUPSManager.vendorColorBypass(make: "HP", model: "9010", ppdText: "") + expectEqual(hpBypass["HPColorControl"], "Off") + + print(" [PASS] AirPrintTests") + + // MARK: - 3. GeometryTests + print("--> GeometryTests") + let w = Geometry.physicalInches(pixels: 2400, dpi: 300) + let h = Geometry.physicalInches(pixels: 3000, dpi: 300) + expectEqual(w, 8.0) + expectEqual(h, 10.0) + expect(Geometry.geometricAccuracyPass(expectedWidthIn: 8, expectedHeightIn: 10, actualWidthIn: w, actualHeightIn: h)) + expectEqual(Geometry.physicalInches(pixels: 720, dpi: 0), 10.0) + expectEqual(Geometry.physicalInches(pixels: 720, dpi: -3), 10.0) + expectEqual(Geometry.physicalInches(pixels: 72, dpi: 72), 1.0) + let letter = Geometry.paper(named: "Letter") + expect(abs(letter.widthPt - 612.0) < 0.01) + expect(abs(letter.heightPt - 792.0) < 0.01) + print(" [PASS] GeometryTests") + + // MARK: - 4. TargetJobTests + print("--> TargetJobTests") + let validJSON = """ + { + "version": 1, + "jobTitle": "Epson_XP55_IlfordLustre_Target_P1-2", + "files": ["/tmp/a.tif", "/tmp/b.tif"], + "printSettings": { + "printerName": "EPSON_XP_55_Series", + "mediaSize": "A4", + "mediaType": "PremiumGlossy", + "paperSource": "Auto", + "resolution": "5760x1440dpi", + "scaling": 1.0, + "centered": true, + "forceUnmanagedColor": true + }, + "uiPolicy": { + "lockColorManagement": true, + "allowBasicDriverChanges": true + }, + "unknownKey": "ignored" + } + """ + do { + let parsed = try TargetJobParser.parse(text: validJSON) + expectEqual(parsed.version, 1) + expectEqual(parsed.jobTitle, "Epson_XP55_IlfordLustre_Target_P1-2") + expectEqual(parsed.files.count, 2) + expectEqual(parsed.printSettings.mediaType, "PremiumGlossy") + let serialized = try TargetJobParser.serialize(parsed) + let again = try TargetJobParser.parse(data: serialized) + expectEqual(parsed, again) + } catch { + fputs("TargetJob parsing error: \(error)\n", stderr) + exit(1) + } + print(" [PASS] TargetJobTests") + + print("==> All test suites passed successfully! 🎉") + } +}