Files
cupsadmin/Sources/CupsKit/PPD.swift
2026-09-16 21:04:02 -05:00

201 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
/// PPD parsing for what `lpoptions -l` and the CUPS web UI's "Set Default Options" page showed:
/// UI options with their groups, UI types, choices, defaults and custom-value parameters.
public struct PPD {
public enum UIType: String {
case pickOne = "PickOne"
case pickMany = "PickMany"
case boolean = "Boolean"
case unknown
}
public struct Choice: Hashable {
public let keyword: String
/// Translation text after the slash, or the keyword when there is none.
public let text: String
public init(keyword: String, text: String) {
self.keyword = keyword
self.text = text
}
}
/// One `*ParamCustomX Name/Text: order type min max` line.
public struct CustomParameter: Hashable {
public let name: String
public let text: String
public let order: Int
/// PPD type as written: string, passcode, password, int, real, points, curve, invcurve.
public let type: String
public let minimum: String
public let maximum: String
}
public struct Option {
public let keyword: String
public let text: String
public var choices: [String] = []
public var defaultChoice: String?
/// "Custom.INTEGER", "Custom.WIDTHxHEIGHT", ... when the PPD allows a custom value.
public var customChoice: String?
/// `*OpenGroup` label (with " / subgroup" when nested); "General" for ungrouped options.
public var group: String = PPD.generalGroup
public var uiType: UIType = .unknown
public var choiceList: [Choice] = []
/// Parameters from `*ParamCustomX`, in order; empty unless the PPD has `*CustomX True`.
public var customParameters: [CustomParameter] = []
public var isJCL = false
}
/// Where libcups and the CUPS web UI put options that aren't inside an `*OpenGroup`.
public static let generalGroup = "General"
public private(set) var options: [Option] = []
/// Group names in first-appearance order ("General" where the first ungrouped option appears).
public private(set) var groups: [String] = []
/// Top-level `*Key: value` attributes (NickName, Manufacturer, ...), first occurrence wins.
public private(set) var attributes: [String: String] = [:]
/// Every `*cupsFilter` / `*cupsFilter2` line, in order, with the quotes removed.
public private(set) var filterLines: [(keyword: String, value: String)] = []
public init(text: String) {
var defaults: [String: String] = [:]
var customKeywords: Set<String> = []
var customParams: [String: [CustomParameter]] = [:]
var groupStack: [String] = []
var current: Option?
var inQuotedValue = false
for rawLine in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) {
let line = String(rawLine)
// Multi-line quoted values (invocation code, etc.) — skip until the closing quote.
if inQuotedValue {
if line.contains("\"") { inQuotedValue = false }
continue
}
guard line.hasPrefix("*"), !line.hasPrefix("*%"), let colon = line.firstIndex(of: ":") else { continue }
let key = line[line.index(after: line.startIndex) ..< colon] // "PageSize Letter/US Letter"
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
if value.hasPrefix("\""), value.filter({ $0 == "\"" }).count == 1 { inQuotedValue = true }
let parts = key.split(separator: " ", maxSplits: 1)
let mainKey = String(parts[0])
switch mainKey {
case "OpenGroup", "OpenSubGroup":
// *OpenGroup: InstallableOptions/Installable Options
let (name, label) = Self.splitText(value)
groupStack.append(label ?? name)
case "CloseGroup", "CloseSubGroup":
_ = groupStack.popLast()
case "OpenUI", "JCLOpenUI":
// *OpenUI *PageSize/Media Size: PickOne
guard parts.count == 2 else { continue }
let spec = parts[1].trimmingCharacters(in: .whitespaces).drop { $0 == "*" }
let nameAndText = spec.split(separator: "/", maxSplits: 1)
let keyword = String(nameAndText[0])
let text = nameAndText.count > 1 ? String(nameAndText[1]) : Self.standardText[keyword] ?? keyword
current = Option(keyword: keyword, text: text)
current!.group = groupStack.isEmpty ? Self.generalGroup : groupStack.joined(separator: " / ")
current!.uiType = UIType(rawValue: value) ?? .unknown
current!.isJCL = mainKey == "JCLOpenUI"
case "CloseUI", "JCLCloseUI":
if let option = current { options.append(option) }
current = nil
default:
if mainKey.hasPrefix("ParamCustom"), parts.count == 2 {
// *ParamCustomRIAngel Angel/Text: 1 int 0 359
let fields = value.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
let (name, label) = Self.splitText(String(parts[1]))
if fields.count >= 2, let order = Int(fields[0]) {
customParams[String(mainKey.dropFirst("ParamCustom".count)), default: []].append(
CustomParameter(name: name, text: label ?? name, order: order, type: fields[1],
minimum: fields.count > 2 ? fields[2] : "",
maximum: fields.count > 3 ? fields[3] : ""))
}
} else if mainKey.hasPrefix("Custom"), parts.count == 2, Self.splitText(String(parts[1])).name == "True" {
customKeywords.insert(String(mainKey.dropFirst("Custom".count)))
} else if mainKey.hasPrefix("Default"), parts.count == 1 {
defaults[String(mainKey.dropFirst("Default".count))] = value
} else if parts.count == 2, let option = current, mainKey == option.keyword {
let (choice, label) = Self.splitText(String(parts[1]))
current!.choices.append(choice)
current!.choiceList.append(Choice(keyword: choice, text: label ?? choice))
} else if parts.count == 1, mainKey == "cupsFilter" || mainKey == "cupsFilter2" {
filterLines.append((mainKey, value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))))
if attributes[mainKey] == nil { attributes[mainKey] = filterLines.last!.value }
} else if parts.count == 1, attributes[mainKey] == nil {
attributes[mainKey] = value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
}
}
}
if let option = current { options.append(option) }
// lpoptions hides PageRegion (it mirrors PageSize).
options.removeAll { $0.keyword == "PageRegion" }
for i in options.indices {
let keyword = options[i].keyword
options[i].defaultChoice = defaults[keyword]
if !groups.contains(options[i].group) { groups.append(options[i].group) }
guard customKeywords.contains(keyword) else { continue }
let params = (customParams[keyword] ?? []).sorted { $0.order < $1.order }
options[i].customParameters = params
if keyword == "PageSize" {
options[i].customChoice = "Custom.WIDTHxHEIGHT"
} else if params.count == 1 {
options[i].customChoice = "Custom.\(Self.lpoptionsTypeName(params[0].type))"
} else if params.count > 1 {
options[i].customChoice = "{" + params.map { "\($0.name)=\(Self.lpoptionsTypeName($0.type))" }
.joined(separator: " ") + "}"
}
}
}
/// Labels libcups gives options whose *OpenUI line has no translation text.
private static let standardText = ["PageSize": "Media Size", "MediaType": "Media Type",
"InputSlot": "Media Source", "ColorModel": "Output Mode",
"Resolution": "Resolution"]
/// How `lpoptions -l` spells a custom parameter type.
private static func lpoptionsTypeName(_ type: String) -> String {
type == "int" ? "INTEGER" : type.uppercased()
}
/// "Letter/US Letter" -> ("Letter", "US Letter"); "Letter" -> ("Letter", nil).
private static func splitText(_ s: String) -> (name: String, text: String?) {
let pieces = s.split(separator: "/", maxSplits: 1)
let name = pieces.first.map { $0.trimmingCharacters(in: .whitespaces) } ?? ""
let text = pieces.count > 1 ? pieces[1].trimmingCharacters(in: .whitespaces) : nil
return (name, text)
}
public func option(_ keyword: String) -> Option? {
options.first { $0.keyword == keyword }
}
}
extension PPD {
/// Reads a PPD file, plain or gzip-compressed (vendors ship `.ppd.gz` / `.gz` under
/// /Library/Printers/PPDs). Text that isn't valid UTF-8 is read as ISO Latin-1, the PPD default.
public static func text(contentsOfFile path: String) throws -> String {
guard var data = FileManager.default.contents(atPath: path) else {
throw CupsAdminError.failed("can’t read \(path)")
}
if data.starts(with: [0x1F, 0x8B]) {
let result = try CupsTools.run("/usr/bin/gzip", ["-dc", path])
guard result.succeeded else { throw CupsAdminError.failed("can’t decompress \(path): \(result.message)") }
data = result.standardOutput
}
return String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1) ?? ""
}
/// True when a `ppdreport` argument names a PPD file rather than a queue. Queue names can't contain "/".
public static func looksLikeFile(_ argument: String) -> Bool {
argument.contains("/") || [".ppd", ".gz", ".ppd.gz"].contains { argument.lowercased().hasSuffix($0) }
}
}