fix: resolve PPD media type and tray option discovery and selection (#1, #2) #3

Merged
gronod merged 1 commits from fix/ppd-media-type-and-tray-options into development 2026-09-07 22:48:18 +01:00
17 changed files with 786 additions and 20 deletions
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
+38
View File
@@ -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"
Regular → Executable
View File
+185 -10
View File
@@ -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)..<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)
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[..<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 }
+20 -4
View File
@@ -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)
+34 -6
View File
@@ -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 {
+4
View File
@@ -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 = "<group>"; };
79A6DBFC8F9430081786B20C /* GeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeometryTests.swift; sourceTree = "<group>"; };
A4F405305D559131407E062C /* AirPrintTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirPrintTests.swift; sourceTree = "<group>"; };
E812A34F2CA7679A4143984B /* PPDOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PPDOptionsTests.swift; sourceTree = "<group>"; };
1507F021100E808DBE2FAA00 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
67629214EEC199752DF68F2F /* TargetPrint.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TargetPrint.entitlements; sourceTree = "<group>"; };
096EF18F1A2CF6F3694EDF95 /* Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; fileEncoding = 4; path = Bridging-Header.h; sourceTree = "<group>"; };
@@ -159,6 +161,7 @@
240466A1B8F60F23F51D0A75 /* TargetJobTests.swift */,
79A6DBFC8F9430081786B20C /* GeometryTests.swift */,
A4F405305D559131407E062C /* AirPrintTests.swift */,
E812A34F2CA7679A4143984B /* PPDOptionsTests.swift */,
);
path = Tests;
sourceTree = "<group>";
@@ -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;
};
+225
View File
@@ -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<ZZ>"), "Invalid<ZZ>")
}
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
+280
View File
@@ -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<T: Equatable>(_ 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<T>(_ 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<ZZ>"), "Invalid<ZZ>")
// 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! 🎉")
}
}