commit eba94aae4eb9ade9047a0a98634f765c9d837bbf Author: kfattic <6775519+kfattic@users.noreply.github.com> Date: Wed Sep 16 13:13:07 2026 -0500 cupsadmin: CLI, CupsKit, app in progress Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d9e63c --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Build output +.build/ +.swiftpm/ +dist/ +DerivedData/ +*.xcodeproj +*.icns +# Compiler output that must never land in the source tree +*.o +*.d +*.dia +*.swiftdeps +*.swiftmodule + +# macOS +.DS_Store + +# Local only — never commit +build.env +scratch/ +Claude outputs/ + +# Maintainer-local notes and reports +CLAUDE.local.md +*.local.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bd0fd56 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# cupsadmin + +A native replacement for the CUPS web interface (`http://localhost:631`) that Apple removed in +macOS 27. `cupsd`, `lpadmin`, `lpstat`, `lpoptions` and IPP Everywhere queues still work; only the +browser admin pages are gone. Two front ends share one library: + +- **CupsKit** (`Sources/CupsKit`) — IPP client, PPD parser, CUPS tool wrappers, read-back checks. + Pure logic: returns values or throws, never prints. +- **`cupsadmin`** (`Sources/cupsadmin`) — the CLI, a thin layer over CupsKit. +- **CUPS Admin.app** (`Sources/CUPSAdminApp`) — the SwiftUI app. The target is `CUPSAdminApp`, not + `CUPSAdmin`: APFS is case-insensitive, so `Sources/CUPSAdmin` is the same folder as `Sources/cupsadmin` + and the two executables would collide in `.build/`. + +Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored). + +## Architecture +- **Reads are IPP** with a small hand-written encoder/decoder (RFC 8010, `CupsKit/IPP`): + CUPS-Get-Printers, Get-Printer-Attributes, Get-Jobs, Get-Job-Attributes, Cancel-Job, + CUPS-Get-Default. This gives typed data (state reasons, `*-default`, `*-supported`, job state) + instead of localized `lpstat` text. +- **Transport is the Unix socket `/private/var/run/cupsd`, not TCP 631.** On macOS 27 launchd + socket-activates cupsd only on that socket; cupsd binds port 631 itself while running and exits + after about a minute idle, so TCP fails whenever it's asleep. URLSession can't use AF_UNIX, so + `UnixSocketHTTP.swift` speaks minimal HTTP/1.1 over POSIX sockets. PPDs are fetched the same way + (`GET /printers/.ppd`). +- **Writes shell out** through `CupsTools`: `lpadmin`, `cupsenable`/`cupsdisable`, + `cupsaccept`/`cupsreject`, `cancel`, `lp`, `lpmove`, `lpinfo`. `lpadmin` already handles + `_lpadmin` authorization and IPP Everywhere PPD generation. +- **Queue defaults are written with `lpadmin -p -o key=value`** (into the queue's PPD / + printers.conf, for every user) — never `lpoptions`. `~/.cups/lpoptions` is only read, to point out + a per-user override. +- **PPD options** keep their `*OpenGroup`, UI type and `*ParamCustom` type/range. Custom values are + written as `Custom.` and quoted when they contain spaces (unquoted, `lpadmin` silently truncates). +- **Quick actions** (`QuickActions.swift`) are data: title, symbol, PPD keyword/value variants, input + type. Actions whose keywords aren't in a queue's PPD are unavailable, with the reason. + +## Build and test +``` +swift build # debug build of everything +.build/debug/cupsadmin printers # run the CLI +./test.sh # Swift Testing via the Command Line Tools (see flags inside) +./build.sh --build-only # universal CLI + app bundle, ad-hoc signed, no identities needed +./build.sh # release: sign, payload-free PKG, notarize, staple, spctl +``` +- Only the Command Line Tools are required. Universal builds are per-triple + (`swift build -c release --triple arm64-apple-macosx14.0` and `x86_64-…`) plus `lipo`; + `--arch arm64 --arch x86_64` needs full Xcode. +- Package: tools-version 6.0, Swift 5 language mode, macOS 14 minimum. +- `build.sh` reads `CODESIGN_APP_IDENTITY`, `CODESIGN_PKG_IDENTITY`, `NOTARY_PROFILE` (and optional + `CODESIGN_TEAM_ID`) from the environment or an untracked `build.env`. Never hard-code identities. +- The PKG is payload-free (`pkgbuild --nopayload --scripts`; `pkg/postinstall` copies the binary), because + any payload's "." entry would reset an existing `/usr/local/bin`'s ownership. +- The app icon master is `Icon/AppIcon-1024.png`; `build.sh` generates `AppIcon.icns` from it. Don't redraw it. +- Live tests are opt-in and write to queues: `CUPSKIT_LIVE_QUEUE=`, `CUPSKIT_LIVE_JOBS=,`, + `CUPSKIT_LIVE_QUICK=,`. Use throwaway queues (e.g. `lpd://127.0.0.1/…`, paused) and + delete them afterwards; the live tests restore what they change. Offline tests use vendor PPDs from + `/Library/Printers/PPDs` and skip when they aren't installed. + +## Conventions +- **US spelling everywhere** — code, UI, CLI output, docs (color, canceled, gray). +- **No third-party dependencies.** No Xcode project. +- **CLI status lines on every exit path:** `started HH:MM:SS`, exactly one `OK: …` or `ERROR: …`, and + `finished HH:MM:SS (total N min)` — on stderr, so tables on stdout pipe cleanly. Exit 0 success, + 1 runtime error or not applied, 2 usage error. Existing command output is a contract: don't change it + casually. Scripts (`build.sh`, `test.sh`) print the same started/finished lines. +- **Read back after every write.** Re-query cupsd and compare; a change that didn't take is reported + (`NOT APPLIED`, non-zero exit, or a red X with the reason) — never silent. +- **App feedback:** every action updates the bottom status line with its timing ("Set 3 options on + Office in 0.4 s", "ERROR: …") and logs the same through `os_log` (subsystem `edu.wku.cupsadmin.app`). + Failures show the tool output and, for permission errors, the `_lpadmin` fix. The exact command is + visible before it runs. Destructive actions confirm and name the object. +- **Secrets:** passcode/password options written as queue defaults land in a world-readable PPD — warn, + and mask them in displayed commands. Never commit `build.env`, `dist/`, notary output or `CLAUDE.local.md`. +- **Native SwiftUI and macOS idioms only:** `NavigationSplitView`, sortable/resizable `Table`, + `Form` with `.formStyle(.grouped)`, standard sheets, alerts and confirmation dialogs, SF Symbols, + system fonts, semantic colors (`.primary`, `.secondary`, `.green`/`.blue`/`.red` state dots), + `ContentUnavailableView` empty states. No custom chrome, no web-style cards, no emoji. Must look right + in light and dark mode. Tables and text are selectable and copyable. diff --git a/Icon/AppIcon-1024.png b/Icon/AppIcon-1024.png new file mode 100644 index 0000000..b43e4cb Binary files /dev/null and b/Icon/AppIcon-1024.png differ diff --git a/Icon/AppIcon.svg b/Icon/AppIcon.svg new file mode 100644 index 0000000..f9fbc22 --- /dev/null +++ b/Icon/AppIcon.svg @@ -0,0 +1,82 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo5NjJiYzA2Ni01ZDNkLTQwZTctODY4YS03YjJjYzQ1OTAwYzcAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaKS0Xjzmf51a2dVexa7KMyEAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDphNGU0YjI5MS0yZTU3LTRiZWYtYWNhMS1iZjYxNzc2MTA3MDBscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoPp6ybA6VTpD6kByfEl9TNgAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggtA/sqv0KHmrmdowCHcDbD/4yklEw+St+3Mn5BQLMIUqkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaM/4s2iMbGGc6NBnGZkjt0sAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCBuG1y66ucVooLhY5oJvPw+Zb8yv5X37RC53HCL54Q58WRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiaZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjk2MmJjMDY2LTVkM2QtNDBlNy04NjhhLTdiMmNjNDU5MDBjNy9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjgwY2I5NWRkLTMyYzQtNDZiMC05ZjBlLTQwYjMwMjgyMmQwZXJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCC0D+yq/QoeauZ2jAIdwNsP/jKSUTD5K37cyfkFAswhSqJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggUJoYEcAj+wTVLnyxc7TT8O6WipaSPf+X60lKCMEyxGOiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggTirlnyGpBeq2ijhPpChy3pXXRzIDSeSzrDgOWuCX3uh0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQIgjEpiTJ4zW7Eda1KQ9DCbDXiLFjGc8CNzy/Zpiacbl55OQKcxgyVohWZQex6hEVa21zgveldu1a3oJZiibAV8= + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Icon/make_icon.py b/Icon/make_icon.py new file mode 100644 index 0000000..88e7240 --- /dev/null +++ b/Icon/make_icon.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""CUPS Admin icon — old-school dot-matrix printer with green-bar fanfold paper.""" +import cairosvg, os + +BG = { + "graphite": ("#5b6470", "#2e343c"), + "slate": ("#6b7a8c", "#3a4655"), + "green": ("#2f8f5b", "#1b5a39"), + "cream": ("#e9e2cf", "#cbbf9f"), +} + +def greenbar(x, y, w, h, bar=26, holes=True, rot=0, id_="gb"): + """A sheet of green-bar paper: alternating pale green / white bands + tractor holes.""" + out = [f''] + out.append(f'') + yy = y + i = 0 + while yy < y + h: + if i % 2 == 0: + hh = min(bar, y + h - yy) + out.append(f'') + yy += bar; i += 1 + if holes: + # tractor-feed strips + for hx in (x + 22, x + w - 22): + out.append(f'') + hy = y + 18 + while hy < y + h - 8: + out.append(f'') + hy += 40 + out.append('') + return "\n".join(out) + +def svg(top, bottom, dark_glyph=False): + body_top, body_bot = ("#f4f6f8", "#d9dee5") + return f''' + + + + + + + + + + + + + + + + + + + + + + + {greenbar(318, 250, 388, 200, rot=0)} + + + + + + + + + + + + + + + {greenbar(300, 612, 424, 160, rot=0)} + + + + + {"".join(f'' for y in (639, 651, 663) for x in range(364, 662, 12) if ((x-364)//12) % 7 != 6)} + {"".join(f'' for y in (719, 731, 743) for x in range(364, 662, 12) if ((x-364)//12) % 7 != 6)} + + +''' + +os.makedirs("out2", exist_ok=True) +for name, (top, bottom) in BG.items(): + s = svg(top, bottom) + open(f"out2/icon-{name}.svg", "w").write(s) + cairosvg.svg2png(bytestring=s.encode(), write_to=f"out2/icon-{name}-1024.png", output_width=1024, output_height=1024) + cairosvg.svg2png(bytestring=s.encode(), write_to=f"out2/icon-{name}-256.png", output_width=256, output_height=256) +print("done") diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8e088b0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kurt Fattic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..41e34c2 --- /dev/null +++ b/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:6.0 +import PackageDescription + +let package = Package( + name: "cupsadmin", + // Executable names must differ case-insensitively (cupsadmin vs CUPSAdminApp) on APFS. + // macOS 14: the app needs ContentUnavailableView/Observation, and SwiftPM has one deployment target per package. + platforms: [.macOS(.v14)], + products: [ + .executable(name: "cupsadmin", targets: ["cupsadmin"]), + .executable(name: "CUPSAdminApp", targets: ["CUPSAdminApp"]), + .library(name: "CupsKit", targets: ["CupsKit"]), + ], + targets: [ + .target(name: "CupsKit", path: "Sources/CupsKit"), + .executableTarget(name: "cupsadmin", dependencies: ["CupsKit"], path: "Sources/cupsadmin"), + .executableTarget(name: "CUPSAdminApp", dependencies: ["CupsKit"], path: "Sources/CUPSAdminApp"), + .testTarget(name: "CupsKitTests", dependencies: ["CupsKit"], path: "Tests/CupsKitTests"), + ], + // Tools 6.0 for Swift Testing with only the Command Line Tools; code stays in Swift 5 mode. + swiftLanguageModes: [.v5] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..f67ba74 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# CUPS Admin for macOS + +A native replacement for the CUPS web interface (`http://localhost:631`) that Apple removed in macOS 27. + +`cupsd`, `lpadmin`, `lpstat`, `lpoptions` and IPP Everywhere queues all still work on macOS 27 — only the browser admin page is gone. This project puts it back, twice: + +- **`cupsadmin`** — a command-line tool for scripts, Mosyle/Jamf/Munki commands and quick checks. +- **CUPS Admin.app** — a native SwiftUI app that covers everything the old web pages did: printers, jobs, queue options (including per-driver options like Ricoh user codes), classes and server settings. + +No third-party dependencies. Builds with the Xcode Command Line Tools alone. + +> Status: the CLI is stable (1.0.x). The app is under active development — see [Releases](../../releases) for what's shipped. + +## Install + +Download the signed, notarized PKG from the latest [release](../../releases). It installs: + +- `/usr/local/bin/cupsadmin` +- `/Applications/CUPS Admin.app` (once the app ships) + +The package is payload-free and installs via a postinstall script, so it never changes the ownership of an existing `/usr/local/bin` (Homebrew on Intel Macs is safe). For Munki, use an `installs` array pointing at `/usr/local/bin/cupsadmin`; there is no payload file list to key off. + +Requires macOS 14 or later. Universal (Apple silicon and Intel). + +## CLI + +``` +cupsadmin printers # every queue: state, reasons, accepting, shared, URI +cupsadmin printer # one queue in detail (--all for every IPP attribute) +cupsadmin jobs [queue] # active jobs (--completed, --all-jobs) +cupsadmin cancel # cancel one job +cupsadmin options # PPD options and IPP *-default values, like lpoptions -l +cupsadmin add -v [-m everywhere | -P file.ppd] [-D desc] [-L location] [--shared] [-o k=v ...] +cupsadmin set -o k=v ... # change queue defaults, then read them back and report +cupsadmin quick [value] # task-named shortcuts, see below +``` + +Every run prints `started HH:MM:SS`, one status line (`OK: …` or `ERROR: …`) and `finished HH:MM:SS (total N min)` — on every exit path. Status lines go to stderr so table output pipes cleanly. + +`set` is stricter than `lpadmin`: `lpadmin -o PageSzie=Letter` (misspelled) exits 0 and changes nothing; `cupsadmin set` reads the queue back afterwards and reports `NOT APPLIED` with a non-zero exit. + +### Quick actions + +The things admins actually do, without knowing the driver's option keywords: + +``` +cupsadmin quick color # always print in color +cupsadmin quick bw # always print black & white +cupsadmin quick usercode 12345 # set a Ricoh user code (enables it too) +cupsadmin quick duplex # default to two-sided +cupsadmin quick simplex +cupsadmin quick letter # Letter paper + fit to nearest size (no "prompt user" at the printer) +cupsadmin quick default # make this the server default printer +``` + +Actions whose option isn't in the queue's PPD are refused with a message saying why. Because these are ordinary `lpadmin -p -o …` writes, they work in an MDM custom command exactly as they do in Terminal. + +## How it works + +- **Reads** go straight to cupsd over IPP, using a small hand-written IPP encoder/decoder (RFC 8010). That's the same data the web UI showed, fully typed — `printer-state-reasons`, `media-supported`, `*-default`, job state and owner — instead of parsing localized `lpstat` output. +- **Transport is the Unix socket** `/private/var/run/cupsd`, not TCP 631. On macOS 27, launchd socket-activates cupsd only on that socket, and cupsd exits after about a minute idle, so port 631 is only reachable right after something else has woken it. The socket path works cold. +- **Writes** shell out to `lpadmin`, `cupsenable`/`cupsdisable`, `cupsaccept`/`cupsreject`, `lp`, `lpmove` and `cupsctl`, then read the result back over IPP. Admin operations over raw IPP would need the same `_lpadmin` group membership anyway, and `lpadmin` already knows how to generate IPP Everywhere PPDs. +- **Options** are parsed from the queue's PPD, grouped the way the PPD groups them, with typed custom values (user codes, passcodes, numbers) handled as text and number fields. Ricoh's PS drivers are documented in `docs/ricoh-ppd-options.md`. + +## Build from source + +Requirements: macOS 14+, Xcode Command Line Tools (full Xcode not needed). + +``` +git clone https://github.com/kfattic/cupsadmin.git +cd cupsadmin +swift build +.build/debug/cupsadmin printers +``` + +To produce a signed, notarized package like the ones in Releases: + +``` +export CODESIGN_APP_IDENTITY="" +export CODESIGN_PKG_IDENTITY="" +export NOTARY_PROFILE="" +VERSION=1.2.3 ./build.sh +``` + +Or put those three lines (without `export`) in a `build.env` next to `build.sh`; it's read if present and gitignored. Variables already set in the environment win. + +`build.sh` builds a universal binary (per-architecture `swift build` + `lipo`), signs with the hardened runtime, builds the payload-free PKG, notarizes, staples and verifies with `spctl`. Use `./build.sh --build-only` to skip signing and notarization. Run `./test.sh` for the test suite (`CUPSKIT_LIVE_QUEUE=` enables the live tests; `CUPSKIT_LIVE_JOBS` and `CUPSKIT_LIVE_QUICK` take two throwaway queues each for the job and quick-action tests). + +## Notes for admins + +- Queue defaults written by `set`, `quick` and the app's Options page go into `/etc/cups/ppd/.ppd` via `lpadmin`, so they apply to every user on the Mac — the same thing the web UI's "Set Default Options" did. Per-user `~/.cups/lpoptions` overrides still win for that user's own jobs. +- PPD files are world-readable. A locked-print password or login password set as a queue default is stored in plain text; the tool warns before doing that. User codes are accounting codes, not secrets. +- New queues default to `printer-is-shared=false`. `lpadmin` defaults to shared; this tool doesn't. + +## License + +MIT — see `LICENSE`. + +Built at Western Kentucky University by Kurt Fattic, with Claude. Not affiliated with Apple or OpenPrinting. diff --git a/Sources/CUPSAdminApp/AddPrinterSheet.swift b/Sources/CUPSAdminApp/AddPrinterSheet.swift new file mode 100644 index 0000000..1d7e557 --- /dev/null +++ b/Sources/CUPSAdminApp/AddPrinterSheet.swift @@ -0,0 +1,331 @@ +import AppKit +import CupsKit +import Observation +import SwiftUI + +/// Add Printer (manual) and Modify Printer: name, device URI, driver, description, location, shared. +@MainActor +@Observable +final class AddPrinterModel: Identifiable { + enum Driver: String, CaseIterable, Identifiable { + case keep, everywhere, list, file + var id: String { rawValue } + var title: String { + switch self { + case .keep: return "Keep Current" + case .everywhere: return "IPP Everywhere" + case .list: return "Driver List" + case .file: return "PPD File" + } + } + } + + struct Model: Identifiable, Hashable { + let model: String + let name: String + var id: String { model } + } + + let id = UUID() + /// nil when adding. + let original: PrinterOriginal? + var name = "" + var deviceURI = "" + var driver: Driver + var modelSearch = "" + var selectedModel: String? + var ppdFile: String? + var descriptionText = "" + var location = "" + var shared = false + private(set) var models: [Model] = [] + private(set) var modelsError: String? + private(set) var isRunning = false + private(set) var errorText: String? + private(set) var checks: [SettingCheck] = [] + private let store: PrinterStore + + struct PrinterOriginal { + let name: String + let deviceURI: String + let description: String + let location: String + let shared: Bool + } + + var isModify: Bool { original != nil } + + init(store: PrinterStore, modifying printer: IPPGroup? = nil) { + self.store = store + if let printer { + let original = PrinterOriginal(name: printer.string("printer-name") ?? "", + deviceURI: printer.string("device-uri") ?? "", + description: printer.string("printer-info") ?? "", + location: printer.string("printer-location") ?? "", + shared: printer.bool("printer-is-shared") ?? false) + self.original = original + name = original.name + deviceURI = original.deviceURI + descriptionText = original.description + location = original.location + shared = original.shared + driver = .keep + } else { + original = nil + driver = .everywhere + } + } + + var drivers: [Driver] { isModify ? Driver.allCases : Driver.allCases.filter { $0 != .keep } } + + var filteredModels: [Model] { + let terms = modelSearch.split(separator: " ") + guard !terms.isEmpty else { return models } + return models.filter { m in terms.allSatisfy { m.name.localizedCaseInsensitiveContains($0) || m.model.localizedCaseInsensitiveContains($0) } } + } + + var nameError: String? { + guard !isModify, !name.isEmpty else { return nil } + do { try QueueAdmin.validateQueueName(name) } catch { return "No spaces, / \\ ? ' \" #; up to 127 bytes" } + if store.printers.contains(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) { + return "A queue named \(name) already exists" + } + return nil + } + + /// Only what changed, for Modify (so the PPD isn't regenerated needlessly); everything for Add. + var spec: QueueSpec { + var spec = QueueSpec() + let uri = deviceURI.trimmingCharacters(in: .whitespaces) + if original?.deviceURI != uri { spec.deviceURI = uri.isEmpty ? "" : uri } + switch driver { + case .keep: break + case .everywhere: spec.model = "everywhere" + case .list: spec.model = selectedModel + case .file: spec.ppdFile = ppdFile + } + if original?.description != descriptionText, !(original == nil && descriptionText.isEmpty) { spec.description = descriptionText } + if original?.location != location, !(original == nil && location.isEmpty) { spec.location = location } + if let original, original.shared != shared { + spec.options.append((key: "printer-is-shared", value: shared ? "true" : "false")) + } + return spec + } + + var canSubmit: Bool { + guard !isRunning, !deviceURI.trimmingCharacters(in: .whitespaces).isEmpty else { return false } + if !isModify, name.isEmpty || nameError != nil { return false } + if driver == .list, selectedModel == nil { return false } + if driver == .file, ppdFile == nil { return false } + if isModify, spec.isEmpty { return false } + return true + } + + var command: String { + let args: [String] + if isModify { + args = spec.lpadminArguments(queue: name) + } else { + var addSpec = spec + addSpec.options.append((key: "printer-is-shared", value: shared ? "true" : "false")) + args = QueueAdmin.addArguments(queue: name.isEmpty ? "" : name, spec: addSpec) + } + return CupsTools.commandLine(CupsTools.lpadminPath, args) + } + + func loadModels() async { + guard models.isEmpty else { return } + do { + let list = try await Task.detached { try CupsTools.models() }.value + models = list.map { Model(model: $0.model, name: $0.name) } + .filter { $0.model != "everywhere" && $0.model != "raw" } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } catch { + modelsError = String(describing: error) + } + } + + func choosePPDFile() { + let panel = NSOpenPanel() + panel.title = "Choose a PPD" + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: "/Library/Printers/PPDs/Contents/Resources") + if panel.runModal() == .OK, let url = panel.url { ppdFile = url.path } + } + + /// Returns true when every setting read back correctly. + func submit() async -> Bool { + isRunning = true + defer { isRunning = false } + errorText = nil + checks = [] + let started = Date() + let client = store.client + do { + if isModify { + let spec = self.spec + _ = try await QueueAdmin.prepareSet(client: client, queue: name, spec: spec) + let result = try await Task.detached { [name] in try CupsTools.lpadmin(spec.lpadminArguments(queue: name)) }.value + checks = try await QueueAdmin.confirmSet(client: client, queue: name, spec: spec, lpadminStatus: result.status) + if !result.succeeded { errorText = result.message } + } else { + let prepared = try await QueueAdmin.prepareAdd(client: client, queue: name, spec: spec, shared: shared) + let result = try await Task.detached { [name] in try CupsTools.lpadmin(QueueAdmin.addArguments(queue: name, spec: prepared)) }.value + if !result.succeeded { + errorText = PrinterStore.explain(result.message.isEmpty ? "lpadmin exited \(result.status)" : result.message) + } else { + let snapshot = try await QueueAdmin.confirmAdded(client: client, queue: name, lpadminStatus: result.status) + checks = snapshot.settings(for: prepared) + } + } + } catch { + errorText = PrinterStore.explain(String(describing: error)) + } + + let failed = checks.filter { !$0.applied } + let verb = isModify ? "Modified" : "Added" + let ok = errorText == nil && failed.isEmpty + if ok { + store.report("\(verb) \(name) in \(elapsedText(since: started)), \(checks.count) setting\(checks.count == 1 ? "" : "s") verified") + } else { + if errorText == nil { + errorText = "Not applied: " + failed.map { "\($0.name) (wanted \($0.wanted), is \($0.value ?? "nothing"))" }.joined(separator: "; ") + } + store.report("ERROR: \(isModify ? "modify" : "add") \(name): \(errorText!)") + } + store.noteChange(queue: name) + await store.refreshQuietly() + return ok + } +} + +struct AddPrinterSheet: View { + @Bindable var model: AddPrinterModel + let onFinished: (String) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(model.isModify ? "Modify \(model.name)" : "Add Printer") + .font(.title3.weight(.semibold)) + .padding([.horizontal, .top], 20) + + Form { + Section { + LabeledContent { + if model.isModify { + Text(model.name).monospaced() + } else { + TextField("Name", text: $model.name, prompt: Text("Queue name")) + .labelsHidden() + .textFieldStyle(.roundedBorder) + } + } label: { + Text("Name") + if let error = model.nameError { Text(error).foregroundStyle(.red) } + } + LabeledContent("Device URI") { + TextField("Device URI", text: $model.deviceURI, prompt: Text("ipp://printer.example.edu/ipp/print")) + .labelsHidden() + .textFieldStyle(.roundedBorder) + .monospaced() + } + } + + Section("Driver") { + Picker("Driver", selection: $model.driver) { + ForEach(model.drivers) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + + switch model.driver { + case .keep: + Text("The queue keeps its PPD and all option defaults.") + .foregroundStyle(.secondary) + case .everywhere: + Text("cupsd builds the PPD from the printer (lpadmin -m everywhere). The printer must be reachable.") + .foregroundStyle(.secondary) + case .list: + TextField("Search drivers", text: $model.modelSearch, prompt: Text("Search \(model.models.count) drivers")) + .textFieldStyle(.roundedBorder) + List(model.filteredModels, selection: $model.selectedModel) { item in + VStack(alignment: .leading, spacing: 1) { + Text(item.name) + Text(item.model).font(.caption).monospaced().foregroundStyle(.secondary) + } + .tag(item.model) + } + .frame(height: 170) + .overlay { + if let error = model.modelsError { + ContentUnavailableView("Can’t List Drivers", systemImage: "exclamationmark.triangle", + description: Text(error)) + } else if model.models.isEmpty { + ProgressView() + } + } + .task { await model.loadModels() } + case .file: + LabeledContent { + Button("Choose PPD File…") { model.choosePPDFile() } + } label: { + Text(model.ppdFile ?? "No file chosen") + .monospaced(model.ppdFile != nil) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + + Section { + LabeledContent("Description") { + TextField("Description", text: $model.descriptionText, prompt: Text("Optional")) + .labelsHidden() + .textFieldStyle(.roundedBorder) + } + LabeledContent("Location") { + TextField("Location", text: $model.location, prompt: Text("Optional")) + .labelsHidden() + .textFieldStyle(.roundedBorder) + } + Toggle("Share this printer on the network", isOn: $model.shared) + } + + Section("Command") { + Text(model.command) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + if let error = model.errorText { + Label(error, systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .formStyle(.grouped) + + HStack { + if model.isRunning { ProgressView().controlSize(.small) } + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button(model.isModify ? "Save" : "Add Printer") { + Task { + if await model.submit() { + onFinished(model.name) + dismiss() + } + } + } + .keyboardShortcut(.defaultAction) + .disabled(!model.canSubmit) + } + .padding([.horizontal, .bottom], 20) + .padding(.top, 8) + } + .frame(width: 600, height: model.driver == .list ? 760 : 600) + } +} diff --git a/Sources/CUPSAdminApp/CUPSAdminApp.swift b/Sources/CUPSAdminApp/CUPSAdminApp.swift new file mode 100644 index 0000000..da82b9b --- /dev/null +++ b/Sources/CUPSAdminApp/CUPSAdminApp.swift @@ -0,0 +1,12 @@ +import SwiftUI + +@main +struct CUPSAdminApp: App { + var body: some Scene { + // Single-window utility; SwiftUI saves and restores the window frame. + Window("CUPS Admin", id: "main") { + ContentView() + } + .defaultSize(width: 980, height: 640) + } +} diff --git a/Sources/CUPSAdminApp/ContentView.swift b/Sources/CUPSAdminApp/ContentView.swift new file mode 100644 index 0000000..8c0f252 --- /dev/null +++ b/Sources/CUPSAdminApp/ContentView.swift @@ -0,0 +1,378 @@ +import CupsKit +import SwiftUI + +struct ContentView: View { + @State private var store = PrinterStore() + // Remembered across launches. + @AppStorage("selectedQueue") private var selection: String? + @AppStorage("detailTab") private var tab: DetailTab = .jobs + @State private var search = "" + @State private var detail: PrinterDetailModel? + @State private var options: OptionsModel? + @State private var proposedSelection: String? + @State private var confirmingDiscard = false + @State private var cancelAllTarget: PrinterSummary? + @State private var deleteTarget: PrinterSummary? + @State private var quickAction: QuickActionModel? + @State private var addPrinter: AddPrinterModel? + + private var selectedPrinter: PrinterSummary? { + store.printers.first { $0.name == selection } + } + + var body: some View { + NavigationSplitView { + SidebarView(store: store, selection: guardedSelection, search: $search, actions: sidebarActions) + .navigationSplitViewColumnWidth(min: 220, ideal: 270, max: 400) + } detail: { + if let detail, let options, selectedPrinter != nil { + PrinterDetailView(model: detail, options: options, store: store, tab: tab) + .id(detail.queue) + } else { + ContentUnavailableView("Select a Printer", systemImage: "printer", + description: Text("Choose a queue in the sidebar.")) + .padding(.bottom, StatusBar.height) + } + } + // Whole-window status bar, under both columns. + .safeAreaInset(edge: .bottom, spacing: 0) { + StatusBar(action: store.statusLine, summary: store.summaryLine) + } + .navigationTitle(selection ?? "CUPS Admin") + .toolbar { toolbar } + .task { + await store.refresh() + await store.loadPPDs() + } + .onChange(of: store.revision) { Task { await store.loadPPDs() } } + .onChange(of: selection, initial: true) { _, queue in + detail = queue.map { PrinterDetailModel(queue: $0, store: store) } + options = queue.map { queue in + OptionsModel(queue: queue) { [store] line in store.report(line) } + } + } + .confirmationDialog("Discard unsaved option changes for \(selection ?? "")?", + isPresented: $confirmingDiscard) { + Button("Discard Changes", role: .destructive) { selection = proposedSelection } + Button("Keep Editing", role: .cancel) {} + } message: { + Text("\(options?.changeCount ?? 0) change(s) haven’t been applied.") + } + .confirmationDialog("Cancel all jobs on \(cancelAllTarget?.name ?? "")?", + isPresented: Binding(get: { cancelAllTarget != nil }, set: { if !$0 { cancelAllTarget = nil } }), + presenting: cancelAllTarget) { printer in + Button("Cancel \(printer.jobCount) Job\(printer.jobCount == 1 ? "" : "s")", role: .destructive) { + Task { await store.perform(.cancelAllJobs, on: printer.name) } + } + Button("Keep Jobs", role: .cancel) {} + } message: { printer in + Text("Runs cancel -a \(printer.name). This can’t be undone.") + } + .confirmationDialog("Delete printer \(deleteTarget?.name ?? "")?", + isPresented: Binding(get: { deleteTarget != nil }, set: { if !$0 { deleteTarget = nil } }), + presenting: deleteTarget) { printer in + Button("Delete \(printer.name)", role: .destructive) { + Task { + if await store.delete(printer.name), selection == printer.name { selection = nil } + } + } + Button("Keep Printer", role: .cancel) {} + } message: { printer in + Text(printer.jobCount > 0 + ? "Runs lpadmin -x \(printer.name). Its \(printer.jobCount) active job\(printer.jobCount == 1 ? "" : "s") will be canceled. This can’t be undone." + : "Runs lpadmin -x \(printer.name). This can’t be undone.") + } + .sheet(item: $quickAction) { model in + QuickActionSheet(model: model) + } + .sheet(item: $addPrinter) { model in + AddPrinterSheet(model: model) { name in + if !model.isModify { selection = name } + } + } + .alert(item: $store.failure) { failure in + Alert(title: Text(failure.title), message: Text(failure.message)) + } + } + + @ToolbarContentBuilder private var toolbar: some ToolbarContent { + if selectedPrinter != nil { + ToolbarItem(placement: .principal) { + Picker("View", selection: $tab) { + ForEach(DetailTab.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + } + } + ToolbarItemGroup(placement: .primaryAction) { + Menu { + if let printer = selectedPrinter { + QuickActionItems(store: store, queue: printer.name) { action in + quickAction = QuickActionModel(action: action, queue: printer.name, store: store) + } + } else { + Text("Select a printer first") + } + } label: { + Label("Quick Actions", systemImage: "bolt") + } + .help("Quick Actions for the selected printer") + + if let printer = selectedPrinter { + let pause: PrinterAction = printer.isPaused ? .resume : .pause + Button { Task { await store.perform(pause, on: printer.name) } } label: { + Label(pause.title, systemImage: pause.systemImage) + } + .help(printer.isPaused ? "Resume printing (cupsenable)" : "Pause printing (cupsdisable)") + + let accept: PrinterAction = printer.accepting ? .reject : .accept + Button { Task { await store.perform(accept, on: printer.name) } } label: { + Label(accept.title, systemImage: accept.systemImage) + } + .help(printer.accepting ? "Stop accepting new jobs (cupsreject)" : "Accept new jobs (cupsaccept)") + } + Button { + addPrinter = AddPrinterModel(store: store) + } label: { + Label("Add Printer", systemImage: "plus") + } + .help("Add a printer (lpadmin -p … -E)") + + Button { + Task { + await store.refresh() + store.noteChange() + } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .help("Refresh printers and jobs") + .keyboardShortcut("r", modifiers: .command) + .disabled(store.isRefreshing) + } + } + + private var sidebarActions: SidebarActions { + SidebarActions( + cancelAll: { cancelAllTarget = $0 }, + delete: { deleteTarget = $0 }, + modify: { printer in + Task { + if let attributes = try? await store.client.getPrinterAttributes(queue: printer.name, + requested: PrinterDetailModel.headerAttributes) { + addPrinter = AddPrinterModel(store: store, modifying: attributes) + } + } + }, + quick: { action, printer in + quickAction = QuickActionModel(action: action, queue: printer.name, store: store) + } + ) + } + + /// Selection that asks before leaving a printer with unapplied option edits. + private var guardedSelection: Binding { + Binding( + get: { selection }, + set: { newValue in + guard newValue != selection else { return } + if options?.hasChanges == true { + proposedSelection = newValue + confirmingDiscard = true + } else { + selection = newValue + } + } + ) + } +} + +struct SidebarActions { + let cancelAll: (PrinterSummary) -> Void + let delete: (PrinterSummary) -> Void + let modify: (PrinterSummary) -> Void + let quick: (QuickAction, PrinterSummary) -> Void +} + +struct SidebarView: View { + let store: PrinterStore + @Binding var selection: String? + @Binding var search: String + let actions: SidebarActions + + private var filtered: [PrinterSummary] { + store.printers.filter { $0.matches(search: search) } + } + + var body: some View { + List(selection: $selection) { + Section("Printers") { + ForEach(filtered) { printer in + PrinterRow(printer: printer, isDefault: store.defaultPrinter == printer.name) + .tag(printer.name) + .contextMenu { menu(for: printer) } + } + } + } + .listStyle(.sidebar) + .searchable(text: $search, placement: .sidebar, prompt: "Search printers") + .contentMargins(.bottom, StatusBar.height, for: .scrollContent) + .overlay { + if store.printers.isEmpty { + if let error = store.errorMessage { + ContentUnavailableView("Can’t Reach CUPS", systemImage: "exclamationmark.triangle", + description: Text(error)) + } else if store.hasLoaded { + ContentUnavailableView("No Printers", systemImage: "printer", + description: Text("No queues are set up on this Mac.")) + } else { + ProgressView() + } + } else if filtered.isEmpty { + ContentUnavailableView.search(text: search) + } + } + } + + @ViewBuilder private func menu(for printer: PrinterSummary) -> some View { + Group { + let pause: PrinterAction = printer.isPaused ? .resume : .pause + Button { Task { await store.perform(pause, on: printer.name) } } label: { + Label(pause.title, systemImage: pause.systemImage) + } + let accept: PrinterAction = printer.accepting ? .reject : .accept + Button { Task { await store.perform(accept, on: printer.name) } } label: { + Label(accept.title, systemImage: accept.systemImage) + } + Button { Task { await store.printTestPage(printer.name) } } label: { + Label("Print Test Page", systemImage: "doc.text") + } + Button(role: .destructive) { actions.cancelAll(printer) } label: { + Label("Cancel All Jobs…", systemImage: "xmark.circle") + } + .disabled(printer.jobCount == 0) + + Divider() + Button { + if let action = QuickAction.named("default") { actions.quick(action, printer) } + } label: { + Label("Set as Default Printer", systemImage: "star") + } + .disabled(store.defaultPrinter == printer.name) + Button { actions.modify(printer) } label: { + Label("Modify Printer…", systemImage: "slider.horizontal.3") + } + Button(role: .destructive) { actions.delete(printer) } label: { + Label("Delete Printer…", systemImage: "trash") + } + + Divider() + Menu { + QuickActionItems(store: store, queue: printer.name) { actions.quick($0, printer) } + } label: { + Label("Quick Actions", systemImage: "bolt") + } + + Divider() + Button { store.copyDeviceURI(printer) } label: { + Label("Copy Device URI", systemImage: "doc.on.doc") + } + .disabled(printer.deviceURI == nil) + Button { store.showPPDInFinder(printer.name) } label: { + Label("Show PPD in Finder", systemImage: "folder") + } + .disabled(!store.hasPPDFile(printer.name)) + } + .labelStyle(.titleAndIcon) + } +} + +/// Quick Action menu items for one queue; unavailable ones are disabled with the reason as tooltip. +struct QuickActionItems: View { + let store: PrinterStore + let queue: String + let choose: (QuickAction) -> Void + + var body: some View { + ForEach(QuickAction.all) { action in + let reason = store.unavailableReason(action, queue: queue) + Button { choose(action) } label: { + Label(action.title, systemImage: action.systemImage) + } + .disabled(reason != nil) + .help(reason ?? action.summary) + } + .labelStyle(.titleAndIcon) + } +} + +struct PrinterRow: View { + let printer: PrinterSummary + var isDefault = false + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(printer.status.color) + .frame(width: 8, height: 8) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + Text(printer.name) + .lineLimit(1) + if isDefault { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(.secondary) + .help("Server default printer") + .accessibilityLabel("Default printer") + } + } + if let secondary = printer.secondaryLine { + Text(secondary) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .badge(printer.jobCount) + .help(printer.stateSummary) + .accessibilityElement(children: .combine) + .accessibilityValue(printer.stateSummary) + } +} + +struct StatusBar: View { + /// Content above the bar adds this much bottom margin so its last row isn't hidden. + static let height: CGFloat = 30 + + /// Last action with its timing, e.g. "Refreshed 21 printers in 42 ms" or "ERROR: ...". + let action: String + /// Totals, e.g. "21 printers · 0 active jobs". + let summary: String + + var body: some View { + VStack(spacing: 0) { + Divider() + HStack(spacing: 12) { + Text(action) + .foregroundStyle(action.hasPrefix("ERROR") ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + Text(summary) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .font(.callout) + .monospacedDigit() + .textSelection(.enabled) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .background(.regularMaterial) + } +} diff --git a/Sources/CUPSAdminApp/OptionsModel.swift b/Sources/CUPSAdminApp/OptionsModel.swift new file mode 100644 index 0000000..9384340 --- /dev/null +++ b/Sources/CUPSAdminApp/OptionsModel.swift @@ -0,0 +1,257 @@ +import CupsKit +import Foundation +import Observation +import OSLog + +/// Options tab state for one queue: queue defaults, pending edits, and per-row apply results. +/// Apply always writes queue defaults with `lpadmin -p queue -o` (never lpoptions). +@MainActor +@Observable +final class OptionsModel { + enum RowStatus: Equatable { + case applying + case applied(note: String?) + case failed(reason: String) + } + + struct CustomPair: Identifiable, Equatable { + let id = UUID() + let key: String + let value: String + var status: RowStatus? + } + + struct Failure: Identifiable { + let id = UUID() + let title: String + let message: String + } + + let queue: String + + private(set) var state: OptionState? + private(set) var sections: [OptionSection] = [] + private(set) var loadError: String? + private(set) var isApplying = false + /// Row key -> new raw value. + private(set) var pending: [String: String] = [:] + /// Field text as typed, so invalid input stays visible with its error. + private(set) var fieldText: [String: String] = [:] + private(set) var rowStatus: [String: RowStatus] = [:] + private(set) var customPairs: [CustomPair] = [] + var customEntry = "" + private(set) var customEntryError: String? + var failure: Failure? + + private let report: (String) -> Void + private let client = CupsClient() + private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "options") + + init(queue: String, report: @escaping (String) -> Void) { + self.queue = queue + self.report = report + } + + // MARK: loading + + func load() async { + let started = Date() + do { + state = try await OptionState.load(client: client, queue: queue) + loadError = nil + rebuild() + let count = sections.reduce(0) { $0 + $1.rows.count } + report("Loaded \(count) options for \(queue) in \(elapsedText(since: started))") + log.info("loaded \(count) options for \(self.queue, privacy: .public) in \(Date().timeIntervalSince(started), format: .fixed(precision: 3)) s") + } catch { + loadError = String(describing: error) + report("ERROR: \(error)") + log.error("load failed for \(self.queue, privacy: .public): \(String(describing: error), privacy: .public)") + } + } + + private func rebuild() { + sections = state?.sections() ?? [] + } + + // MARK: values + + var hasChanges: Bool { !pending.isEmpty || customPairs.contains { $0.status == nil } } + var changeCount: Int { pending.count + customPairs.filter { $0.status == nil }.count } + var hasErrors: Bool { sections.flatMap(\.rows).contains { validationError($0) != nil } } + + /// The queue default before pending edits. + func currentValue(_ row: OptionRow) -> String? { + state?.queueValue(row.key) + } + + /// The value shown in the control: pending edit, else current. + func value(_ row: OptionRow) -> String? { + pending[row.key] ?? currentValue(row) + } + + func set(_ row: OptionRow, to newValue: String) { + rowStatus[row.key] = nil + pending[row.key] = newValue == currentValue(row) ? nil : newValue + } + + func fieldText(_ row: OptionRow, field: CustomField) -> String { + fieldText[row.key] ?? field.text(fromValue: value(row)) + } + + func setFieldText(_ text: String, row: OptionRow, field: CustomField) { + fieldText[row.key] = text + set(row, to: field.value(fromText: text)) + if pending[row.key] == nil { fieldText[row.key] = nil } + } + + func validationError(_ row: OptionRow) -> String? { + guard case .field(let field) = row.control, let text = fieldText[row.key] else { return nil } + return field.validationError(text) + } + + func isPending(_ row: OptionRow) -> Bool { pending[row.key] != nil } + + /// Your own ~/.cups/lpoptions value wins over the queue default for your jobs; say so. + func overrideNote(_ row: OptionRow) -> String? { + guard let state, let mine = state.userOverride(row.key) else { return nil } + return "Your ~/.cups/lpoptions overrides this for your jobs: \(state.display(mine, row: row))" + } + + // MARK: custom option row + + func addCustomEntry() { + let entry = customEntry.trimmingCharacters(in: .whitespaces) + guard let eq = entry.firstIndex(of: "="), eq != entry.startIndex else { + customEntryError = "Use keyword=value" + return + } + let key = String(entry[.. String { + let message = result.message.isEmpty ? "\(result.commandLine) exited \(result.status)" : result.message + let lower = message.lowercased() + if lower.contains("forbidden") || lower.contains("not authorized") || lower.contains("unauthorized") + || lower.contains("authentication") { + return message + "\n\nChanging queue defaults needs an admin account or membership in _lpadmin:\n" + + "sudo dseditgroup -o edit -a \(NSUserName()) -t user _lpadmin" + } + return message + } +} diff --git a/Sources/CUPSAdminApp/OptionsView.swift b/Sources/CUPSAdminApp/OptionsView.swift new file mode 100644 index 0000000..eedba2b --- /dev/null +++ b/Sources/CUPSAdminApp/OptionsView.swift @@ -0,0 +1,335 @@ +import CupsKit +import SwiftUI + +/// The web UI's "Set Default Options" page: every PPD option by group, IPP defaults, and queue policies. +struct OptionsView: View { + @Bindable var model: OptionsModel + @State private var showingApplySheet = false + + var body: some View { + Group { + if model.state == nil, let error = model.loadError { + ContentUnavailableView("Can’t Load Options", systemImage: "exclamationmark.triangle", + description: Text(error)) + } else if model.state == nil { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + form + } + } + .safeAreaInset(edge: .top, spacing: 0) { actionBar } + .sheet(isPresented: $showingApplySheet) { + ApplyOptionsSheet(model: model) { + showingApplySheet = false + Task { await model.apply() } + } + } + .alert(item: $model.failure) { failure in + Alert(title: Text(failure.title), message: Text(failure.message)) + } + .task(id: model.queue) { await model.load() } + } + + private var actionBar: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Text(model.scopeDescription) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer(minLength: 12) + + if model.isApplying { + ProgressView().controlSize(.small) + } else if model.hasChanges { + Text("\(model.changeCount) unsaved change\(model.changeCount == 1 ? "" : "s")") + .foregroundStyle(.secondary) + .monospacedDigit() + .accessibilityIdentifier("options.changeCount") + } + Button("Revert") { model.revert() } + .disabled(!model.hasChanges || model.isApplying) + .accessibilityIdentifier("options.revert") + Button("Apply…") { showingApplySheet = true } + .keyboardShortcut(.defaultAction) + .disabled(!model.hasChanges || model.hasErrors || model.isApplying) + .accessibilityIdentifier("options.apply") + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + Divider() + } + .background(.bar) + } + + private var form: some View { + Form { + ForEach(model.sections) { section in + Section { + ForEach(section.rows) { row in + OptionRowView(model: model, row: row) + } + } header: { + Text(section.title) + } footer: { + if section.title == "IPP" { + Text("Queue-wide IPP defaults. On PPD queues, media, sides, output bin, resolution and color follow their PPD option.") + .foregroundStyle(.secondary) + } + } + } + + Section { + ForEach(model.customPairs) { pair in + LabeledContent { + HStack(spacing: 8) { + StatusIndicator(status: pair.status, pending: pair.status == nil) + Button { + model.removeCustomPair(pair) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .help("Remove") + .disabled(model.isApplying) + } + } label: { + Text("\(pair.key)=\(pair.value)") + .monospaced() + .textSelection(.enabled) + .accessibilityIdentifier("options.custom.\(pair.key)") + if case .failed(let reason) = pair.status { + Text(reason).foregroundStyle(.red) + } else if case .applied(let note?) = pair.status { + Text(note) + } + } + } + LabeledContent { + HStack { + TextField("Add option manually", text: $model.customEntry, prompt: Text("keyword=value")) + .labelsHidden() + .textFieldStyle(.roundedBorder) + .monospaced() + .frame(minWidth: 260) + .onSubmit { model.addCustomEntry() } + .accessibilityIdentifier("options.customEntry") + Button("Add") { model.addCustomEntry() } + .disabled(model.customEntry.isEmpty || model.isApplying) + .accessibilityIdentifier("options.customAdd") + } + } label: { + Text("Add option manually") + Text(model.customEntryError ?? "Any lpadmin -o pair, e.g. RIUserCode=Custom.1234") + .foregroundStyle(model.customEntryError == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(.red)) + } + } header: { + Text("Advanced") + } + } + .formStyle(.grouped) + // Keep the last row clear of the window's status bar. + .contentMargins(.bottom, StatusBar.height, for: .scrollContent) + } +} + +struct OptionRowView: View { + let model: OptionsModel + let row: OptionRow + + var body: some View { + LabeledContent { + HStack(spacing: 8) { + control + .accessibilityIdentifier("option.\(row.key)") + StatusIndicator(status: model.rowStatus[row.key], pending: model.isPending(row)) + } + .disabled(model.isApplying) + } label: { + Text(row.label) + Text(keywordLine) + .monospaced() + .textSelection(.enabled) + if let error = model.validationError(row) { + Text(error).foregroundStyle(.red) + } else if case .failed(let reason) = model.rowStatus[row.key] { + Text(reason).foregroundStyle(.red) + } else if case .applied(let note?) = model.rowStatus[row.key] { + Text(note) + } else if case .readOnly(let reason) = row.control { + Text(reason) + } else if let note = model.overrideNote(row) { + Text(note) + } + } + } + + private var keywordLine: String { + if case .field(let field) = row.control { return "\(row.key) · \(field.hint)" } + return row.key + } + + @ViewBuilder private var control: some View { + switch row.control { + case .picker(let choices): + Picker(row.label, selection: Binding( + get: { model.value(row) ?? "" }, + set: { model.set(row, to: $0) } + )) { + let current = model.value(row) ?? "" + if !choices.contains(where: { $0.keyword == current }) { + Text(current.isEmpty ? "Not set" : current).tag(current) + } + ForEach(choices, id: \.keyword) { choice in + Text(choice.text).tag(choice.keyword) + } + } + .labelsHidden() + .fixedSize() + + case .toggle(let on, let off): + Toggle(row.label, isOn: Binding( + get: { model.value(row) == on }, + set: { model.set(row, to: $0 ? on : off) } + )) + .labelsHidden() + .toggleStyle(.switch) + + case .field(let field): + let text = Binding( + get: { model.fieldText(row, field: field) }, + set: { model.setFieldText($0, row: row, field: field) } + ) + let prompt = Text(field.choices.first ?? "") + Group { + if field.isSecret { + SecureField(row.label, text: text, prompt: prompt) + } else { + TextField(row.label, text: text, prompt: prompt) + } + } + .labelsHidden() + .textFieldStyle(.roundedBorder) + .monospacedDigit() + .frame(width: field.isNumeric ? 110 : 200) + + case .readOnly: + Text(model.state?.display(model.value(row), row: row) ?? "—") + .foregroundStyle(.secondary) + .monospaced() + .textSelection(.enabled) + } + } +} + +/// Unsaved dot, spinner while applying, then a green check or red X after read-back. +struct StatusIndicator: View { + let status: OptionsModel.RowStatus? + let pending: Bool + + var body: some View { + Group { + switch status { + case .applying: + ProgressView().controlSize(.mini) + case .applied: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .help("Applied and verified") + case .failed(let reason): + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + .help(reason) + case nil: + if pending { + Image(systemName: "circle.fill") + .font(.system(size: 6)) + .foregroundStyle(.tint) + .help("Unsaved change") + } else { + Color.clear + } + } + } + .frame(width: 16, height: 16) + .accessibilityLabel(accessibilityText) + } + + private var accessibilityText: String { + switch status { + case .applying: return "Applying" + case .applied: return "Applied" + case .failed(let reason): return "Not applied: \(reason)" + case nil: return pending ? "Unsaved change" : "" + } + } +} + +struct ApplyOptionsSheet: View { + let model: OptionsModel + let apply: () -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Apply \(model.changeCount) Change\(model.changeCount == 1 ? "" : "s") to \(model.queue)?") + .font(.title3.weight(.semibold)) + Text("Changes the queue default for every user on this Mac.") + .foregroundStyle(.secondary) + } + + if let warning = model.secretWarning { + Label { + Text(warning) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + } + .fixedSize(horizontal: false, vertical: true) + } + + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { + GridRow { + Text("Setting") + Text("Current") + Text("New") + } + .font(.callout.weight(.semibold)) + .foregroundStyle(.secondary) + Divider() + ForEach(model.changeSummaries) { change in + GridRow { + Text(change.label) + Text(change.from).foregroundStyle(.secondary) + Text(change.to) + } + } + } + .textSelection(.enabled) + + VStack(alignment: .leading, spacing: 6) { + Text("Command") + .font(.callout.weight(.semibold)) + .foregroundStyle(.secondary) + Text(model.command) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("applySheet.cancel") + Button("Apply") { apply() } + .keyboardShortcut(.defaultAction) + .accessibilityIdentifier("applySheet.apply") + } + } + .padding(20) + .frame(width: 560) + } +} diff --git a/Sources/CUPSAdminApp/PrinterDetailModel.swift b/Sources/CUPSAdminApp/PrinterDetailModel.swift new file mode 100644 index 0000000..2e46aed --- /dev/null +++ b/Sources/CUPSAdminApp/PrinterDetailModel.swift @@ -0,0 +1,149 @@ +import CupsKit +import Foundation +import Observation +import OSLog + +enum DetailTab: String, CaseIterable, Identifiable { + case jobs, options + var id: String { rawValue } + var title: String { self == .jobs ? "Jobs" : "Options" } +} + +enum JobFilter: String, CaseIterable, Identifiable { + case active, completed, all + var id: String { rawValue } + + var title: String { + switch self { + case .active: return "Active" + case .completed: return "Completed" + case .all: return "All" + } + } + + /// Get-Jobs which-jobs. + var whichJobs: String { + switch self { + case .active: return "not-completed" + case .completed: return "completed" + case .all: return "all" + } + } +} + +/// Header attributes and the Jobs table for one printer. +@MainActor +@Observable +final class PrinterDetailModel { + let queue: String + private(set) var printer: IPPGroup? + private(set) var jobs: [JobSummary] = [] + private(set) var loadError: String? + private(set) var isWorking = false + var selection = Set() + var sortOrder = [KeyPathComparator(\JobSummary.id, order: .reverse)] + var jobFilter: JobFilter = .active { + didSet { if jobFilter != oldValue { Task { await loadJobs() } } } + } + + private let store: PrinterStore + private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "jobs") + + init(queue: String, store: PrinterStore) { + self.queue = queue + self.store = store + } + + static let headerAttributes = [ + "printer-name", "printer-state", "printer-state-reasons", "printer-state-message", + "printer-is-accepting-jobs", "printer-is-shared", "device-uri", "printer-make-and-model", + "printer-info", "printer-location", "queued-job-count", + ] + + var sortedJobs: [JobSummary] { jobs.sorted(using: sortOrder) } + var selectedJobs: [JobSummary] { jobs.filter { selection.contains($0.id) } } + + /// The queue's PPD, if it has one (driverless and raw queues don't). + var ppdPath: String? { + let path = "/private/etc/cups/ppd/\(queue).ppd" + return FileManager.default.fileExists(atPath: path) ? path : nil + } + + func load() async { + do { + printer = try await store.client.getPrinterAttributes(queue: queue, requested: Self.headerAttributes) + loadError = nil + } catch { + loadError = String(describing: error) + store.report("ERROR: \(queue): \(error)") + } + await loadJobs() + } + + func loadJobs() async { + do { + let groups = try await store.client.getJobs(queue: queue, which: jobFilter.whichJobs, + requested: JobSummary.requestedAttributes) + jobs = groups.map(JobSummary.init) + selection = selection.intersection(jobs.map(\.id)) + } catch { + store.report("ERROR: jobs on \(queue): \(error)") + } + } + + // MARK: job actions + + func canPerform(_ action: JobAction, on ids: Set) -> Bool { + let targets = jobs.filter { ids.contains($0.id) } + guard !targets.isEmpty, !isWorking else { return false } + switch action { + case .cancel, .move: return targets.allSatisfy(\.isActive) + case .hold: return targets.allSatisfy { $0.isActive && !$0.isHeld } + case .release: return targets.allSatisfy(\.isHeld) + } + } + + func perform(_ action: JobAction, on ids: Set) async { + let ordered = ids.sorted() + guard !ordered.isEmpty else { return } + isWorking = true + defer { isWorking = false } + let started = Date() + log.info("\(action.title, privacy: .public) jobs \(ordered.map(String.init).joined(separator: ","), privacy: .public) on \(self.queue, privacy: .public)") + + let outcomes = await QueueActions.perform(action, jobIDs: ordered, client: store.client) + let failed = ordered.filter { outcomes[$0]?.succeeded != true } + let seconds = Date().timeIntervalSince(started) + let noun = ordered.count == 1 ? "job \(ordered[0])" : "\(ordered.count) jobs" + if failed.isEmpty { + store.report("\(action.pastTense) \(noun) in \(elapsedText(seconds))") + } else { + let details = failed.map { "job \($0): \(outcomes[$0]?.message ?? "failed")" } + store.report("ERROR: \(failed.count) of \(ordered.count) not applied — \(details[0])") + store.fail("\(action.title) didn’t apply to \(failed.count) job\(failed.count == 1 ? "" : "s")", + (details + failed.compactMap { outcomes[$0]?.command }.prefix(1)).joined(separator: "\n")) + } + log.info("\(action.title, privacy: .public): \(ordered.count - failed.count) ok, \(failed.count) failed in \(seconds, format: .fixed(precision: 3)) s") + + store.noteChange() + await loadJobs() + await store.refreshQuietly() + } + + /// Tab-separated rows for the clipboard, for pasting into tickets. + func copyText(for ids: Set) -> String { + let header = ["ID", "Name", "User", "Size", "Pages", "State", "Reasons", "Submitted"].joined(separator: "\t") + let rows = sortedJobs.filter { ids.contains($0.id) }.map { job in + [String(job.id), job.name, job.user, Format.kilobytes(job.sizeK), job.pages.map(String.init) ?? "", + job.stateName, job.reasons.joined(separator: ","), job.submitted.map(Format.timestamp) ?? ""] + .joined(separator: "\t") + } + return ([header] + rows).joined(separator: "\n") + } +} + +extension JobSummary { + var pagesSortKey: Int { pages ?? -1 } + var reasonsText: String { reasons.joined(separator: ", ") } + var submittedSortKey: Date { submitted ?? .distantPast } +} diff --git a/Sources/CUPSAdminApp/PrinterDetailView.swift b/Sources/CUPSAdminApp/PrinterDetailView.swift new file mode 100644 index 0000000..541118f --- /dev/null +++ b/Sources/CUPSAdminApp/PrinterDetailView.swift @@ -0,0 +1,236 @@ +import AppKit +import CupsKit +import SwiftUI + +/// Printer header (grouped Form) above the Jobs or Options tab. +struct PrinterDetailView: View { + let model: PrinterDetailModel + let options: OptionsModel + let store: PrinterStore + let tab: DetailTab + + var body: some View { + VStack(spacing: 0) { + if let printer = model.printer { + PrinterHeaderView(printer: printer, ppdPath: model.ppdPath) + Divider() + } else if let error = model.loadError { + ContentUnavailableView("Can’t Load \(model.queue)", systemImage: "exclamationmark.triangle", + description: Text(error)) + .frame(maxHeight: 220) + Divider() + } + switch tab { + case .jobs: + JobsView(model: model, store: store) + case .options: + OptionsView(model: options) + } + } + .task(id: model.queue) { await model.load() } + .onChange(of: store.revision) { Task { await model.load() } } + } +} + +struct PrinterHeaderView: View { + let printer: IPPGroup + let ppdPath: String? + + private var state: Int? { printer.int("printer-state") } + private var accepting: Bool { printer.bool("printer-is-accepting-jobs") ?? true } + private var reasons: [String] { printer.strings("printer-state-reasons").filter { $0 != "none" } } + private var stateColor: Color { + if state == 5 || !accepting { return .red } + return state == 4 ? .blue : .green + } + + var body: some View { + HStack(alignment: .top, spacing: 0) { + Form { + Section("Status") { + LabeledContent("State") { + HStack(spacing: 6) { + Circle().fill(stateColor).frame(width: 8, height: 8) + Text(Format.printerState(state).capitalized) + } + } + LabeledContent("Reasons") { + Text(reasons.isEmpty ? "None" : reasons.joined(separator: ", ")) + .monospaced(!reasons.isEmpty) + } + LabeledContent("Message") { + Text(printer.string("printer-state-message").flatMap { $0.isEmpty ? nil : $0 } ?? "—") + .lineLimit(2) + } + LabeledContent("Accepting jobs", value: accepting ? "Yes" : "No") + LabeledContent("Shared", value: printer.bool("printer-is-shared") == true ? "Yes" : "No") + } + } + Form { + Section("Queue") { + LabeledContent("Device URI") { + Text(printer.string("device-uri") ?? "—") + .monospaced() + .lineLimit(1) + .truncationMode(.middle) + } + LabeledContent { + VStack(alignment: .trailing, spacing: 2) { + Text(printer.string("printer-make-and-model") ?? "—") + Text(ppdPath ?? "No PPD (driverless or raw)") + .font(.caption) + .monospaced(ppdPath != nil) + .foregroundStyle(.secondary) + } + } label: { + Text("Model") + } + LabeledContent("Location", value: printer.string("printer-location").flatMap { $0.isEmpty ? nil : $0 } ?? "—") + LabeledContent("Description", value: printer.string("printer-info").flatMap { $0.isEmpty ? nil : $0 } ?? "—") + } + } + } + .formStyle(.grouped) + .scrollDisabled(true) + .textSelection(.enabled) + .frame(height: 262) + } +} + +struct JobsView: View { + @Bindable var model: PrinterDetailModel + let store: PrinterStore + @SceneStorage("JobsTableColumns") private var columns = TableColumnCustomization() + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Picker("Show", selection: $model.jobFilter) { + ForEach(JobFilter.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + + Spacer(minLength: 12) + + if model.isWorking { ProgressView().controlSize(.small) } + jobButtons(for: model.selection) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.bar) + Divider() + + Table(model.sortedJobs, selection: $model.selection, sortOrder: $model.sortOrder, + columnCustomization: $columns) { + TableColumn("ID", value: \.id) { Text(String($0.id)).monospacedDigit() } + .width(min: 40, ideal: 56, max: 80) + .customizationID("id") + TableColumn("Name", value: \.name) + .width(min: 120, ideal: 220, max: 360) + .customizationID("name") + TableColumn("User", value: \.user) + .width(min: 60, ideal: 110, max: 200) + .customizationID("user") + TableColumn("Size", value: \.sizeK) { Text(Format.kilobytes($0.sizeK)).monospacedDigit() } + .width(min: 50, ideal: 64, max: 90) + .customizationID("size") + TableColumn("Pages", value: \.pagesSortKey) { Text($0.pages.map(String.init) ?? "—").monospacedDigit() } + .width(min: 44, ideal: 54, max: 80) + .customizationID("pages") + TableColumn("State", value: \.state) { Text($0.stateName.capitalized) } + .width(min: 70, ideal: 90, max: 120) + .customizationID("state") + // Reasons takes the spare width; the other columns are capped. + TableColumn("Reasons", value: \.reasonsText) { Text($0.reasonsText).monospaced() } + .width(min: 120, ideal: 320, max: .infinity) + .customizationID("reasons") + TableColumn("Submitted", value: \.submittedSortKey) { + Text($0.submitted.map(Format.timestamp) ?? "—").monospacedDigit() + } + .width(150) + .customizationID("submitted") + } + .alternatingRowBackgrounds(.disabled) + .contextMenu(forSelectionType: Int.self) { ids in + jobMenu(for: ids) + } + .onCopyCommand { + [NSItemProvider(object: model.copyText(for: model.selection) as NSString)] + } + .overlay { + if model.jobs.isEmpty { + ContentUnavailableView(emptyTitle, systemImage: "doc.text.magnifyingglass") + } + } + .padding(.bottom, StatusBar.height) + } + } + + private var emptyTitle: String { + switch model.jobFilter { + case .active: return "No Active Jobs" + case .completed: return "No Completed Jobs" + case .all: return "No Jobs" + } + } + + private var otherQueues: [String] { + store.printers.map(\.name).filter { $0 != model.queue } + } + + @ViewBuilder private func jobButtons(for ids: Set) -> some View { + Button { run(.cancel, ids) } label: { Label("Cancel", systemImage: "xmark.circle") } + .disabled(!model.canPerform(.cancel, on: ids)) + .help("Cancel the selected jobs") + Button { run(.hold, ids) } label: { Label("Hold", systemImage: "pause.circle") } + .disabled(!model.canPerform(.hold, on: ids)) + .help("Hold the selected jobs (lp -H hold)") + Button { run(.release, ids) } label: { Label("Release", systemImage: "play.circle") } + .disabled(!model.canPerform(.release, on: ids)) + .help("Release held jobs (lp -H resume)") + Menu { + ForEach(otherQueues, id: \.self) { queue in + Button(queue) { run(.move(to: queue), ids) } + } + } label: { + Label("Move To", systemImage: "arrow.right.circle") + } + .fixedSize() + .disabled(!model.canPerform(.move(to: ""), on: ids) || otherQueues.isEmpty) + .help("Move the selected jobs to another queue (lpmove)") + } + + @ViewBuilder private func jobMenu(for ids: Set) -> some View { + Group { + Button { run(.cancel, ids) } label: { Label("Cancel", systemImage: "xmark.circle") } + .disabled(!model.canPerform(.cancel, on: ids)) + Button { run(.hold, ids) } label: { Label("Hold", systemImage: "pause.circle") } + .disabled(!model.canPerform(.hold, on: ids)) + Button { run(.release, ids) } label: { Label("Release", systemImage: "play.circle") } + .disabled(!model.canPerform(.release, on: ids)) + Menu { + ForEach(otherQueues, id: \.self) { queue in + Button(queue) { run(.move(to: queue), ids) } + } + } label: { + Label("Move To", systemImage: "arrow.right.circle") + } + .disabled(!model.canPerform(.move(to: ""), on: ids)) + Divider() + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(model.copyText(for: ids), forType: .string) + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .disabled(ids.isEmpty) + } + .labelStyle(.titleAndIcon) + } + + private func run(_ action: JobAction, _ ids: Set) { + Task { await model.perform(action, on: ids) } + } +} diff --git a/Sources/CUPSAdminApp/PrinterStore.swift b/Sources/CUPSAdminApp/PrinterStore.swift new file mode 100644 index 0000000..c652c17 --- /dev/null +++ b/Sources/CUPSAdminApp/PrinterStore.swift @@ -0,0 +1,291 @@ +import AppKit +import CupsKit +import Foundation +import Observation +import OSLog +import SwiftUI + +/// One sidebar row's worth of queue state, from CUPS-Get-Printers. +struct PrinterSummary: Identifiable, Hashable { + enum Status { + case idle, processing, stopped + + var color: Color { + switch self { + case .idle: return .green + case .processing: return .blue + case .stopped: return .red + } + } + } + + let name: String + let state: Int? + let reasons: [String] + let stateMessage: String? + let accepting: Bool + let info: String? + let location: String? + let deviceURI: String? + let jobCount: Int + + var id: String { name } + + static let requestedAttributes = [ + "printer-name", "printer-state", "printer-state-reasons", "printer-state-message", + "printer-is-accepting-jobs", "printer-info", "printer-location", "device-uri", "queued-job-count", + ] + + init(_ attributes: IPPGroup) { + name = attributes.string("printer-name") ?? "?" + state = attributes.int("printer-state") + reasons = attributes.strings("printer-state-reasons").filter { $0 != "none" } + stateMessage = attributes.string("printer-state-message").flatMap { $0.isEmpty ? nil : $0 } + accepting = attributes.bool("printer-is-accepting-jobs") ?? true + info = attributes.string("printer-info").flatMap { $0.isEmpty ? nil : $0 } + location = attributes.string("printer-location").flatMap { $0.isEmpty ? nil : $0 } + deviceURI = attributes.string("device-uri").flatMap { $0.isEmpty ? nil : $0 } + jobCount = attributes.int("queued-job-count") ?? 0 + } + + var isPaused: Bool { state == 5 } + + /// Red when stopped or rejecting jobs, blue while printing, green otherwise. + var status: Status { + if state == 5 || !accepting { return .stopped } + return state == 4 ? .processing : .idle + } + + /// Location, else description, else nothing — never text that just restates the name, an IP or a URI. + var secondaryLine: String? { + [location, info].compactMap { $0 }.first { !Self.restates($0, name: name) } + } + + static func restates(_ text: String, name: String) -> Bool { + func letters(_ s: String) -> String { s.lowercased().filter { $0.isLetter || $0.isNumber } } + if letters(text) == letters(name) { return true } + if text.contains("://") { return true } + return text.range(of: #"^\d{1,3}(\.\d{1,3}){3}$"#, options: .regularExpression) != nil + } + + /// "Idle", "Stopped — paused", "Idle, rejecting jobs". + var stateSummary: String { + var text = Format.printerState(state).capitalized + if !reasons.isEmpty { text += " — " + reasons.joined(separator: ", ") } + if !accepting { text += ", rejecting jobs" } + return text + } + + func matches(search: String) -> Bool { + guard !search.isEmpty else { return true } + return [name, location, info, deviceURI].compactMap { $0 } + .contains { $0.localizedCaseInsensitiveContains(search) } + } +} + +@MainActor +@Observable +final class PrinterStore { + struct Failure: Identifiable { + let id = UUID() + let title: String + let message: String + } + + private(set) var printers: [PrinterSummary] = [] + private(set) var isRefreshing = false + private(set) var hasLoaded = false + private(set) var errorMessage: String? + private(set) var statusLine = "" + /// Bumped after any write, so open detail views reload. + private(set) var revision = 0 + /// The server default printer (CUPS-Get-Default), if any. + private(set) var defaultPrinter: String? + /// Parsed PPDs, for enabling Quick Actions per printer. A queue with no PPD maps to nil. + private(set) var ppds: [String: PPD?] = [:] + var failure: Failure? + + let client = CupsClient() + private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "store") + + /// "21 printers · 0 active jobs" for the status bar. + var summaryLine: String { + guard hasLoaded, errorMessage == nil else { return "" } + let jobs = printers.reduce(0) { $0 + $1.jobCount } + return "\(printers.count) printer\(printers.count == 1 ? "" : "s") · \(jobs) active job\(jobs == 1 ? "" : "s")" + } + + /// Last-action line for the status bar, from any view. + func report(_ line: String) { + statusLine = line + } + + func fail(_ title: String, _ message: String) { + failure = Failure(title: title, message: Self.explain(message)) + } + + func refresh() async { + guard !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + let started = Date() + log.info("refresh started") + + do { + let groups = try await client.getPrinters(requested: PrinterSummary.requestedAttributes) + printers = groups.map(PrinterSummary.init).sorted { + $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + errorMessage = nil + defaultPrinter = try? await client.getDefaultPrinter() + let seconds = Date().timeIntervalSince(started) + statusLine = "Refreshed \(printers.count) printers in \(elapsedText(seconds))" + log.info("refresh finished: \(self.printers.count) printers in \(seconds, format: .fixed(precision: 3)) s") + } catch { + errorMessage = String(describing: error) + statusLine = "ERROR: \(error)" + log.error("refresh failed: \(String(describing: error), privacy: .public)") + } + hasLoaded = true + } + + /// Pause/Resume/Accept/Reject/Cancel All, verified against cupsd, reported in the status bar. + func perform(_ action: PrinterAction, on queue: String) async { + let started = Date() + log.info("\(action.title, privacy: .public) \(queue, privacy: .public)") + do { + let outcome = try await QueueActions.perform(action, queue: queue, client: client) + let seconds = Date().timeIntervalSince(started) + if outcome.succeeded { + report("\(action.pastTense) \(queue) in \(elapsedText(seconds))") + log.info("\(action.title, privacy: .public) \(queue, privacy: .public) ok in \(seconds, format: .fixed(precision: 3)) s") + } else { + let message = outcome.message ?? "failed" + report("ERROR: \(action.title) \(queue): \(message)") + fail("Couldn’t \(action.title.lowercased()) \(queue)", "\(message)\n\n\(outcome.command)") + log.error("\(action.title, privacy: .public) \(queue, privacy: .public) failed: \(message, privacy: .public)") + } + } catch { + report("ERROR: \(action.title) \(queue): \(error)") + fail("Couldn’t \(action.title.lowercased()) \(queue)", String(describing: error)) + } + revision += 1 + await refreshQuietly() + } + + /// Refresh the sidebar without replacing the last action's status line. + func refreshQuietly() async { + let line = statusLine + await refresh() + statusLine = line + } + + func noteChange(queue: String? = nil) { + if let queue { ppds[queue] = nil } + revision += 1 + } + + // MARK: PPDs for Quick Actions + + /// Fetches PPDs not yet cached, in the background of a refresh. + func loadPPDs() async { + for printer in printers where ppds[printer.name] == nil { + let text = try? await client.getPPD(queue: printer.name) + ppds[printer.name] = .some(text.map(PPD.init(text:))) + } + } + + /// nil when the action can run on the queue, else why not (shown as the menu item's tooltip). + func unavailableReason(_ action: QuickAction, queue: String) -> String? { + if case .defaultPrinter = action.effect { + return defaultPrinter == queue ? "\(queue) is already the default printer" : nil + } + guard let cached = ppds[queue] else { return "Checking \(queue)’s PPD…" } + if action.isAvailable(for: cached) { return nil } + if case .failure(let unavailable) = action.changes(for: cached, value: action.input == .userCode ? "0" : nil) { + return unavailable.reason + } + return "Not available on \(queue)" + } + + // MARK: per-printer actions + + func printTestPage(_ queue: String) async { + let started = Date() + do { + let (outcome, jobID) = try await QueueActions.printTestPage(queue: queue, client: client) + if outcome.succeeded, let jobID { + report("Sent test page to \(queue) as job \(jobID) in \(elapsedText(since: started))") + log.info("test page \(queue, privacy: .public) job \(jobID)") + } else { + report("ERROR: test page on \(queue): \(outcome.message ?? "failed")") + fail("Couldn’t print a test page on \(queue)", "\(outcome.message ?? "failed")\n\n\(outcome.command)") + } + } catch { + report("ERROR: test page on \(queue): \(error)") + fail("Couldn’t print a test page on \(queue)", String(describing: error)) + } + noteChange() + await refreshQuietly() + } + + /// Returns true when the queue is gone. + func delete(_ queue: String) async -> Bool { + let started = Date() + var deleted = false + do { + let outcome = try await QueueActions.delete(queue: queue, client: client) + deleted = outcome.succeeded + if deleted { + report("Deleted \(queue) in \(elapsedText(since: started))") + log.info("deleted \(queue, privacy: .public)") + } else { + report("ERROR: delete \(queue): \(outcome.message ?? "failed")") + fail("Couldn’t delete \(queue)", "\(outcome.message ?? "failed")\n\n\(outcome.command)") + } + } catch { + report("ERROR: delete \(queue): \(error)") + fail("Couldn’t delete \(queue)", String(describing: error)) + } + ppds[queue] = nil + noteChange() + await refreshQuietly() + return deleted + } + + func showPPDInFinder(_ queue: String) { + let url = URL(fileURLWithPath: "/private/etc/cups/ppd/\(queue).ppd") + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + func hasPPDFile(_ queue: String) -> Bool { + FileManager.default.fileExists(atPath: "/private/etc/cups/ppd/\(queue).ppd") + } + + func copyDeviceURI(_ printer: PrinterSummary) { + guard let uri = printer.deviceURI else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(uri, forType: .string) + report("Copied device URI for \(printer.name)") + } + + /// Tool/IPP output plus the fix when it's a permissions problem. + static func explain(_ message: String) -> String { + let lower = message.lowercased() + if lower.contains("forbidden") || lower.contains("not authorized") || lower.contains("unauthorized") + || lower.contains("authentication") || lower.contains("not-authorized") { + return message + "\n\nThis needs an admin account or membership in _lpadmin:\n" + + "sudo dseditgroup -o edit -a \(NSUserName()) -t user _lpadmin" + } + return message + } +} + +/// "42 ms" under a tenth of a second, else "0.4 s". +func elapsedText(_ seconds: TimeInterval) -> String { + seconds < 0.1 ? "\(max(1, Int((seconds * 1000).rounded()))) ms" : String(format: "%.1f s", seconds) +} + +func elapsedText(since start: Date) -> String { + elapsedText(Date().timeIntervalSince(start)) +} diff --git a/Sources/CUPSAdminApp/QuickActionSheet.swift b/Sources/CUPSAdminApp/QuickActionSheet.swift new file mode 100644 index 0000000..ed4c01a --- /dev/null +++ b/Sources/CUPSAdminApp/QuickActionSheet.swift @@ -0,0 +1,262 @@ +import CupsKit +import Observation +import SwiftUI + +/// One Quick Action on one printer: shows what will change, asks only for what's needed, runs lpadmin, reads back. +@MainActor +@Observable +final class QuickActionModel: Identifiable { + enum Phase: Equatable { + case loading, ready, running, finished(succeeded: Bool) + } + + let id = UUID() + let action: QuickAction + let queue: String + var code = "" + var showCurrentCode = false + private(set) var phase: Phase = .loading + private(set) var state: OptionState? + private(set) var currentDefault: String? + private(set) var readBack: [String: OptionReadBack] = [:] + private(set) var resultMessage: String? + private let store: PrinterStore + + init(action: QuickAction, queue: String, store: PrinterStore) { + self.action = action + self.queue = queue + self.store = store + } + + func load() async { + do { + state = try await OptionState.load(client: store.client, queue: queue) + currentDefault = try await store.client.getDefaultPrinter() + phase = .ready + } catch { + resultMessage = String(describing: error) + phase = .finished(succeeded: false) + } + } + + var currentCode: String? { QuickAction.currentUserCode(state?.ppd) } + + var validationError: String? { action.validationError(code, ppd: state?.ppd) } + + /// What will be written, or why the action can't run here. + func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> { + action.changes(for: state?.ppd, value: clearing ? "" : code) + } + + var command: String { + if case .defaultPrinter = action.effect { return CupsTools.commandLine(CupsTools.lpadminPath, ["-d", queue]) } + guard case .success(let changes) = plannedChanges() else { return "" } + return OptionApplier.displayCommand(queue: queue, changes: changes) + } + + /// `lpstat` and apps use ~/.cups/lpoptions' Default line over the server default. + var userDefaultNote: String? { + guard case .defaultPrinter = action.effect, + let text = try? String(contentsOf: LpOptionsFile.userFileURL, encoding: .utf8), + let line = text.split(whereSeparator: \.isNewline).first(where: { $0.hasPrefix("Default ") }) else { return nil } + let name = line.split(separator: " ")[1].split(separator: "/")[0] + guard name != queue else { return nil } + let exists = store.printers.contains { $0.name == name } + return "Your ~/.cups/lpoptions has “Default \(name)”, which overrides the server default for your account" + + (exists ? "." : " — and there is no queue named \(name) on this Mac.") + } + + func run(clearing: Bool = false) async { + phase = .running + let started = Date() + do { + let outcome = try await action.run(queue: queue, value: clearing ? "" : (action.input == .userCode ? code : nil), + client: store.client) + readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) }) + state = try? await OptionState.load(client: store.client, queue: queue) + let seconds = elapsedText(since: started) + let title = clearing ? "Cleared user code" : action.title.replacingOccurrences(of: "…", with: "") + if outcome.succeeded { + resultMessage = nil + store.report("\(title) on \(queue) in \(seconds)") + } else { + resultMessage = outcome.message ?? "Not applied" + store.report("ERROR: \(title) on \(queue): \(resultMessage!)") + } + phase = .finished(succeeded: outcome.succeeded) + } catch let unavailable as QuickActionUnavailable { + resultMessage = unavailable.reason + phase = .finished(succeeded: false) + } catch { + resultMessage = PrinterStore.explain(String(describing: error)) + store.report("ERROR: \(action.title) on \(queue): \(error)") + phase = .finished(succeeded: false) + } + store.noteChange(queue: queue) + await store.refreshQuietly() + } +} + +struct QuickActionSheet: View { + @Bindable var model: QuickActionModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Label(model.action.title.replacingOccurrences(of: "…", with: ""), systemImage: model.action.systemImage) + .font(.title3.weight(.semibold)) + Text("\(model.queue) · saved as the queue default for every user on this Mac") + .foregroundStyle(.secondary) + } + + switch model.phase { + case .loading: + ProgressView().frame(maxWidth: .infinity) + default: + content + } + + HStack { + if model.action.input == .userCode, model.currentCode != nil, model.phase == .ready { + Button("Clear Code", role: .destructive) { Task { await model.run(clearing: true) } } + } + Spacer() + switch model.phase { + case .finished: + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + default: + Button("Cancel", role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Apply") { Task { await model.run() } } + .keyboardShortcut(.defaultAction) + .disabled(!canApply) + } + } + } + .padding(20) + .frame(width: 520) + .task { await model.load() } + } + + private var canApply: Bool { + guard model.phase == .ready else { return false } + if model.action.input == .userCode { + return !model.code.isEmpty && model.validationError == nil + } + if case .failure = model.plannedChanges() { return false } + return true + } + + @ViewBuilder private var content: some View { + if model.action.input == .userCode { + Form { + LabeledContent("Current code") { + HStack(spacing: 8) { + if let current = model.currentCode { + Text(model.showCurrentCode ? current : String(repeating: "•", count: current.count)) + .monospaced() + .textSelection(.enabled) + Toggle("Show", isOn: $model.showCurrentCode) + .toggleStyle(.checkbox) + } else { + Text("None").foregroundStyle(.secondary) + } + } + } + LabeledContent { + TextField("User code", text: $model.code, prompt: Text("Digits")) + .labelsHidden() + .textFieldStyle(.roundedBorder) + .monospacedDigit() + .frame(width: 160) + .disabled(model.phase != .ready) + } label: { + Text("New code") + if let error = model.validationError { + Text(error).foregroundStyle(.red) + } + } + } + .formStyle(.grouped) + .scrollDisabled(true) + .frame(height: 130) + } + + if case .defaultPrinter = model.action.effect { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { + GridRow { + Text("Server default").foregroundStyle(.secondary) + Text(model.currentDefault ?? "None") + Image(systemName: "arrow.right").foregroundStyle(.secondary) + Text(model.queue) + resultIcon(applied: model.phase == .finished(succeeded: true) ? true + : model.phase == .finished(succeeded: false) ? false : nil) + } + } + if let note = model.userDefaultNote { + Label(note, systemImage: "info.circle") + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + switch model.plannedChanges() { + case .failure(let unavailable) where model.action.input == .none: + Label(unavailable.reason, systemImage: "exclamationmark.triangle.fill") + .symbolRenderingMode(.multicolor) + default: + changeGrid + } + } + + if !model.command.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Command").font(.callout.weight(.semibold)).foregroundStyle(.secondary) + Text(model.command) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + + if let message = model.resultMessage { + Label(message, systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + + @ViewBuilder private var changeGrid: some View { + let changes = (try? model.plannedChanges().get()) ?? [] + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { + GridRow { + Text("Setting"); Text("Current"); Text(""); Text("New"); Text("") + } + .font(.callout.weight(.semibold)) + .foregroundStyle(.secondary) + Divider() + ForEach(changes, id: \.key) { change in + GridRow { + Text(change.key).monospaced() + Text(model.state?.queueValue(change.key) ?? "—").foregroundStyle(.secondary).monospaced() + Image(systemName: "arrow.right").foregroundStyle(.secondary) + Text(model.action.input == .userCode && change.key == "RIUserCode" && !model.code.isEmpty + ? "Custom.\(model.code)" : change.value).monospaced() + resultIcon(applied: model.readBack[change.key]?.applied) + .help(model.readBack[change.key]?.reason ?? "") + } + } + } + .textSelection(.enabled) + } + + @ViewBuilder private func resultIcon(applied: Bool?) -> some View { + switch applied { + case true?: Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + case false?: Image(systemName: "xmark.circle.fill").foregroundStyle(.red) + case nil: + if model.phase == .running { ProgressView().controlSize(.mini) } else { Color.clear.frame(width: 14, height: 14) } + } + } +} diff --git a/Sources/CupsKit/CupsTools.swift b/Sources/CupsKit/CupsTools.swift new file mode 100644 index 0000000..dbf0c50 --- /dev/null +++ b/Sources/CupsKit/CupsTools.swift @@ -0,0 +1,135 @@ +import Foundation + +/// Output of one CUPS command-line tool run. +public struct ToolResult { + public let executable: String + public let arguments: [String] + public let status: Int32 + public let standardOutput: Data + public let standardError: Data + + public var commandLine: String { CupsTools.commandLine(executable, arguments) } + public var succeeded: Bool { status == 0 } + /// stderr then stdout as text, trimmed — what to show a user when a tool fails. + public var message: String { + (String(decoding: standardError, as: UTF8.self) + String(decoding: standardOutput, as: UTF8.self)) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +/// Writes go through the CUPS tools (lpadmin handles _lpadmin auth and IPP Everywhere PPD generation). +/// Nothing here prints; callers decide what to show. +public enum CupsTools { + public static let lpadminPath = "/usr/sbin/lpadmin" + public static let cupsenablePath = "/usr/sbin/cupsenable" + public static let cupsdisablePath = "/usr/sbin/cupsdisable" + public static let cupsacceptPath = "/usr/sbin/cupsaccept" + public static let cupsrejectPath = "/usr/sbin/cupsreject" + public static let cancelPath = "/usr/bin/cancel" + public static let lpPath = "/usr/bin/lp" + public static let lpmovePath = "/usr/sbin/lpmove" + public static let lpinfoPath = "/usr/sbin/lpinfo" + /// The CUPS standard test page (what the web UI's Print Test Page sent). + public static let testPagePath = "/usr/share/cups/data/testprint" + + public static func lpadmin(_ arguments: [String]) throws -> ToolResult { + try run(lpadminPath, arguments) + } + + public static func enable(queue: String) throws -> ToolResult { + try run(cupsenablePath, [queue]) + } + + public static func disable(queue: String, reason: String? = nil) throws -> ToolResult { + try run(cupsdisablePath, (reason.map { ["-r", $0] } ?? []) + [queue]) + } + + public static func accept(queue: String) throws -> ToolResult { + try run(cupsacceptPath, [queue]) + } + + public static func reject(queue: String, reason: String? = nil) throws -> ToolResult { + try run(cupsrejectPath, (reason.map { ["-r", $0] } ?? []) + [queue]) + } + + /// `cancel -a queue`: every job on the queue. + public static func cancelAll(queue: String) throws -> ToolResult { + try run(cancelPath, ["-a", queue]) + } + + /// `lp -i id -H hold`. + public static func hold(jobID: Int) throws -> ToolResult { + try run(lpPath, ["-i", String(jobID), "-H", "hold"]) + } + + /// `lp -i id -H resume`. + public static func release(jobID: Int) throws -> ToolResult { + try run(lpPath, ["-i", String(jobID), "-H", "resume"]) + } + + /// `lpmove id destination`. + public static func move(jobID: Int, to destination: String) throws -> ToolResult { + try run(lpmovePath, [String(jobID), destination]) + } + + /// `lpadmin -d queue`: the server default printer. + public static func setDefault(queue: String) throws -> ToolResult { + try run(lpadminPath, ["-d", queue]) + } + + /// `lpadmin -x queue`. + public static func delete(queue: String) throws -> ToolResult { + try run(lpadminPath, ["-x", queue]) + } + + /// `lp -d queue -t "Test Page" file`; the job id is in stdout ("request id is Q-12 (1 file(s))"). + public static func printTestPage(queue: String, file: String) throws -> ToolResult { + try run(lpPath, ["-d", queue, "-t", "Test Page", file]) + } + + /// `lpinfo -m`: every driver cupsd knows, as (model, name). + public static func models() throws -> [(model: String, name: String)] { + let result = try run(lpinfoPath, ["-m"]) + guard result.succeeded else { throw CupsAdminError.failed("lpinfo -m: \(result.message)") } + return String(decoding: result.standardOutput, as: UTF8.self).split(whereSeparator: \.isNewline).compactMap { line in + let parts = line.split(separator: " ", maxSplits: 1) + guard parts.count == 2 else { return nil } + return (String(parts[0]), String(parts[1])) + } + } + + /// Runs a tool and captures stdout/stderr. stdin is inherited; libcups password prompts use /dev/tty. + public static func run(_ executable: String, _ arguments: [String]) throws -> ToolResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + try process.run() + + // Drain both pipes concurrently so a chatty tool can't block on a full pipe buffer. + final class Box: @unchecked Sendable { var data = Data() } + let out = Box(), err = Box() + let group = DispatchGroup() + for (pipe, box) in [(outPipe, out), (errPipe, err)] { + group.enter() + DispatchQueue.global().async { + box.data = pipe.fileHandleForReading.readDataToEndOfFile() + group.leave() + } + } + process.waitUntilExit() + group.wait() + + return ToolResult(executable: executable, arguments: arguments, status: process.terminationStatus, + standardOutput: out.data, standardError: err.data) + } + + /// Shell-style display of a command, quoting arguments that contain spaces or quotes. + public static func commandLine(_ executable: String, _ arguments: [String]) -> String { + ([executable] + arguments).map { $0.contains(where: { " '\"$".contains($0) }) ? "'\($0)'" : $0 } + .joined(separator: " ") + } +} diff --git a/Sources/CupsKit/Format.swift b/Sources/CupsKit/Format.swift new file mode 100644 index 0000000..032d6a5 --- /dev/null +++ b/Sources/CupsKit/Format.swift @@ -0,0 +1,101 @@ +import Foundation + +public enum Format { + public static func printerState(_ state: Int?) -> String { + switch state { + case 3: return "idle" + case 4: return "processing" + case 5: return "stopped" + case let s?: return "state-\(s)" + case nil: return "?" + } + } + + public static func jobState(_ state: Int?) -> String { + switch state { + case 3: return "pending" + case 4: return "held" + case 5: return "processing" + case 6: return "stopped" + case 7: return "canceled" + case 8: return "aborted" + case 9: return "completed" + case let s?: return "state-\(s)" + case nil: return "?" + } + } + + /// CUPS reports most *-time attributes as integer seconds since the epoch. + public static func epoch(_ seconds: Int?) -> String { + guard let seconds, seconds > 0 else { return "-" } + return timestamp(Date(timeIntervalSince1970: TimeInterval(seconds))) + } + + public static func timestamp(_ date: Date) -> String { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + return f.string(from: date) + } + + public static func kilobytes(_ k: Int?) -> String { + guard let k else { return "-" } + if k < 1024 { return "\(k)K" } + return String(format: "%.1fM", Double(k) / 1024) + } + + public static func yesNo(_ b: Bool?) -> String { + guard let b else { return "?" } + return b ? "yes" : "no" + } + + /// state-reasons without the "none" placeholder. + public static func reasons(_ values: [String]) -> String { + let real = values.filter { $0 != "none" } + return real.isEmpty ? "none" : real.joined(separator: ",") + } + + public static func value(_ value: IPPValue, attribute: String) -> String { + switch value { + case .integer(let i): + if attribute.hasSuffix("-time") || attribute.hasPrefix("time-at-"), i > 946_684_800 { + return "\(i) (\(epoch(Int(i))))" + } + return String(i) + case .enumeration(let e): + switch attribute { + case "printer-state": return printerState(Int(e)) + case "job-state": return jobState(Int(e)) + case "print-quality-default", "print-quality-supported": + return [3: "draft", 4: "normal", 5: "high"][e] ?? String(e) + default: return String(e) + } + case .boolean(let b): return b ? "true" : "false" + case .string(_, let s): return s + case .stringWithLanguage(_, let language, let s): return "\(s) [\(language)]" + case .octetString(let d): + if let s = String(data: d, encoding: .utf8), !s.contains(where: { $0.isASCII && $0.asciiValue! < 0x20 }) { + return s + } + return "<\(d.count) bytes>" + case .dateTime(let date): return timestamp(date) + case .resolution(let x, let y, let units): + let unit = units == 3 ? "dpi" : units == 4 ? "dpcm" : "units\(units)" + return x == y ? "\(x)\(unit)" : "\(x)x\(y)\(unit)" + case .range(let lower, let upper): return "\(lower)-\(upper)" + case .collection(let members): + let inner = members.map { m in + "\(m.name)=" + m.values.map { self.value($0, attribute: m.name) }.joined(separator: ",") + } + return "{" + inner.joined(separator: " ") + "}" + case .outOfBand(let tag): + switch tag { + case .unsupported: return "(unsupported)" + case .unknown: return "(unknown)" + case .noValue: return "(no-value)" + default: return "(out-of-band 0x\(String(tag.rawValue, radix: 16)))" + } + case .other(let tag, let d): + return "" + } + } +} diff --git a/Sources/CupsKit/IPP/CupsClient.swift b/Sources/CupsKit/IPP/CupsClient.swift new file mode 100644 index 0000000..4533668 --- /dev/null +++ b/Sources/CupsKit/IPP/CupsClient.swift @@ -0,0 +1,173 @@ +import Foundation + +public enum CupsAdminError: Error, CustomStringConvertible { + case usage(String) + case transport(String) + case ipp(status: UInt16, message: String?) + case failed(String) + + public var description: String { + switch self { + case .usage(let m): return m + case .transport(let m), .failed(let m): return m + case .ipp(let status, let message): + let name = IPPStatus.name(status) + if let message, !message.isEmpty { return "\(name): \(message)" } + return name + } + } +} + +public enum IPPStatus { + public static let notFound: UInt16 = 0x0406 + + public static func name(_ code: UInt16) -> String { + let known: [UInt16: String] = [ + 0x0400: "client-error-bad-request", + 0x0401: "client-error-forbidden", + 0x0402: "client-error-not-authenticated", + 0x0403: "client-error-not-authorized", + 0x0404: "client-error-not-possible", + 0x0405: "client-error-timeout", + 0x0406: "client-error-not-found", + 0x0407: "client-error-gone", + 0x040B: "client-error-attributes-or-values-not-supported", + 0x0500: "server-error-internal-error", + 0x0501: "server-error-operation-not-supported", + 0x0502: "server-error-service-unavailable", + 0x0503: "server-error-version-not-supported", + ] + return known[code] ?? String(format: "ipp-status-0x%04x", code) + } +} + +/// Talks IPP to the local cupsd over its Unix domain socket (see UnixSocketHTTP for why not TCP 631). +public final class CupsClient { + public let socketPath = "/private/var/run/cupsd" + private var nextRequestID: Int32 = 1 + + public init() {} + + // MARK: operations + + /// CUPS-Get-Printers: one printer-attributes group per queue (printers and classes). + public func getPrinters(requested: [String]) async throws -> [IPPGroup] { + let request = makeRequest(IPPOperation.cupsGetPrinters, target: serverURI, requested: requested) + return try await send(request, resource: "/").groups(.printerAttributes) + } + + public func getPrinterAttributes(queue: String, requested: [String]) async throws -> IPPGroup { + let request = makeRequest(IPPOperation.getPrinterAttributes, target: printerURI(queue), requested: requested) + let response = try await send(request, resource: printerResource(queue)) + guard let group = response.groups(.printerAttributes).first else { + throw CupsAdminError.transport("cupsd returned no printer attributes for \(queue)") + } + return group + } + + public func queueExists(_ queue: String) async throws -> Bool { + do { + _ = try await getPrinterAttributes(queue: queue, requested: ["printer-name"]) + return true + } catch CupsAdminError.ipp(let status, _) where status == IPPStatus.notFound { + return false + } + } + + /// CUPS-Get-Default: the server default printer's name, or nil when there is none. + public func getDefaultPrinter() async throws -> String? { + let request = makeRequest(IPPOperation.cupsGetDefault, target: serverURI, requested: ["printer-name"]) + do { + return try await send(request, resource: "/").groups(.printerAttributes).first?.string("printer-name") + } catch CupsAdminError.ipp(let status, _) where status == IPPStatus.notFound { + return nil + } + } + + /// Get-Jobs. `queue` nil means all queues. `which` is "not-completed", "completed" or "all". + public func getJobs(queue: String?, which: String, requested: [String]) async throws -> [IPPGroup] { + let extra = [ + IPPAttribute.string("which-jobs", .keyword, [which]), + IPPAttribute.boolean("my-jobs", false), + ] + let request = makeRequest(IPPOperation.getJobs, target: queue.map(printerURI) ?? serverURI, + extra: extra, requested: requested) + return try await send(request, resource: queue.map(printerResource) ?? "/").groups(.jobAttributes) + } + + public func getJobAttributes(jobID: Int, requested: [String]) async throws -> IPPGroup { + let request = makeRequest(IPPOperation.getJobAttributes, target: jobURI(jobID), requested: requested) + guard let group = try await send(request, resource: "/jobs/").groups(.jobAttributes).first else { + throw CupsAdminError.transport("cupsd returned no attributes for job \(jobID)") + } + return group + } + + public func cancelJob(jobID: Int) async throws { + let request = makeRequest(IPPOperation.cancelJob, target: jobURI(jobID), requested: []) + _ = try await send(request, resource: "/jobs/") + } + + /// The queue's PPD text (GET /printers/.ppd), or nil if the queue has no PPD. + public func getPPD(queue: String) async throws -> String? { + let response = try UnixSocketHTTP.get(socketPath: socketPath, resource: printerResource(queue) + ".ppd") + switch response.status { + case 200: return String(decoding: response.body, as: UTF8.self) + case 404: return nil + default: throw CupsAdminError.transport("HTTP \(response.status) fetching PPD for \(queue)") + } + } + + // MARK: plumbing + + private typealias Target = (name: String, uri: String) + + private var serverURI: Target { ("printer-uri", "ipp://localhost/") } + + private func printerURI(_ queue: String) -> Target { + ("printer-uri", "ipp://localhost\(printerResource(queue))") + } + + private func jobURI(_ jobID: Int) -> Target { + ("job-uri", "ipp://localhost/jobs/\(jobID)") + } + + private func printerResource(_ queue: String) -> String { + let escaped = queue.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed.subtracting(["/"])) ?? queue + return "/printers/\(escaped)" + } + + private func makeRequest(_ operation: UInt16, target: Target, extra: [IPPAttribute] = [], + requested: [String]) -> IPPMessage { + // Order matters: charset, natural-language, target, then the rest (RFC 8011 §4.1.4). + var attributes: [IPPAttribute] = [ + .string("attributes-charset", .charset, ["utf-8"]), + .string("attributes-natural-language", .naturalLanguage, ["en"]), + .string(target.name, .uri, [target.uri]), + .string("requesting-user-name", .nameWithoutLanguage, [NSUserName()]), + ] + attributes += extra + if !requested.isEmpty { + attributes.append(.string("requested-attributes", .keyword, requested)) + } + return IPPMessage(code: operation, groups: [IPPGroup(tag: .operationAttributes, attributes: attributes)]) + } + + private func send(_ message: IPPMessage, resource: String) async throws -> IPPMessage { + var message = message + message.requestID = nextRequestID + nextRequestID += 1 + + let response = try UnixSocketHTTP.post(socketPath: socketPath, resource: resource, + contentType: "application/ipp", body: IPPEncoder.encode(message)) + guard response.status == 200 else { + throw CupsAdminError.transport("HTTP \(response.status) from cupsd for POST \(resource)") + } + + let reply = try IPPDecoder.decode(response.body) + guard reply.isSuccess else { + throw CupsAdminError.ipp(status: reply.code, message: reply.operationGroup?.string("status-message")) + } + return reply + } +} diff --git a/Sources/CupsKit/IPP/IPPCoding.swift b/Sources/CupsKit/IPP/IPPCoding.swift new file mode 100644 index 0000000..78accc0 --- /dev/null +++ b/Sources/CupsKit/IPP/IPPCoding.swift @@ -0,0 +1,272 @@ +import Foundation + +/// RFC 8010 wire-format encoder. +enum IPPEncoder { + static func encode(_ message: IPPMessage) -> Data { + var out: [UInt8] = [message.versionMajor, message.versionMinor] + out.appendUInt16(message.code) + out.appendInt32(message.requestID) + for group in message.groups { + out.append(group.tag.rawValue) + for attribute in group.attributes { + for (index, value) in attribute.values.enumerated() { + // Additional values of a 1setOf carry an empty name. + write(value, name: index == 0 ? attribute.name : "", into: &out) + } + } + } + out.append(IPPTag.endOfAttributes.rawValue) + return Data(out) + message.data + } + + private static func write(_ value: IPPValue, name: String, into out: inout [UInt8]) { + out.append(value.tag.rawValue) + out.appendLengthPrefixed(Array(name.utf8)) + + switch value { + case .integer(let i), .enumeration(let i): + out.appendUInt16(4) + out.appendInt32(i) + case .boolean(let b): + out.appendUInt16(1) + out.append(b ? 1 : 0) + case .string(_, let s): + out.appendLengthPrefixed(Array(s.utf8)) + case .stringWithLanguage(_, let language, let s): + var payload: [UInt8] = [] + payload.appendLengthPrefixed(Array(language.utf8)) + payload.appendLengthPrefixed(Array(s.utf8)) + out.appendLengthPrefixed(payload) + case .octetString(let d), .other(_, let d): + out.appendLengthPrefixed([UInt8](d)) + case .dateTime(let date): + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(secondsFromGMT: 0)! + let c = cal.dateComponents([.year, .month, .day, .hour, .minute, .second, .nanosecond], from: date) + var payload: [UInt8] = [] + payload.appendUInt16(UInt16(c.year ?? 1970)) + payload += [c.month, c.day, c.hour, c.minute, c.second].map { UInt8($0 ?? 0) } + payload += [UInt8((c.nanosecond ?? 0) / 100_000_000), UInt8(ascii: "+"), 0, 0] + out.appendLengthPrefixed(payload) + case .resolution(let x, let y, let units): + out.appendUInt16(9) + out.appendInt32(x) + out.appendInt32(y) + out.append(units) + case .range(let lower, let upper): + out.appendUInt16(8) + out.appendInt32(lower) + out.appendInt32(upper) + case .outOfBand: + out.appendUInt16(0) + case .collection(let members): + out.appendUInt16(0) + for member in members { + out.append(IPPTag.memberAttrName.rawValue) + out.appendUInt16(0) + out.appendLengthPrefixed(Array(member.name.utf8)) + for memberValue in member.values { + write(memberValue, name: "", into: &out) + } + } + out.append(IPPTag.endCollection.rawValue) + out.appendUInt16(0) + out.appendUInt16(0) + } + } +} + +/// RFC 8010 wire-format decoder. +struct IPPDecoder { + enum DecodeError: Error, CustomStringConvertible { + case truncated(offset: Int) + case malformed(String, offset: Int) + + var description: String { + switch self { + case .truncated(let offset): return "IPP response truncated at byte \(offset)" + case .malformed(let what, let offset): return "malformed IPP response at byte \(offset): \(what)" + } + } + } + + private let bytes: [UInt8] + private var offset = 0 + + static func decode(_ data: Data) throws -> IPPMessage { + var decoder = IPPDecoder(bytes: [UInt8](data)) + return try decoder.decodeMessage() + } + + private init(bytes: [UInt8]) { + self.bytes = bytes + } + + private mutating func decodeMessage() throws -> IPPMessage { + let major = try readUInt8() + let minor = try readUInt8() + var message = IPPMessage(code: try readUInt16()) + message.versionMajor = major + message.versionMinor = minor + message.requestID = try readInt32() + + var current: IPPGroup? + while true { + let tag = IPPTag(rawValue: try readUInt8()) + if tag.isDelimiter { + if let group = current { message.groups.append(group) } + current = nil + if tag == .endOfAttributes { break } + current = IPPGroup(tag: tag, attributes: []) + continue + } + guard current != nil else { + throw DecodeError.malformed("value tag 0x\(String(tag.rawValue, radix: 16)) outside a group", offset: offset) + } + let name = try readString(length: Int(try readUInt16())) + let value = try readValue(tag: tag) + if name.isEmpty { + guard !current!.attributes.isEmpty else { + throw DecodeError.malformed("additional value with no preceding attribute", offset: offset) + } + current!.attributes[current!.attributes.count - 1].values.append(value) + } else { + current!.attributes.append(IPPAttribute(name: name, values: [value])) + } + } + message.data = Data(bytes[offset...]) + return message + } + + /// Reads value-length + value for `tag` (the name has already been consumed). + private mutating func readValue(tag: IPPTag) throws -> IPPValue { + let length = Int(try readUInt16()) + if tag == .begCollection { + _ = try readBytes(length) + return .collection(try readCollectionMembers()) + } + // Rebase to 0 so v[0], v[8] etc. index the value, not the whole response. + let v = Array(try readBytes(length))[...] + + switch tag { + case _ where tag.isOutOfBand: + return .outOfBand(tag) + case .integer where length == 4: + return .integer(Self.int32(v, 0)) + case .enumeration where length == 4: + return .enumeration(Self.int32(v, 0)) + case .boolean where length == 1: + return .boolean(v[0] != 0) + case .octetString: + return .octetString(Data(v)) + case .dateTime where length == 11: + return Self.date(v).map { .dateTime($0) } ?? .other(tag, Data(v)) + case .resolution where length == 9: + return .resolution(x: Self.int32(v, 0), y: Self.int32(v, 4), units: v[8]) + case .rangeOfInteger where length == 8: + return .range(Self.int32(v, 0), Self.int32(v, 4)) + case .textWithLanguage, .nameWithLanguage: + guard length >= 4 else { return .other(tag, Data(v)) } + let langLength = Int(UInt16(v[0]) << 8 | UInt16(v[1])) + guard 2 + langLength + 2 <= length else { return .other(tag, Data(v)) } + let language = String(decoding: v[2 ..< 2 + langLength], as: UTF8.self) + let textStart = 2 + langLength + let textLength = Int(UInt16(v[textStart]) << 8 | UInt16(v[textStart + 1])) + guard textStart + 2 + textLength <= length else { return .other(tag, Data(v)) } + let text = String(decoding: v[textStart + 2 ..< textStart + 2 + textLength], as: UTF8.self) + return .stringWithLanguage(tag, language: language, text) + case _ where tag.isCharacterString: + return .string(tag, String(decoding: v, as: UTF8.self)) + default: + return .other(tag, Data(v)) + } + } + + /// Called just after a begCollection value; consumes through the matching endCollection. + private mutating func readCollectionMembers() throws -> [IPPAttribute] { + var members: [IPPAttribute] = [] + while true { + let tag = IPPTag(rawValue: try readUInt8()) + guard !tag.isDelimiter else { + throw DecodeError.malformed("delimiter inside collection", offset: offset) + } + _ = try readBytes(Int(try readUInt16())) // name, always empty inside a collection + + if tag == .endCollection { + _ = try readBytes(Int(try readUInt16())) + return members + } + if tag == .memberAttrName { + let memberName = try readString(length: Int(try readUInt16())) + members.append(IPPAttribute(name: memberName, values: [])) + continue + } + guard !members.isEmpty else { + throw DecodeError.malformed("collection value before memberAttrName", offset: offset) + } + let value = try readValue(tag: tag) + members[members.count - 1].values.append(value) + } + } + + // MARK: primitives + + private mutating func readBytes(_ count: Int) throws -> ArraySlice { + guard offset + count <= bytes.count else { throw DecodeError.truncated(offset: offset) } + defer { offset += count } + return bytes[offset ..< offset + count] + } + + private mutating func readUInt8() throws -> UInt8 { try readBytes(1).first! } + + private mutating func readUInt16() throws -> UInt16 { + let b = try readBytes(2) + return UInt16(b[b.startIndex]) << 8 | UInt16(b[b.startIndex + 1]) + } + + private mutating func readInt32() throws -> Int32 { Self.int32(try readBytes(4), 0) } + + private mutating func readString(length: Int) throws -> String { + String(decoding: try readBytes(length), as: UTF8.self) + } + + private static func int32(_ b: ArraySlice, _ at: Int) -> Int32 { + let i = b.startIndex + at + return Int32(bitPattern: UInt32(b[i]) << 24 | UInt32(b[i + 1]) << 16 | UInt32(b[i + 2]) << 8 | UInt32(b[i + 3])) + } + + /// RFC 2579 DateAndTime (11 octets). + private static func date(_ b: ArraySlice) -> Date? { + let s = b.startIndex + var c = DateComponents() + c.year = Int(UInt16(b[s]) << 8 | UInt16(b[s + 1])) + c.month = Int(b[s + 2]) + c.day = Int(b[s + 3]) + c.hour = Int(b[s + 4]) + c.minute = Int(b[s + 5]) + c.second = Int(b[s + 6]) + c.nanosecond = Int(b[s + 7]) * 100_000_000 + let sign = b[s + 8] == UInt8(ascii: "-") ? -1 : 1 + let utcOffset = sign * (Int(b[s + 9]) * 3600 + Int(b[s + 10]) * 60) + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(secondsFromGMT: utcOffset) ?? TimeZone(secondsFromGMT: 0)! + return cal.date(from: c) + } +} + +private extension Array where Element == UInt8 { + mutating func appendUInt16(_ v: UInt16) { + append(UInt8(v >> 8)) + append(UInt8(v & 0xFF)) + } + + mutating func appendInt32(_ v: Int32) { + let u = UInt32(bitPattern: v) + append(contentsOf: [UInt8(u >> 24), UInt8(u >> 16 & 0xFF), UInt8(u >> 8 & 0xFF), UInt8(u & 0xFF)]) + } + + mutating func appendLengthPrefixed(_ payload: [UInt8]) { + appendUInt16(UInt16(payload.count)) + append(contentsOf: payload) + } +} diff --git a/Sources/CupsKit/IPP/IPPTypes.swift b/Sources/CupsKit/IPP/IPPTypes.swift new file mode 100644 index 0000000..d0e28e3 --- /dev/null +++ b/Sources/CupsKit/IPP/IPPTypes.swift @@ -0,0 +1,164 @@ +import Foundation + +/// IPP tag byte (RFC 8010 §3.5). A struct rather than an enum so unknown tags survive decoding. +public struct IPPTag: RawRepresentable, Hashable { + public let rawValue: UInt8 + public init(rawValue: UInt8) { self.rawValue = rawValue } + + // Delimiter tags + public static let operationAttributes = IPPTag(rawValue: 0x01) + public static let jobAttributes = IPPTag(rawValue: 0x02) + public static let endOfAttributes = IPPTag(rawValue: 0x03) + public static let printerAttributes = IPPTag(rawValue: 0x04) + public static let unsupportedAttributes = IPPTag(rawValue: 0x05) + + // Out-of-band values + public static let unsupported = IPPTag(rawValue: 0x10) + public static let unknown = IPPTag(rawValue: 0x12) + public static let noValue = IPPTag(rawValue: 0x13) + + // Integer values + public static let integer = IPPTag(rawValue: 0x21) + public static let boolean = IPPTag(rawValue: 0x22) + public static let enumeration = IPPTag(rawValue: 0x23) + + // Octet-string values + public static let octetString = IPPTag(rawValue: 0x30) + public static let dateTime = IPPTag(rawValue: 0x31) + public static let resolution = IPPTag(rawValue: 0x32) + public static let rangeOfInteger = IPPTag(rawValue: 0x33) + public static let begCollection = IPPTag(rawValue: 0x34) + public static let textWithLanguage = IPPTag(rawValue: 0x35) + public static let nameWithLanguage = IPPTag(rawValue: 0x36) + public static let endCollection = IPPTag(rawValue: 0x37) + + // Character-string values + public static let textWithoutLanguage = IPPTag(rawValue: 0x41) + public static let nameWithoutLanguage = IPPTag(rawValue: 0x42) + public static let keyword = IPPTag(rawValue: 0x44) + public static let uri = IPPTag(rawValue: 0x45) + public static let uriScheme = IPPTag(rawValue: 0x46) + public static let charset = IPPTag(rawValue: 0x47) + public static let naturalLanguage = IPPTag(rawValue: 0x48) + public static let mimeMediaType = IPPTag(rawValue: 0x49) + public static let memberAttrName = IPPTag(rawValue: 0x4A) + + public static let extensionTag = IPPTag(rawValue: 0x7F) + + public var isDelimiter: Bool { rawValue < 0x10 } + public var isOutOfBand: Bool { (0x10...0x1F).contains(rawValue) } + public var isCharacterString: Bool { (0x40...0x5F).contains(rawValue) } +} + +public enum IPPValue { + case integer(Int32) + case boolean(Bool) + case enumeration(Int32) + /// textWithoutLanguage, nameWithoutLanguage, keyword, uri, charset, mimeMediaType, ... + case string(IPPTag, String) + /// textWithLanguage / nameWithLanguage + case stringWithLanguage(IPPTag, language: String, String) + case octetString(Data) + case dateTime(Date) + case resolution(x: Int32, y: Int32, units: UInt8) + case range(Int32, Int32) + case collection([IPPAttribute]) + case outOfBand(IPPTag) + case other(IPPTag, Data) + + public var tag: IPPTag { + switch self { + case .integer: return .integer + case .boolean: return .boolean + case .enumeration: return .enumeration + case .string(let t, _): return t + case .stringWithLanguage(let t, _, _): return t + case .octetString: return .octetString + case .dateTime: return .dateTime + case .resolution: return .resolution + case .range: return .rangeOfInteger + case .collection: return .begCollection + case .outOfBand(let t): return t + case .other(let t, _): return t + } + } + + public var stringValue: String? { + switch self { + case .string(_, let s), .stringWithLanguage(_, _, let s): return s + case .octetString(let d): return String(data: d, encoding: .utf8) + default: return nil + } + } + + public var intValue: Int? { + switch self { + case .integer(let i), .enumeration(let i): return Int(i) + default: return nil + } + } + + public var boolValue: Bool? { + if case .boolean(let b) = self { return b } + return nil + } +} + +public struct IPPAttribute { + public var name: String + public var values: [IPPValue] + + public init(name: String, values: [IPPValue]) { + self.name = name + self.values = values + } + + public static func string(_ name: String, _ tag: IPPTag, _ values: [String]) -> IPPAttribute { + IPPAttribute(name: name, values: values.map { .string(tag, $0) }) + } + + public static func boolean(_ name: String, _ value: Bool) -> IPPAttribute { + IPPAttribute(name: name, values: [.boolean(value)]) + } +} + +/// One attribute group (operation, printer, job, ...) in a message. +public struct IPPGroup { + public var tag: IPPTag + public var attributes: [IPPAttribute] + + public subscript(name: String) -> IPPAttribute? { + attributes.first { $0.name == name } + } + + public func string(_ name: String) -> String? { self[name]?.values.first?.stringValue } + public func strings(_ name: String) -> [String] { self[name]?.values.compactMap(\.stringValue) ?? [] } + public func int(_ name: String) -> Int? { self[name]?.values.first?.intValue } + public func ints(_ name: String) -> [Int] { self[name]?.values.compactMap(\.intValue) ?? [] } + public func bool(_ name: String) -> Bool? { self[name]?.values.first?.boolValue } +} + +public struct IPPMessage { + public var versionMajor: UInt8 = 2 + public var versionMinor: UInt8 = 0 + /// operation-id in a request, status-code in a response + public var code: UInt16 + public var requestID: Int32 = 1 + public var groups: [IPPGroup] = [] + public var data = Data() + + /// Status codes 0x0000-0x00FF are successful-ok-*. + public var isSuccess: Bool { code < 0x0100 } + + public func groups(_ tag: IPPTag) -> [IPPGroup] { groups.filter { $0.tag == tag } } + public var operationGroup: IPPGroup? { groups.first { $0.tag == .operationAttributes } } +} + +public enum IPPOperation { + public static let cancelJob: UInt16 = 0x0008 + public static let getJobAttributes: UInt16 = 0x0009 + public static let getJobs: UInt16 = 0x000A + public static let getPrinterAttributes: UInt16 = 0x000B + public static let cupsGetDefault: UInt16 = 0x4001 + public static let cupsGetPrinters: UInt16 = 0x4002 +} diff --git a/Sources/CupsKit/IPP/UnixSocketHTTP.swift b/Sources/CupsKit/IPP/UnixSocketHTTP.swift new file mode 100644 index 0000000..656f20b --- /dev/null +++ b/Sources/CupsKit/IPP/UnixSocketHTTP.swift @@ -0,0 +1,175 @@ +import Foundation + +/// Minimal HTTP/1.1 client (POST for IPP, GET for PPDs) over a Unix domain socket. +/// +/// Why not URLSession to localhost:631: on macOS 27 launchd only socket-activates cupsd on +/// /private/var/run/cupsd. TCP 631 is bound by cupsd itself and disappears when cupsd idle-exits, +/// so connecting over TCP fails whenever the scheduler is asleep. URLSession can't do AF_UNIX. +enum UnixSocketHTTP { + struct Response { + let status: Int + let body: Data + } + + static func post(socketPath: String, resource: String, contentType: String, body: Data) throws -> Response { + try request(method: "POST", socketPath: socketPath, resource: resource, contentType: contentType, body: body) + } + + static func get(socketPath: String, resource: String) throws -> Response { + try request(method: "GET", socketPath: socketPath, resource: resource, contentType: nil, body: Data()) + } + + private static func request(method: String, socketPath: String, resource: String, contentType: String?, + body: Data, timeoutSeconds: Int = 15) throws -> Response { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw failure("socket()") } + defer { close(fd) } + + var timeout = timeval(tv_sec: timeoutSeconds, tv_usec: 0) + let timevalSize = socklen_t(MemoryLayout.size) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, timevalSize) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, timevalSize) + var noSigPipe: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size)) + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let path = Array(socketPath.utf8) + [0] + let capacity = MemoryLayout.size(ofValue: address.sun_path) + guard path.count <= capacity else { throw CupsAdminError.transport("socket path too long: \(socketPath)") } + withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: path) } + address.sun_len = UInt8(MemoryLayout.size) + + let connected = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard connected == 0 else { throw failure("connect to \(socketPath)") } + + var head = "\(method) \(resource) HTTP/1.1\r\nHost: localhost\r\n" + if let contentType { + head += "Content-Type: \(contentType)\r\nContent-Length: \(body.count)\r\n" + } + head += "Connection: close\r\n\r\n" + try writeAll(fd, [UInt8](Data(head.utf8) + body)) + + var reader = Reader(fd: fd) + let statusLine = try reader.readLine() + let statusParts = statusLine.split(separator: " ", maxSplits: 2) + guard statusParts.count >= 2, statusParts[0].hasPrefix("HTTP/"), let status = Int(statusParts[1]) else { + throw CupsAdminError.transport("bad HTTP status line from cupsd: \(statusLine)") + } + + var headers: [String: String] = [:] + while true { + let line = try reader.readLine() + if line.isEmpty { break } + guard let colon = line.firstIndex(of: ":") else { continue } + let name = line[.. CupsAdminError { + let code = errno + let reason: String + switch code { + case ENOENT: reason = "socket not found (is CUPS installed?)" + case ECONNREFUSED: reason = "connection refused (cupsd not running?)" + case EAGAIN: reason = "timed out" + default: reason = String(cString: strerror(code)) + } + return CupsAdminError.transport("\(what): \(reason)") + } + + private struct Reader { + let fd: Int32 + private var buffer: [UInt8] = [] + private var position = 0 + + init(fd: Int32) { self.fd = fd } + + /// Returns false at EOF. + private mutating func fill() throws -> Bool { + if position > 0 { + buffer.removeFirst(position) + position = 0 + } + var chunk = [UInt8](repeating: 0, count: 65_536) + while true { + let n = chunk.withUnsafeMutableBytes { read(fd, $0.baseAddress, $0.count) } + if n < 0 { + if errno == EINTR { continue } + throw UnixSocketHTTP.failure("read from cupsd") + } + if n == 0 { return false } + buffer += chunk[0 ..< n] + return true + } + } + + mutating func readLine() throws -> String { + while true { + if let lf = buffer[position...].firstIndex(of: UInt8(ascii: "\n")) { + var end = lf + if end > position, buffer[end - 1] == UInt8(ascii: "\r") { end -= 1 } + let line = String(decoding: buffer[position ..< end], as: UTF8.self) + position = lf + 1 + return line + } + guard try fill() else { throw CupsAdminError.transport("cupsd closed the connection mid-response") } + } + } + + mutating func readBytes(_ count: Int) throws -> [UInt8] { + while buffer.count - position < count { + guard try fill() else { throw CupsAdminError.transport("cupsd closed the connection mid-response") } + } + defer { position += count } + return Array(buffer[position ..< position + count]) + } + + mutating func readToEnd() throws -> [UInt8] { + while try fill() {} + defer { position = buffer.count } + return Array(buffer[position...]) + } + } +} diff --git a/Sources/CupsKit/OptionEditing.swift b/Sources/CupsKit/OptionEditing.swift new file mode 100644 index 0000000..8191f1a --- /dev/null +++ b/Sources/CupsKit/OptionEditing.swift @@ -0,0 +1,477 @@ +import Foundation + +// MARK: - Controls + +/// A typed custom value (`*ParamCustomX`) edited as text, stored as `Custom.`. +public struct CustomField: Hashable { + public enum Kind: String { + case string, passcode, password, int, real + } + + public let kind: Kind + public let parameterName: String + public let minimum: Double? + public let maximum: Double? + /// The PPD's own choices (e.g. `None`, `0`). An empty field means the first of them. + public let choices: [String] + + /// Passcodes and passwords: masked in the UI and in displayed commands. + public var isSecret: Bool { kind == .passcode || kind == .password } + public var isNumeric: Bool { kind == .int || kind == .real } + + init?(parameter: PPD.CustomParameter, choices: [String]) { + guard let kind = Kind(rawValue: parameter.type) else { return nil } + self.kind = kind + parameterName = parameter.name + minimum = Double(parameter.minimum) + maximum = Double(parameter.maximum) + self.choices = choices + } + + /// Stored option value -> field text. `Custom.1234` -> `1234`; a plain choice -> "" (or the choice for numbers). + public func text(fromValue value: String?) -> String { + guard let value else { return "" } + if value.hasPrefix("Custom.") { return String(value.dropFirst("Custom.".count)) } + if isNumeric, value != choices.first { return value } + return "" + } + + /// Field text -> option value. "" -> the PPD's placeholder choice; text matching a choice -> that choice. + public func value(fromText text: String) -> String { + if text.isEmpty { return choices.first ?? "None" } + if choices.contains(text) { return text } + return "Custom." + text + } + + /// nil when valid. + public func validationError(_ text: String) -> String? { + guard !text.isEmpty, !choices.contains(text) else { return nil } + switch kind { + case .string, .password, .passcode: + if kind == .passcode, !text.allSatisfy(\.isASCIIDigit) { return "Digits only" } + if let minimum, Double(text.count) < minimum { return "At least \(Int(minimum)) characters" } + if let maximum, Double(text.count) > maximum { return "At most \(Int(maximum)) characters" } + case .int: + guard let number = Int(text) else { return "Whole number" } + return rangeError(Double(number)) + case .real: + guard let number = Double(text) else { return "Number" } + return rangeError(number) + } + return nil + } + + private func rangeError(_ number: Double) -> String? { + if let minimum, number < minimum { return "\(format(minimum)) to \(format(maximum))" } + if let maximum, number > maximum { return "\(format(minimum)) to \(format(maximum))" } + return nil + } + + private func format(_ n: Double?) -> String { + guard let n else { return "…" } + return n == n.rounded() ? String(Int(n)) : String(n) + } + + /// "8 characters max", "4–8 digits", "0 to 23". + public var hint: String { + switch kind { + case .string, .password: + return maximum.map { "Up to \(Int($0)) characters" } ?? "Text" + case .passcode: + if let minimum, let maximum { return "\(Int(minimum))–\(Int(maximum)) digits" } + return "Digits" + case .int, .real: + return "\(format(minimum)) to \(format(maximum))" + } + } +} + +public enum OptionControl: Hashable { + /// Menu of choices. `(keyword, label)` pairs. + case picker([PPD.Choice]) + /// Boolean with exactly a true and a false choice; the keywords to write for each state. + case toggle(on: String, off: String) + /// Custom value typed as text; the PPD's choices are only a placeholder. + case field(CustomField) + /// Read-only, with the reason (e.g. "Set by PPD option PageSize"). + case readOnly(String) +} + +extension PPD.Option { + public var control: OptionControl { + // A true Boolean has exactly a True and a False choice; some PPDs mislabel PickOnes as Boolean. + if uiType == .boolean, + let on = choices.first(where: { $0.caseInsensitiveCompare("True") == .orderedSame }), + let off = choices.first(where: { $0.caseInsensitiveCompare("False") == .orderedSame }), + choices.count == 2 { + return .toggle(on: on, off: off) + } + // Ricoh codes/passwords: a placeholder choice plus one typed custom parameter. + if customParameters.count == 1, choices.count <= 1, + let field = CustomField(parameter: customParameters[0], choices: choices) { + return .field(field) + } + return .picker(choiceList) + } +} + +// MARK: - Encoding + +public enum OptionEncoding { + /// `key=value` for lpadmin/lpoptions `-o`, quoting values cupsParseOptions would otherwise split. + /// (Unquoted, `Custom.John Smith` is silently stored as `Custom.John`.) + public static func argument(key: String, value: String) -> String { + "\(key)=\(quoted(value))" + } + + public static func quoted(_ value: String) -> String { + guard value.contains(where: { $0.isWhitespace || "\"'\\{}".contains($0) }) else { return value } + let escaped = value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + + /// Masks the value part of a custom secret (`Custom.1234` -> `Custom.••••`). + public static func masked(_ value: String) -> String { + if value.hasPrefix("Custom.") { return "Custom." + String(repeating: "•", count: max(4, value.count - 7)) } + return value + } +} + +// MARK: - lpoptions + +/// Per-user option defaults from `~/.cups/lpoptions` (read only, to show where a user overrides a queue default). +public enum LpOptionsFile { + public static var userFileURL: URL { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".cups/lpoptions") + } + + /// Options on the `Dest`/`Default` line for `queue` (no instance). Empty if none or no file. + public static func options(for queue: String, fileURL: URL = userFileURL) -> [String: String] { + guard let text = try? String(contentsOf: fileURL, encoding: .utf8) else { return [:] } + return options(for: queue, in: text) + } + + public static func options(for queue: String, in text: String) -> [String: String] { + for line in text.split(whereSeparator: \.isNewline) { + let fields = line.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true) + guard fields.count >= 2, fields[0] == "Dest" || fields[0] == "Default", + fields[1].caseInsensitiveCompare(queue) == .orderedSame else { continue } + return fields.count > 2 ? parse(String(fields[2])) : [:] + } + return [:] + } + + /// cupsParseOptions-style: `a=b c="d e" f={g=h i=j}`. + public static func parse(_ string: String) -> [String: String] { + var result: [String: String] = [:] + var chars = Array(string) + var i = 0 + func skipSpaces() { while i < chars.count, chars[i].isWhitespace { i += 1 } } + while true { + skipSpaces() + guard i < chars.count else { break } + var name = "" + while i < chars.count, !chars[i].isWhitespace, chars[i] != "=" { name.append(chars[i]); i += 1 } + guard i < chars.count, chars[i] == "=" else { + if !name.isEmpty { result[name] = "true" } + continue + } + i += 1 + var value = "" + var quote: Character? + var braces = 0 + while i < chars.count { + let c = chars[i] + if c == "\\", i + 1 < chars.count { + value.append(chars[i + 1]); i += 2; continue + } + if let q = quote { + if c == q { quote = nil } else { value.append(c) } + } else if c == "\"" || c == "'" { + quote = c + } else if c == "{" { + braces += 1; value.append(c) + } else if c == "}" { + braces -= 1; value.append(c) + } else if c.isWhitespace, braces == 0 { + break + } else { + value.append(c) + } + i += 1 + } + result[name] = value + } + chars.removeAll() + return result + } +} + +// MARK: - Catalog + +public struct OptionRow: Identifiable, Hashable { + public enum Source: Hashable { + case ppd, ipp, policy + } + + /// The key written with `lpadmin -p queue -o key=value` (PPD keyword, `sides-default`, `job-quota-period`). + public let key: String + public let label: String + public let group: String + public let control: OptionControl + public let source: Source + + public var id: String { key } + public var isSecret: Bool { + if case .field(let field) = control { return field.isSecret } + return false + } +} + +public struct OptionSection: Identifiable, Hashable { + public let title: String + public let rows: [OptionRow] + public var id: String { title } +} + +/// Queue defaults for one queue: IPP attributes and PPD (what lpadmin changes), plus the user's +/// ~/.cups/lpoptions, read only to point out per-user overrides. +public struct OptionState { + public let queue: String + public let snapshot: QueueSnapshot + public let userOptions: [String: String] + + public static func load(client: CupsClient, queue: String) async throws -> OptionState { + OptionState(queue: queue, snapshot: try await QueueSnapshot.load(client: client, queue: queue), + userOptions: LpOptionsFile.options(for: queue)) + } + + public var ppd: PPD? { snapshot.ppd } + + /// IPP job-template defaults an admin can set with `lpadmin -o name-default=value`. + public static let ippDefaults = ["copies", "media", "sides", "print-color-mode", "print-quality", "output-bin", + "printer-resolution", "number-up", "orientation-requested", "job-hold-until", + "job-priority", "job-cancel-after"] + /// On PPD queues cupsd derives these IPP defaults from a PPD option, and setting both makes duplicates. + public static let ppdControlled = ["media": "PageSize", "sides": "Duplex", "output-bin": "OutputBin", + "printer-resolution": "Resolution", "print-color-mode": "ColorModel", + "print-quality": "cupsPrintQuality"] + public static let integerPolicies = ["job-quota-period", "job-page-limit", "job-k-limit"] + + /// PPD groups shown first, in this order; "Installable Options" goes last (hardware, rarely changed). + public static let leadingGroups = [PPD.generalGroup, "Basic", "Paper"] + public static let trailingGroup = "Installable Options" + + /// General, Basic, Paper, other PPD groups in file order, IPP, Policies, Installable Options. + public func sections() -> [OptionSection] { + var ppdSections: [OptionSection] = [] + if let ppd { + let ordered = Self.leadingGroups.filter(ppd.groups.contains) + + ppd.groups.filter { !Self.leadingGroups.contains($0) && $0 != Self.trailingGroup } + for group in ordered { + let rows = ppd.options.filter { $0.group == group }.map { + OptionRow(key: $0.keyword, label: $0.text, group: group, control: $0.control, source: .ppd) + } + ppdSections.append(OptionSection(title: group, rows: rows)) + } + } + var sections = ppdSections + + var ipp: [OptionRow] = [] + for base in Self.ippDefaults { + let name = base + "-default" + guard snapshot.attributes[name] != nil || snapshot.attributes[base + "-supported"] != nil else { continue } + var control = ippControl(base: base) + if let keyword = Self.ppdControlled[base], ppd?.option(keyword) != nil { + control = .readOnly("Set by PPD option \(keyword)") + } + ipp.append(OptionRow(key: name, label: Self.label(base), group: "IPP", control: control, source: .ipp)) + } + if !ipp.isEmpty { sections.append(OptionSection(title: "IPP", rows: ipp)) } + + var policies: [OptionRow] = [] + for name in ["printer-error-policy", "printer-op-policy"] where snapshot.attributes[name] != nil { + let choices = supportedChoices(name + "-supported") + policies.append(OptionRow(key: name, label: Self.label(name), group: "Policies", + control: .picker(choices), source: .policy)) + } + if snapshot.attributes["job-sheets-default"] != nil { + let sheets = supportedChoices("job-sheets-supported").map(\.keyword) + var pairs: [PPD.Choice] = [] + for start in sheets { + for end in sheets { pairs.append(PPD.Choice(keyword: "\(start),\(end)", text: "\(start), \(end)")) } + } + policies.append(OptionRow(key: "job-sheets-default", label: "Banner pages (start, end)", + group: "Policies", control: .picker(pairs), source: .policy)) + } + for name in Self.integerPolicies where snapshot.attributes[name] != nil { + let field = CustomField(parameter: PPD.CustomParameter(name: name, text: name, order: 1, type: "int", + minimum: "0", maximum: "2147483647"), + choices: ["0"])! + policies.append(OptionRow(key: name, label: Self.label(name), group: "Policies", control: .field(field), source: .policy)) + } + if !policies.isEmpty { sections.append(OptionSection(title: "Policies", rows: policies)) } + + if let ppd, ppd.groups.contains(Self.trailingGroup) { + let rows = ppd.options.filter { $0.group == Self.trailingGroup }.map { + OptionRow(key: $0.keyword, label: $0.text, group: Self.trailingGroup, control: $0.control, source: .ppd) + } + sections.append(OptionSection(title: Self.trailingGroup, rows: rows)) + } + return sections + } + + /// "print-color-mode" -> "Print color mode". + static func label(_ name: String) -> String { + let words = name.replacingOccurrences(of: "-", with: " ") + return words.prefix(1).uppercased() + words.dropFirst() + } + + private func ippControl(base: String) -> OptionControl { + let supported = snapshot.attributes[base + "-supported"] + if let supported, case .range(let lower, let upper)? = supported.values.first, supported.values.count == 1 { + return .field(integerField(base, lower: Int(lower), upper: Int(upper))) + } + if base == "job-priority" { + return .field(integerField(base, lower: 1, upper: 100)) + } + let choices = supportedChoices(base + "-supported") + return choices.isEmpty ? .readOnly("No \(base)-supported values") : .picker(choices) + } + + private func integerField(_ name: String, lower: Int, upper: Int) -> CustomField { + CustomField(parameter: PPD.CustomParameter(name: name, text: name, order: 1, type: "int", + minimum: String(lower), maximum: String(upper)), choices: [])! + } + + /// `*-supported` values as choices: raw value to write, human text to show. + private func supportedChoices(_ name: String) -> [PPD.Choice] { + guard let attribute = snapshot.attributes[name] else { return [] } + let base = String(name.dropLast("-supported".count)) + return attribute.values.compactMap { value in + guard let raw = Self.rawValue(value) else { return nil } + let shown = Format.value(value, attribute: base + "-default") + return PPD.Choice(keyword: raw, text: shown == raw ? raw : "\(shown) (\(raw))") + } + } + + /// What lpadmin accepts for an IPP value: enums and integers as numbers, resolutions as 600dpi. + static func rawValue(_ value: IPPValue) -> String? { + switch value { + case .integer(let i), .enumeration(let i): return String(i) + case .string(_, let s), .stringWithLanguage(_, _, let s): return s + case .resolution(let x, let y, let units): + let unit = units == 4 ? "dpcm" : "dpi" + return x == y ? "\(x)\(unit)" : "\(x)x\(y)\(unit)" + case .range(let lower, let upper): return "\(lower)-\(upper)" + case .boolean(let b): return b ? "true" : "false" + default: return nil + } + } + + // MARK: values + + /// The queue default for a row key (PPD default, or the first IPP attribute of that name), in raw form. + public func queueValue(_ key: String) -> String? { + if let option = ppd?.option(key) { return option.defaultChoice } + guard let attribute = snapshot.attributes[key] else { return nil } + let raw = attribute.values.compactMap(Self.rawValue) + return raw.isEmpty ? nil : raw.joined(separator: ",") + } + + /// This user's own lpoptions value for a row (`sides-default` is `sides` there), if they have one. + public func userOverride(_ key: String) -> String? { + let base = key.hasSuffix("-default") ? String(key.dropLast("-default".count)) : key + return userOptions[Self.ippDefaults.contains(base) ? base : key] + } + + /// Human form of a raw value for a row, e.g. `Custom.1234` -> `1234`, `5` -> `high`, `DuplexNoTumble` -> `Long Edge`. + public func display(_ value: String?, row: OptionRow) -> String { + guard let value else { return "—" } + switch row.control { + case .picker(let choices): + return choices.first { $0.keyword == value }?.text ?? value + case .toggle(let on, _): + return value == on ? "On" : "Off" + case .field(let field): + let text = field.text(fromValue: value) + if text.isEmpty { return value } + return field.isSecret ? String(repeating: "•", count: text.count) : text + case .readOnly: + return value + } + } +} + +// MARK: - Apply + +public struct OptionChange: Hashable { + public let key: String + public let value: String + public let isSecret: Bool + + public init(key: String, value: String, isSecret: Bool) { + self.key = key + self.value = value + self.isSecret = isSecret + } +} + +public struct OptionReadBack: Hashable { + public let key: String + public let applied: Bool + /// Why it didn't apply. + public let reason: String? +} + +/// Writes queue defaults with `lpadmin -p queue -o ...` (the PPD in /etc/cups/ppd and printers.conf), +/// the same thing the CUPS web UI's Set Default Options page did. Never lpoptions. +public enum OptionApplier { + public static func command(queue: String, changes: [OptionChange]) -> (executable: String, arguments: [String]) { + var args = ["-p", queue] + for change in changes { + args += ["-o", OptionEncoding.argument(key: change.key, value: change.value)] + } + return (CupsTools.lpadminPath, args) + } + + /// The command as shown to the user, with secret values masked. + public static func displayCommand(queue: String, changes: [OptionChange]) -> String { + let masked = changes.map { + OptionChange(key: $0.key, value: $0.isSecret ? OptionEncoding.masked($0.value) : $0.value, isSecret: $0.isSecret) + } + let (executable, args) = command(queue: queue, changes: masked) + return CupsTools.commandLine(executable, args) + } + + /// Runs one lpadmin call for all changes, then re-reads the queue and checks each change. + public static func apply(client: CupsClient, queue: String, + changes: [OptionChange]) async throws -> (result: ToolResult, readBack: [OptionReadBack], state: OptionState) { + let (executable, args) = command(queue: queue, changes: changes) + let result = try await Task.detached { try CupsTools.run(executable, args) }.value + let state = try await OptionState.load(client: client, queue: queue) + guard result.succeeded else { + let message = result.message.isEmpty ? "lpadmin exited \(result.status)" : result.message + return (result, changes.map { OptionReadBack(key: $0.key, applied: false, reason: message) }, state) + } + return (result, changes.map { verify($0, state: state) }, state) + } + + public static func verify(_ change: OptionChange, state: OptionState) -> OptionReadBack { + let actual = state.queueValue(change.key) + // PPD choices are written verbatim; IPP values read back in their own spelling (none,none / true). + let exact = state.ppd?.option(change.key) != nil + if let actual, exact ? actual == change.value : QueueSnapshot.matches(actual, change.value) { + return OptionReadBack(key: change.key, applied: true, reason: nil) + } + guard let actual else { + return OptionReadBack(key: change.key, applied: false, reason: "Not applied: \(state.queue) has no option \(change.key)") + } + return OptionReadBack(key: change.key, applied: false, + reason: "Not applied: still \(change.isSecret ? OptionEncoding.masked(actual) : actual)") + } +} + +private extension Character { + var isASCIIDigit: Bool { ("0"..."9").contains(self) } +} diff --git a/Sources/CupsKit/PPD.swift b/Sources/CupsKit/PPD.swift new file mode 100644 index 0000000..1ff568e --- /dev/null +++ b/Sources/CupsKit/PPD.swift @@ -0,0 +1,173 @@ +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] = [:] + + public init(text: String) { + var defaults: [String: String] = [:] + var customKeywords: Set = [] + 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, 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 } + } +} diff --git a/Sources/CupsKit/PrinterType.swift b/Sources/CupsKit/PrinterType.swift new file mode 100644 index 0000000..431dc62 --- /dev/null +++ b/Sources/CupsKit/PrinterType.swift @@ -0,0 +1,35 @@ +import Foundation + +/// CUPS printer-type bits (cups/cups.h cups_ptype_e) we surface. +public enum PrinterType { + public static let isClass = 0x0000_0001 + public static let remote = 0x0000_0002 + public static let bw = 0x0000_0004 + public static let color = 0x0000_0008 + public static let duplex = 0x0000_0010 + public static let staple = 0x0000_0020 + public static let fax = 0x0004_0000 + public static let discovered = 0x0100_0000 + public static let scanner = 0x0200_0000 + public static let mfp = 0x0400_0000 + + public static func kind(_ type: Int?) -> String { + guard let type else { return "?" } + var parts = [type & isClass != 0 ? "class" : "printer"] + if type & remote != 0 { parts.append("remote") } + if type & discovered != 0 { parts.append("discovered") } + if type & fax != 0 { parts.append("fax") } + return parts.joined(separator: ",") + } + + public static func capabilities(_ type: Int?) -> String { + guard let type else { return "?" } + var caps: [String] = [] + if type & color != 0 { caps.append("color") } else if type & bw != 0 { caps.append("b/w") } + if type & duplex != 0 { caps.append("duplex") } + if type & staple != 0 { caps.append("staple") } + if type & scanner != 0 { caps.append("scanner") } + if type & mfp != 0 { caps.append("mfp") } + return caps.isEmpty ? "-" : caps.joined(separator: ", ") + } +} diff --git a/Sources/CupsKit/QueueActions.swift b/Sources/CupsKit/QueueActions.swift new file mode 100644 index 0000000..d7bc80d --- /dev/null +++ b/Sources/CupsKit/QueueActions.swift @@ -0,0 +1,268 @@ +import CoreGraphics +import Foundation + +/// One job as the Jobs table shows it. +public struct JobSummary: Identifiable, Hashable { + public let id: Int + public let name: String + public let user: String + public let queue: String + /// Size in kilobytes (job-k-octets). + public let sizeK: Int + /// Pages printed or counted so far, when cupsd knows. + public let pages: Int? + public let state: Int + public let reasons: [String] + public let submitted: Date? + + public static let requestedAttributes = [ + "job-id", "job-name", "job-originating-user-name", "job-printer-uri", "job-k-octets", + "job-media-sheets-completed", "job-impressions-completed", "job-impressions", + "job-state", "job-state-reasons", "time-at-creation", + ] + + public init(_ job: IPPGroup) { + id = job.int("job-id") ?? 0 + name = job.string("job-name") ?? "" + user = job.string("job-originating-user-name") ?? "" + queue = Self.queueName(fromPrinterURI: job.string("job-printer-uri")) ?? "" + sizeK = job.int("job-k-octets") ?? 0 + pages = [job.int("job-impressions-completed"), job.int("job-media-sheets-completed"), job.int("job-impressions")] + .compactMap { $0 }.first { $0 > 0 } + state = job.int("job-state") ?? 0 + reasons = job.strings("job-state-reasons").filter { $0 != "none" } + submitted = job.int("time-at-creation").flatMap { $0 > 0 ? Date(timeIntervalSince1970: TimeInterval($0)) : nil } + } + + /// pending, held, processing, stopped, canceled, aborted, completed. + public var stateName: String { Format.jobState(state) } + public var isHeld: Bool { state == 4 } + /// pending / held / processing / stopped: cancel, hold, release and move still make sense. + public var isActive: Bool { state >= 3 && state <= 6 } + + /// ipp://host/printers/NAME -> NAME. + public static func queueName(fromPrinterURI uri: String?) -> String? { + guard let uri, let last = uri.split(separator: "/").last else { return nil } + return last.removingPercentEncoding ?? String(last) + } +} + +public enum PrinterAction: CaseIterable { + case pause, resume, accept, reject, cancelAllJobs + + public var title: String { + switch self { + case .pause: return "Pause" + case .resume: return "Resume" + case .accept: return "Accept Jobs" + case .reject: return "Reject Jobs" + case .cancelAllJobs: return "Cancel All Jobs" + } + } + + public var systemImage: String { + switch self { + case .pause: return "pause.circle" + case .resume: return "play.circle" + case .accept: return "checkmark.circle" + case .reject: return "nosign" + case .cancelAllJobs: return "xmark.circle" + } + } + + /// "Paused", "Resumed", ... for status lines. + public var pastTense: String { + switch self { + case .pause: return "Paused" + case .resume: return "Resumed" + case .accept: return "Accepting jobs on" + case .reject: return "Rejecting jobs on" + case .cancelAllJobs: return "Canceled all jobs on" + } + } +} + +public enum JobAction: Hashable { + case cancel, hold, release + case move(to: String) + + public var title: String { + switch self { + case .cancel: return "Cancel" + case .hold: return "Hold" + case .release: return "Release" + case .move(let destination): return "Move to \(destination)" + } + } + + public var pastTense: String { + switch self { + case .cancel: return "Canceled" + case .hold: return "Held" + case .release: return "Released" + case .move(let destination): return "Moved to \(destination):" + } + } +} + +/// The outcome of an action after reading cupsd back. +public struct ActionOutcome: Hashable { + public let succeeded: Bool + /// The command that ran (shell-style), or the IPP operation. + public let command: String + /// Why it failed: tool output, IPP status, or what the read-back found. + public let message: String? +} + +/// Pause/resume/accept/reject/cancel-all and job cancel/hold/release/move, each verified against cupsd. +public enum QueueActions { + public static func perform(_ action: PrinterAction, queue: String, client: CupsClient) async throws -> ActionOutcome { + let result: ToolResult = try await Task.detached { + switch action { + case .pause: return try CupsTools.disable(queue: queue) + case .resume: return try CupsTools.enable(queue: queue) + case .accept: return try CupsTools.accept(queue: queue) + case .reject: return try CupsTools.reject(queue: queue) + case .cancelAllJobs: return try CupsTools.cancelAll(queue: queue) + } + }.value + guard result.succeeded else { + return ActionOutcome(succeeded: false, command: result.commandLine, + message: result.message.isEmpty ? "exited \(result.status)" : result.message) + } + + let problem: String? + switch action { + case .pause, .resume, .accept, .reject: + let printer = try await client.getPrinterAttributes(queue: queue, requested: ["printer-state", "printer-is-accepting-jobs"]) + let stopped = printer.int("printer-state") == 5 + let accepting = printer.bool("printer-is-accepting-jobs") ?? true + switch action { + case .pause: problem = stopped ? nil : "still \(Format.printerState(printer.int("printer-state")))" + case .resume: problem = stopped ? "still stopped" : nil + case .accept: problem = accepting ? nil : "still rejecting jobs" + default: problem = accepting ? "still accepting jobs" : nil + } + case .cancelAllJobs: + let left = try await client.getJobs(queue: queue, which: "not-completed", requested: ["job-id"]).count + problem = left == 0 ? nil : "\(left) job\(left == 1 ? "" : "s") still active" + } + return ActionOutcome(succeeded: problem == nil, command: result.commandLine, message: problem.map { "Not applied: \($0)" }) + } + + /// Runs the action on each job, then reads each job back. Returns one outcome per job id, in order. + public static func perform(_ action: JobAction, jobIDs: [Int], client: CupsClient) async -> [Int: ActionOutcome] { + var outcomes: [Int: ActionOutcome] = [:] + for id in jobIDs { + outcomes[id] = await perform(action, jobID: id, client: client) + } + return outcomes + } + + static func perform(_ action: JobAction, jobID: Int, client: CupsClient) async -> ActionOutcome { + var command = "" + do { + switch action { + case .cancel: + // Same IPP Cancel-Job the CLI's `cupsadmin cancel` uses. + command = "IPP Cancel-Job \(jobID)" + try await client.cancelJob(jobID: jobID) + case .hold, .release, .move: + let result: ToolResult = try await Task.detached { + switch action { + case .hold: return try CupsTools.hold(jobID: jobID) + case .release: return try CupsTools.release(jobID: jobID) + case .move(let destination): return try CupsTools.move(jobID: jobID, to: destination) + case .cancel: fatalError("handled above") + } + }.value + command = result.commandLine + guard result.succeeded else { + return ActionOutcome(succeeded: false, command: command, + message: result.message.isEmpty ? "exited \(result.status)" : result.message) + } + } + } catch { + return ActionOutcome(succeeded: false, command: command, message: String(describing: error)) + } + + do { + let job = JobSummary(try await client.getJobAttributes(jobID: jobID, requested: JobSummary.requestedAttributes)) + let problem: String? + switch action { + case .cancel: problem = job.state == 7 ? nil : "still \(job.stateName)" + case .hold: problem = job.isHeld ? nil : "still \(job.stateName)" + case .release: problem = job.isHeld ? "still held" : nil + case .move(let destination): problem = job.queue == destination ? nil : "still on \(job.queue)" + } + return ActionOutcome(succeeded: problem == nil, command: command, message: problem.map { "Not applied: \($0)" }) + } catch CupsAdminError.ipp(let status, _) where status == IPPStatus.notFound && action == .cancel { + // Purged from history right away: canceled. + return ActionOutcome(succeeded: true, command: command, message: nil) + } catch { + return ActionOutcome(succeeded: false, command: command, message: "Couldn’t read job \(jobID) back: \(error)") + } + } +} + +// MARK: - Default, delete, test page + +extension QueueActions { + /// `lpadmin -d queue`, verified with CUPS-Get-Default. + public static func setDefault(queue: String, client: CupsClient) async throws -> ActionOutcome { + let result = try await Task.detached { try CupsTools.setDefault(queue: queue) }.value + guard result.succeeded else { + return ActionOutcome(succeeded: false, command: result.commandLine, message: result.message.isEmpty ? "exited \(result.status)" : result.message) + } + let current = try await client.getDefaultPrinter() + return ActionOutcome(succeeded: current == queue, command: result.commandLine, + message: current == queue ? nil : "Not applied: server default is \(current ?? "none")") + } + + /// `lpadmin -x queue`, verified by the queue being gone. + public static func delete(queue: String, client: CupsClient) async throws -> ActionOutcome { + let result = try await Task.detached { try CupsTools.delete(queue: queue) }.value + guard result.succeeded else { + return ActionOutcome(succeeded: false, command: result.commandLine, message: result.message.isEmpty ? "exited \(result.status)" : result.message) + } + let stillThere = try await client.queueExists(queue) + return ActionOutcome(succeeded: !stillThere, command: result.commandLine, + message: stillThere ? "Not applied: \(queue) still exists" : nil) + } + + /// Sends the CUPS test page (or a generated one-page PDF when it's missing). Returns the job id when verified. + public static func printTestPage(queue: String, client: CupsClient) async throws -> (outcome: ActionOutcome, jobID: Int?) { + let file = FileManager.default.fileExists(atPath: CupsTools.testPagePath) + ? CupsTools.testPagePath : try generatedTestPage(queue: queue) + let result = try await Task.detached { try CupsTools.printTestPage(queue: queue, file: file) }.value + guard result.succeeded else { + return (ActionOutcome(succeeded: false, command: result.commandLine, message: result.message.isEmpty ? "exited \(result.status)" : result.message), nil) + } + // "request id is Q-12 (1 file(s))" + let text = String(decoding: result.standardOutput, as: UTF8.self) + guard let token = text.split(separator: " ").first(where: { $0.hasPrefix(queue + "-") }), + let id = Int(token.dropFirst(queue.count + 1)) else { + return (ActionOutcome(succeeded: false, command: result.commandLine, message: "lp didn’t report a job id: \(text)"), nil) + } + let job = JobSummary(try await client.getJobAttributes(jobID: id, requested: JobSummary.requestedAttributes)) + let ok = job.queue == queue + return (ActionOutcome(succeeded: ok, command: result.commandLine, message: ok ? nil : "Job \(id) isn’t on \(queue)"), id) + } + + private static func generatedTestPage(queue: String) throws -> String { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("cupsadmin-testpage.pdf") + var box = CGRect(x: 0, y: 0, width: 612, height: 792) + guard let context = CGContext(url as CFURL, mediaBox: &box, nil) else { + throw CupsAdminError.failed("couldn’t create a test page PDF") + } + context.beginPDFPage(nil) + context.setLineWidth(4) + context.stroke(box.insetBy(dx: 36, dy: 36)) + context.move(to: CGPoint(x: 36, y: 36)); context.addLine(to: CGPoint(x: 576, y: 756)) + context.move(to: CGPoint(x: 36, y: 756)); context.addLine(to: CGPoint(x: 576, y: 36)) + context.strokePath() + context.endPDFPage() + context.closePDF() + return url.path + } +} diff --git a/Sources/CupsKit/QueueAdmin.swift b/Sources/CupsKit/QueueAdmin.swift new file mode 100644 index 0000000..18bb6d4 --- /dev/null +++ b/Sources/CupsKit/QueueAdmin.swift @@ -0,0 +1,152 @@ +import Foundation + +/// What `add` / `set` were asked to change. Maps 1:1 onto lpadmin flags. +public struct QueueSpec { + public var deviceURI: String? + public var model: String? + public var ppdFile: String? + public var description: String? + public var location: String? + public var options: [(key: String, value: String)] = [] + + public init() {} + + public var isEmpty: Bool { + deviceURI == nil && model == nil && ppdFile == nil && description == nil && location == nil && options.isEmpty + } + + public func lpadminArguments(queue: String) -> [String] { + var args = ["-p", queue] + if let deviceURI { args += ["-v", deviceURI] } + if let model { args += ["-m", model] } + if let ppdFile { args += ["-P", ppdFile] } + if let description { args += ["-D", description] } + if let location { args += ["-L", location] } + for option in options { args += ["-o", "\(option.key)=\(option.value)"] } + return args + } +} + +/// One requested change and where it landed. +public struct SettingCheck { + public let name: String + public let source: String + public let value: String? + public let wanted: String + + public var applied: Bool { QueueSnapshot.matches(value, wanted) } +} + +/// Queue state used to verify that lpadmin actually applied each change. +public struct QueueSnapshot { + public let attributes: IPPGroup + public let ppd: PPD? + + public static func load(client: CupsClient, queue: String) async throws -> QueueSnapshot { + QueueSnapshot(attributes: try await client.getPrinterAttributes(queue: queue, requested: ["all"]), + ppd: try await client.getPPD(queue: queue).map(PPD.init(text:))) + } + + /// Where an lpadmin `-o key=value` lands: an IPP attribute of that name, a PPD option, or key-default. + public func lookup(option key: String) -> (value: String, source: String)? { + if let attribute = attributes[key] { return (Self.join(attribute), "ipp") } + if let option = ppd?.option(key) { return (option.defaultChoice ?? "?", "ppd") } + if let attribute = attributes[key + "-default"] { return (Self.join(attribute), "ipp \(key)-default") } + return nil + } + + public func settings(for spec: QueueSpec) -> [SettingCheck] { + var result: [SettingCheck] = [] + if let uri = spec.deviceURI { + result.append(SettingCheck(name: "device-uri (-v)", source: "ipp", value: attributes.string("device-uri"), wanted: uri)) + } + if let d = spec.description { + result.append(SettingCheck(name: "printer-info (-D)", source: "ipp", value: attributes.string("printer-info"), wanted: d)) + } + if let l = spec.location { + result.append(SettingCheck(name: "printer-location (-L)", source: "ipp", value: attributes.string("printer-location"), wanted: l)) + } + for option in spec.options { + let found = lookup(option: option.key) + result.append(SettingCheck(name: option.key, source: found?.source ?? "not found", value: found?.value, wanted: option.value)) + } + return result + } + + private static func join(_ attribute: IPPAttribute) -> String { + attribute.values.map { Format.value($0, attribute: attribute.name) }.joined(separator: ",") + } + + /// Loose equality: case-insensitive, yes/on/true style booleans, and "none" matching "none,none". + public static func matches(_ actual: String?, _ wanted: String) -> Bool { + guard let actual else { return false } + func normalize(_ s: String) -> String { + let lower = s.lowercased() + return ["yes": "true", "on": "true", "no": "false", "off": "false"][lower] ?? lower + } + let a = normalize(actual), w = normalize(wanted) + if a == w { return true } + let parts = a.split(separator: ",").map(String.init) + return !w.contains(",") && parts.count > 1 && parts.allSatisfy { $0 == w } + } +} + +/// Add/modify flows, split around the lpadmin run so a caller can show the command and its output in between. +public enum QueueAdmin { + /// Validates an add request and applies the sharing default. Returns the spec to hand to lpadmin. + public static func prepareAdd(client: CupsClient, queue: String, spec: QueueSpec, shared: Bool) async throws -> QueueSpec { + try validateQueueName(queue) + guard spec.deviceURI != nil else { throw CupsAdminError.usage("add needs -v ") } + + // lpadmin creates queues shared by default; ours aren't unless asked (--shared or an explicit -o). + var spec = spec + if let explicit = spec.options.first(where: { $0.key == "printer-is-shared" }) { + if shared, !QueueSnapshot.matches(explicit.value, "true") { + throw CupsAdminError.usage("--shared conflicts with -o printer-is-shared=\(explicit.value)") + } + } else { + spec.options.append(("printer-is-shared", shared ? "true" : "false")) + } + if try await client.queueExists(queue) { + throw CupsAdminError.failed("queue \(queue) already exists (use set to change it)") + } + return spec + } + + /// lpadmin arguments for add. -E after -p: enable and accept jobs, like the web UI's Add Printer did. + public static func addArguments(queue: String, spec: QueueSpec) -> [String] { + spec.lpadminArguments(queue: queue) + ["-E"] + } + + /// After lpadmin ran for add: checks it worked and returns the new queue's state. + public static func confirmAdded(client: CupsClient, queue: String, lpadminStatus: Int32) async throws -> QueueSnapshot { + guard lpadminStatus == 0 else { throw CupsAdminError.failed("lpadmin exited \(lpadminStatus); queue not added") } + guard try await client.queueExists(queue) else { + throw CupsAdminError.failed("lpadmin exited 0 but \(queue) does not exist") + } + return try await QueueSnapshot.load(client: client, queue: queue) + } + + /// Validates a set request and returns the current values of everything it will change. + public static func prepareSet(client: CupsClient, queue: String, spec: QueueSpec) async throws -> [SettingCheck] { + guard !spec.isEmpty else { throw CupsAdminError.usage("set needs at least one of -o, -v, -D, -L, -m, -P") } + guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") } + return try await QueueSnapshot.load(client: client, queue: queue).settings(for: spec) + } + + /// After lpadmin ran for set: checks the exit status and returns the new values. + public static func confirmSet(client: CupsClient, queue: String, spec: QueueSpec, lpadminStatus: Int32) async throws -> [SettingCheck] { + guard lpadminStatus == 0 else { throw CupsAdminError.failed("lpadmin exited \(lpadminStatus); nothing verified") } + return try await QueueSnapshot.load(client: client, queue: queue).settings(for: spec) + } + + /// cupsd's rules: printable, no space / slash / backslash / quote / ? / #, at most 127 bytes. + public static func validateQueueName(_ queue: String) throws { + let forbidden = Set(" \t/\\?'\"#") + guard !queue.isEmpty, queue.utf8.count <= 127, + queue.unicodeScalars.allSatisfy({ $0.value > 0x20 && $0.value != 0x7F }), + !queue.contains(where: forbidden.contains) else { + throw CupsAdminError.usage("invalid queue name \(queue): no spaces, / \\ ? ' \" #, max 127 bytes") + } + } +} diff --git a/Sources/CupsKit/QuickActions.swift b/Sources/CupsKit/QuickActions.swift new file mode 100644 index 0000000..e5e08c1 --- /dev/null +++ b/Sources/CupsKit/QuickActions.swift @@ -0,0 +1,139 @@ +import Foundation + +/// Task-named shortcuts that set queue defaults without knowing option keywords. +/// Each one is data: which PPD keyword/value pairs to write (first variant the PPD supports wins). +/// Keywords and values come from docs/ricoh-ppd-options.md — never guessed. +public struct QuickAction: Identifiable, Hashable { + public enum Input: Hashable { + case none + /// Digits, up to the PPD's own limit. An empty value clears the code. + case userCode + } + + public enum Effect: Hashable { + /// `lpadmin -p queue -o ...` with the first variant whose keywords and values this PPD has. + /// `{value}` in a value is replaced with the input. + case options(variants: [[String: String]], clearVariants: [[String: String]]) + /// `lpadmin -d queue`. + case defaultPrinter + } + + /// CLI name: `cupsadmin quick `. + public let id: String + public let title: String + public let systemImage: String + public let input: Input + public let effect: Effect + /// One line for the CLI list and tooltips. + public let summary: String + + public static let all: [QuickAction] = [ + QuickAction(id: "color", title: "Always Print in Color", systemImage: "paintpalette", input: .none, + effect: .options(variants: [["ColorModel": "CMYK"], ["HQColorMode": "COLOR"]], clearVariants: []), + summary: "Color by default (ColorModel=CMYK, or HQColorMode=COLOR)"), + QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none, + effect: .options(variants: [["ColorModel": "Gray"], ["HQColorMode": "BW"]], clearVariants: []), + summary: "Black & white by default (ColorModel=Gray, or HQColorMode=BW)"), + QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode, + effect: .options(variants: [["RIEnableUserCode": "True", "RIUserCode": "Custom.{value}"]], + clearVariants: [["RIEnableUserCode": "False", "RIUserCode": "None"]]), + summary: "Ricoh user code for every job (RIEnableUserCode=True, RIUserCode=Custom.)"), + QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none, + effect: .options(variants: [["Duplex": "DuplexNoTumble"]], clearVariants: []), + summary: "Two-sided, long edge (Duplex=DuplexNoTumble)"), + QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none, + effect: .options(variants: [["Duplex": "None"]], clearVariants: []), + summary: "One-sided (Duplex=None)"), + QuickAction(id: "letter", title: "Use Letter Paper, Fit to Nearest Size", systemImage: "arrow.up.left.and.arrow.down.right", + input: .none, + effect: .options(variants: [["PageSize": "Letter", "RIPaperPolicy": "NearestSizeAdjust"]], clearVariants: []), + summary: "Letter, never prompt at the printer (PageSize=Letter, RIPaperPolicy=NearestSizeAdjust)"), + QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none, + effect: .defaultPrinter, summary: "Server default printer (lpadmin -d)"), + ] + + public static func named(_ id: String) -> QuickAction? { + all.first { $0.id == id } + } + + /// The option changes for this PPD, or why the action isn't available on it. + public func changes(for ppd: PPD?, value: String? = nil) -> Result<[OptionChange], QuickActionUnavailable> { + guard case .options(let variants, let clearVariants) = effect else { return .success([]) } + let clearing = input == .userCode && (value ?? "").isEmpty + let candidates = clearing ? clearVariants : variants + guard let ppd else { return .failure(QuickActionUnavailable(reason: "\(title) needs a PPD; this queue has none")) } + + for variant in candidates { + var changes: [OptionChange] = [] + for key in variant.keys.sorted() { + guard let option = ppd.option(key) else { changes = []; break } + let raw = variant[key]! + if raw.contains("{value}") { + guard !option.customParameters.isEmpty else { changes = []; break } + changes.append(OptionChange(key: key, value: raw.replacingOccurrences(of: "{value}", with: value ?? ""), isSecret: false)) + } else { + guard option.choices.contains(raw) else { changes = []; break } + changes.append(OptionChange(key: key, value: raw, isSecret: false)) + } + } + if !changes.isEmpty { return .success(changes) } + } + let needed = Set(candidates.flatMap { $0.keys }).sorted().joined(separator: " / ") + return .failure(QuickActionUnavailable(reason: "This printer’s PPD has no \(needed) with the needed choice")) + } + + public func isAvailable(for ppd: PPD?) -> Bool { + if case .defaultPrinter = effect { return true } + if case .success = changes(for: ppd, value: input == .userCode ? "0" : nil) { return true } + return false + } + + /// For Set User Code: digits only, within the PPD's RIUserCode length. nil when valid. + public func validationError(_ value: String, ppd: PPD?) -> String? { + guard input == .userCode, !value.isEmpty else { return nil } + guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" } + if let max = ppd?.option("RIUserCode")?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max { + return "At most \(max) digits" + } + return nil + } + + /// The current user code on the queue, without the Custom. prefix (nil when none is set). + public static func currentUserCode(_ ppd: PPD?) -> String? { + guard let value = ppd?.option("RIUserCode")?.defaultChoice, value.hasPrefix("Custom.") else { return nil } + return String(value.dropFirst("Custom.".count)) + } +} + +public struct QuickActionUnavailable: Error, Hashable { + public let reason: String +} + +public struct QuickActionOutcome { + public let command: String + public let succeeded: Bool + /// Per-option read-back; empty for Make Default Printer. + public let readBack: [OptionReadBack] + public let changes: [OptionChange] + public let message: String? +} + +extension QuickAction { + /// Runs the action on a queue with read-back. Throws QuickActionUnavailable when the PPD lacks the options. + public func run(queue: String, value: String? = nil, client: CupsClient) async throws -> QuickActionOutcome { + switch effect { + case .defaultPrinter: + let outcome = try await QueueActions.setDefault(queue: queue, client: client) + return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, readBack: [], + changes: [], message: outcome.message) + case .options: + let ppd = try await client.getPPD(queue: queue).map(PPD.init(text:)) + let changes = try self.changes(for: ppd, value: value).get() + let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: changes) + let failed = readBack.filter { !$0.applied } + return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: changes), + succeeded: result.succeeded && failed.isEmpty, readBack: readBack, changes: changes, + message: failed.first?.reason) + } + } +} diff --git a/Sources/cupsadmin/Commands/CancelCommand.swift b/Sources/cupsadmin/Commands/CancelCommand.swift new file mode 100644 index 0000000..931ec83 --- /dev/null +++ b/Sources/cupsadmin/Commands/CancelCommand.swift @@ -0,0 +1,36 @@ +import Foundation +import CupsKit + +enum CancelCommand { + /// Accepts a bare job id ("123") or lpstat's "Queue-123" form. + static func parseJobID(_ argument: String) throws -> Int { + let digits = argument.split(separator: "-").last.map(String.init) ?? argument + guard let id = Int(digits), id > 0 else { + throw CupsAdminError.usage("cancel needs a job id like 123 or Queue-123, got \(argument)") + } + return id + } + + static func run(client: CupsClient, jobID: Int) async throws -> String { + let requested = ["job-id", "job-name", "job-printer-uri", "job-originating-user-name", "job-state"] + let job: IPPGroup + do { + job = try await client.getJobAttributes(jobID: jobID, requested: requested) + } catch CupsAdminError.ipp(let status, _) where status == IPPStatus.notFound { + throw CupsAdminError.failed("no such job \(jobID)") + } + + let queue = JobsCommand.queueName(job) ?? "?" + let name = job.string("job-name") ?? "-" + let owner = job.string("job-originating-user-name") ?? "-" + print("Job \(jobID) on \(queue): \"\(name)\" by \(owner), \(Format.jobState(job.int("job-state")))") + + try await client.cancelJob(jobID: jobID) + + // Read back rather than trusting the successful status. + let after = try? await client.getJobAttributes(jobID: jobID, requested: ["job-state"]) + let state = Format.jobState(after?.int("job-state")) + print("Now: \(after == nil ? "gone from job history" : state)") + return "OK: canceled job \(jobID) on \(queue)" + } +} diff --git a/Sources/cupsadmin/Commands/JobsCommand.swift b/Sources/cupsadmin/Commands/JobsCommand.swift new file mode 100644 index 0000000..828bcba --- /dev/null +++ b/Sources/cupsadmin/Commands/JobsCommand.swift @@ -0,0 +1,39 @@ +import Foundation +import CupsKit + +enum JobsCommand { + static func run(client: CupsClient, queue: String?, which: String) async throws -> String { + let jobs = try await client.getJobs(queue: queue, which: which, requested: [ + "job-id", "job-name", "job-originating-user-name", "job-printer-uri", "job-state", + "job-state-reasons", "job-k-octets", "time-at-creation", + ]) + let rows = jobs.sorted { ($0.int("job-id") ?? 0) < ($1.int("job-id") ?? 0) }.map { j -> [String] in + let jobQueue = queueName(j) + return [ + j.int("job-id").map(String.init) ?? "?", + jobQueue ?? queue ?? "?", + j.string("job-originating-user-name") ?? "-", + Format.jobState(j.int("job-state")), + Format.kilobytes(j.int("job-k-octets")), + Format.epoch(j.int("time-at-creation")), + Format.reasons(j.strings("job-state-reasons")), + j.string("job-name") ?? "-", + ] + } + + let scope = queue.map { " on \($0)" } ?? "" + let label = which == "not-completed" ? "active" : which + if rows.isEmpty { + print("No \(label) jobs\(scope).") + } else { + printTable(headers: ["ID", "QUEUE", "OWNER", "STATE", "SIZE", "SUBMITTED", "REASONS", "NAME"], rows: rows) + } + return "OK: \(rows.count) \(label) job\(rows.count == 1 ? "" : "s")\(scope)" + } + + /// Queue name from job-printer-uri (ipp://host/printers/NAME). + static func queueName(_ job: IPPGroup) -> String? { + guard let uri = job.string("job-printer-uri"), let last = uri.split(separator: "/").last else { return nil } + return last.removingPercentEncoding ?? String(last) + } +} diff --git a/Sources/cupsadmin/Commands/OptionsCommand.swift b/Sources/cupsadmin/Commands/OptionsCommand.swift new file mode 100644 index 0000000..3d1e99c --- /dev/null +++ b/Sources/cupsadmin/Commands/OptionsCommand.swift @@ -0,0 +1,54 @@ +import Foundation +import CupsKit + +enum OptionsCommand { + /// Queue-level settings lpadmin -o can change that aren't named *-default. + static let policyAttributes = ["printer-error-policy", "printer-op-policy", "printer-is-shared", + "port-monitor", "job-quota-period", "job-k-limit", "job-page-limit"] + + static func run(client: CupsClient, queue: String, full: Bool) async throws -> String { + let attributes = try await client.getPrinterAttributes(queue: queue, requested: ["all"]) + let ppd = try await client.getPPD(queue: queue).map(PPD.init(text:)) + + if let ppd { + let a = ppd.attributes + let details = [a["Manufacturer"], a["PCFileName"], a["FileVersion"].map { "version \($0)" }] + .compactMap { $0 }.joined(separator: ", ") + print("PPD: \(a["NickName"] ?? "?")" + (details.isEmpty ? "" : " (\(details))")) + print("PPD options (* = queue default; per-user lpoptions overrides not applied):") + for option in ppd.options { + let choices = option.choices.map { $0 == option.defaultChoice ? "*\($0)" : $0 } + + [option.customChoice].compactMap { $0 } + var line = choices.joined(separator: " ") + if option.choices.isEmpty, let d = option.defaultChoice { line = "*\(d)" } + print(" \(option.keyword)/\(option.text): \(line)") + } + } else { + print("PPD: none (raw queue, or cupsd could not provide one)") + } + + let defaultNames = attributes.attributes.map(\.name).filter { $0.hasSuffix("-default") }.sorted() + let names = defaultNames + policyAttributes.filter { attributes[$0] != nil } + let rows = names.map { name -> [String] in + let base = name.hasSuffix("-default") ? String(name.dropLast("-default".count)) : name + return [name, + cell(attributes[name], full: full), + cell(attributes[base + "-supported"], full: full)] + } + print("\nIPP defaults and queue policy:") + printTable(headers: [" ATTRIBUTE", "DEFAULT", "SUPPORTED"], rows: rows.map { [" " + $0[0]] + $0.dropFirst() }) + if !full { print("\n(long lists truncated; use --full)") } + + return "OK: \(queue) \(ppd?.options.count ?? 0) PPD options, \(rows.count) IPP defaults/policies" + } + + private static func cell(_ attribute: IPPAttribute?, full: Bool) -> String { + guard let attribute else { return "-" } + var values = attribute.values.map { Format.value($0, attribute: attribute.name) } + if !full, values.count > 8 { + values = Array(values.prefix(8)) + ["… (+\(attribute.values.count - 8) more)"] + } + let text = values.joined(separator: ", ") + return !full && text.count > 100 ? String(text.prefix(99)) + "…" : text + } +} diff --git a/Sources/cupsadmin/Commands/PrinterCommands.swift b/Sources/cupsadmin/Commands/PrinterCommands.swift new file mode 100644 index 0000000..88efc57 --- /dev/null +++ b/Sources/cupsadmin/Commands/PrinterCommands.swift @@ -0,0 +1,107 @@ +import Foundation +import CupsKit + +enum PrintersCommand { + static func run(client: CupsClient) async throws -> String { + let printers = try await client.getPrinters(requested: [ + "printer-name", "printer-type", "printer-state", "printer-state-reasons", + "printer-is-accepting-jobs", "printer-is-shared", "queued-job-count", "device-uri", + ]) + let sorted = printers.sorted { + ($0.string("printer-name") ?? "").localizedStandardCompare($1.string("printer-name") ?? "") == .orderedAscending + } + let rows = sorted.map { p -> [String] in + [ + p.string("printer-name") ?? "?", + PrinterType.kind(p.int("printer-type")), + Format.printerState(p.int("printer-state")), + Format.yesNo(p.bool("printer-is-accepting-jobs")), + Format.yesNo(p.bool("printer-is-shared")), + p.int("queued-job-count").map(String.init) ?? "-", + Format.reasons(p.strings("printer-state-reasons")), + p.string("device-uri") ?? "-", + ] + } + if rows.isEmpty { + print("No queues.") + } else { + printTable(headers: ["QUEUE", "KIND", "STATE", "ACCEPTING", "SHARED", "JOBS", "REASONS", "DEVICE URI"], + rows: rows) + } + + let classes = sorted.filter { ($0.int("printer-type") ?? 0) & PrinterType.isClass != 0 }.count + let problems = sorted.filter { + $0.int("printer-state") == 5 || $0.bool("printer-is-accepting-jobs") == false + }.count + var status = "OK: \(sorted.count - classes) printers" + if classes > 0 { status += ", \(classes) class\(classes == 1 ? "" : "es")" } + if problems > 0 { status += ", \(problems) stopped or rejecting" } + return status + } +} + +enum PrinterCommand { + static func run(client: CupsClient, queue: String, dumpAll: Bool) async throws -> String { + // "all" in CUPS excludes a few expensive attributes (e.g. media-col-database), which is what we want. + let p = try await client.getPrinterAttributes(queue: queue, requested: ["all"]) + + if dumpAll { + let width = min(p.attributes.map(\.name.count).max() ?? 0, 40) + for attribute in p.attributes.sorted(by: { $0.name < $1.name }) { + let values = attribute.values.map { Format.value($0, attribute: attribute.name) } + let pad = String(repeating: " ", count: max(0, width - attribute.name.count)) + print("\(attribute.name)\(pad) \(values.joined(separator: ", "))") + } + return "OK: \(queue) \(Format.printerState(p.int("printer-state"))), \(p.attributes.count) attributes" + } + + let type = p.int("printer-type") + var lines: [(String, String)] = [ + ("Queue", p.string("printer-name") ?? queue), + ("Description", p.string("printer-info") ?? "-"), + ("Location", p.string("printer-location") ?? "-"), + ("Make and model", p.string("printer-make-and-model") ?? "-"), + ("Kind", PrinterType.kind(type)), + ] + if (type ?? 0) & PrinterType.isClass != 0 { + lines.append(("Members", p.strings("member-names").joined(separator: ", "))) + } + let stateMessage = p.string("printer-state-message") ?? "" + lines += [ + ("Device URI", p.string("device-uri") ?? "-"), + ("Printer URI", p.strings("printer-uri-supported").first ?? "-"), + ("State", Format.printerState(p.int("printer-state")) + (stateMessage.isEmpty ? "" : " — \(stateMessage)")), + ("State reasons", Format.reasons(p.strings("printer-state-reasons"))), + ("State changed", Format.epoch(p.int("printer-state-change-time"))), + ("Accepting jobs", Format.yesNo(p.bool("printer-is-accepting-jobs"))), + ("Shared", Format.yesNo(p.bool("printer-is-shared"))), + ("Queued jobs", p.int("queued-job-count").map(String.init) ?? "-"), + ("Capabilities", PrinterType.capabilities(type)), + ] + + let defaults = ["media-default", "sides-default", "print-color-mode-default", "print-quality-default", + "output-bin-default", "copies-default", "job-sheets-default", "printer-error-policy", + "printer-op-policy"] + for name in defaults { + guard let attribute = p[name] else { continue } + lines.append((name, attribute.values.map { Format.value($0, attribute: name) }.joined(separator: ", "))) + } + + let markerNames = p.strings("marker-names") + let markerLevels = p.ints("marker-levels") + for (i, marker) in markerNames.enumerated() { + let level = i < markerLevels.count ? markerLevels[i] : -1 + lines.append((i == 0 ? "Supplies" : "", "\(marker): " + (level >= 0 ? "\(level)%" : "unknown"))) + } + + let width = lines.map(\.0.count).max() ?? 0 + for (label, value) in lines { + let heading = label.isEmpty ? String(repeating: " ", count: width + 1) + : label + ":" + String(repeating: " ", count: width - label.count) + print("\(heading) \(value)") + } + print("\n(use --all for every attribute)") + + return "OK: \(queue) \(Format.printerState(p.int("printer-state"))), reasons \(Format.reasons(p.strings("printer-state-reasons")))" + } +} diff --git a/Sources/cupsadmin/Commands/QueueAdminCommands.swift b/Sources/cupsadmin/Commands/QueueAdminCommands.swift new file mode 100644 index 0000000..7dc86ae --- /dev/null +++ b/Sources/cupsadmin/Commands/QueueAdminCommands.swift @@ -0,0 +1,104 @@ +import Foundation +import CupsKit + +extension QueueSpec { + /// Parses ` [-v uri] [-m model | -P ppd] [-D text] [-L text] [-o key=value ...]`. + static func parse(_ args: [String], command: String) throws -> (queue: String, spec: QueueSpec) { + var spec = QueueSpec() + var queue: String? + var i = 0 + func value(_ flag: String) throws -> String { + i += 1 + guard i < args.count else { throw CupsAdminError.usage("\(flag) needs a value") } + return args[i] + } + while i < args.count { + let arg = args[i] + switch arg { + case "-v": spec.deviceURI = try value(arg) + case "-m": spec.model = try value(arg) + case "-P": spec.ppdFile = try value(arg) + case "-D": spec.description = try value(arg) + case "-L": spec.location = try value(arg) + case "-o": + let pair = try value(arg) + guard let eq = pair.firstIndex(of: "="), eq != pair.startIndex else { + throw CupsAdminError.usage("-o expects key=value, got \(pair)") + } + spec.options.append((String(pair[.. Int32 { + print("+ " + CupsTools.commandLine(executable, arguments)) + fflush(stdout) + let result = try CupsTools.run(executable, arguments) + FileHandle.standardOutput.write(result.standardOutput) + FileHandle.standardError.write(result.standardError) + return result.status +} + +enum AddCommand { + static func run(client: CupsClient, queue: String, spec: QueueSpec, shared: Bool) async throws -> String { + let spec = try await QueueAdmin.prepareAdd(client: client, queue: queue, spec: spec, shared: shared) + let status = try runTool(CupsTools.lpadminPath, QueueAdmin.addArguments(queue: queue, spec: spec)) + let after = try await QueueAdmin.confirmAdded(client: client, queue: queue, lpadminStatus: status) + + let a = after.attributes + print("\nAdded \(queue): \(Format.printerState(a.int("printer-state"))), " + + "accepting \(Format.yesNo(a.bool("printer-is-accepting-jobs"))), " + + "model \(a.string("printer-make-and-model") ?? "-")") + + let settings = after.settings(for: spec) + let failed = settings.filter { !$0.applied } + if !settings.isEmpty { + printTable(headers: ["SETTING", "SOURCE", "VALUE", "RESULT"], rows: settings.map { + [$0.name, $0.source, $0.value ?? "-", $0.applied ? "ok" : "NOT APPLIED (wanted \($0.wanted))"] + }) + } + guard failed.isEmpty else { + throw CupsAdminError.failed("added \(queue), but \(failed.count) setting(s) not applied: " + + failed.map(\.name).joined(separator: ", ")) + } + return "OK: added \(queue), \(settings.count) setting(s) verified" + } +} + +enum SetCommand { + static func run(client: CupsClient, queue: String, spec: QueueSpec) async throws -> String { + let before = try await QueueAdmin.prepareSet(client: client, queue: queue, spec: spec) + let status = try runTool(CupsTools.lpadminPath, spec.lpadminArguments(queue: queue)) + let after = try await QueueAdmin.confirmSet(client: client, queue: queue, spec: spec, lpadminStatus: status) + + print() + let rows = zip(before, after).map { b, a -> [String] in + [a.name, a.source, b.value ?? "-", a.value ?? "-", a.applied ? "ok" : "NOT APPLIED (wanted \(a.wanted))"] + } + printTable(headers: ["SETTING", "SOURCE", "BEFORE", "AFTER", "RESULT"], rows: rows) + if spec.model != nil || spec.ppdFile != nil { + print("(driver changed; see `cupsadmin printer \(queue)` for make-and-model)") + } + + let failed = after.filter { !$0.applied } + guard failed.isEmpty else { + throw CupsAdminError.failed("lpadmin exited 0 but \(failed.count) setting(s) not applied: " + + failed.map(\.name).joined(separator: ", ")) + } + return "OK: \(queue) \(after.count) setting(s) changed and verified" + } +} diff --git a/Sources/cupsadmin/Commands/QuickCommand.swift b/Sources/cupsadmin/Commands/QuickCommand.swift new file mode 100644 index 0000000..b1ecb30 --- /dev/null +++ b/Sources/cupsadmin/Commands/QuickCommand.swift @@ -0,0 +1,73 @@ +import Foundation +import CupsKit + +enum QuickCommand { + static func run(client: CupsClient, args: [String], clear: Bool) async throws -> String { + guard let name = args.first else { + printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map { + [$0.id, $0.input == .userCode ? " | --clear" : "", $0.summary] + }) + return "OK: \(QuickAction.all.count) quick actions" + } + guard let action = QuickAction.named(name) else { + throw CupsAdminError.usage("unknown quick action \(name) (one of: \(QuickAction.all.map(\.id).joined(separator: ", ")))") + } + guard args.count >= 2 else { throw CupsAdminError.usage("quick \(name) needs a queue name") } + let queue = args[1] + let value: String? + switch action.input { + case .none: + guard args.count == 2, !clear else { throw CupsAdminError.usage("quick \(name) takes only a queue name") } + value = nil + case .userCode: + if clear { + guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") } + value = "" + } else { + guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") } + value = args[2] + } + } + guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") } + + if case .defaultPrinter = action.effect { + let before = try await client.getDefaultPrinter() + let status = try runTool(CupsTools.lpadminPath, ["-d", queue]) + guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); default not changed") } + let after = try await client.getDefaultPrinter() + print() + printTable(headers: ["SETTING", "BEFORE", "AFTER", "RESULT"], + rows: [["server default", before ?? "none", after ?? "none", after == queue ? "ok" : "NOT APPLIED (wanted \(queue))"]]) + guard after == queue else { throw CupsAdminError.failed("lpadmin exited 0 but the server default is \(after ?? "none")") } + return "OK: \(queue) is the server default printer" + } + + let before = try await OptionState.load(client: client, queue: queue) + if let value, let error = action.validationError(value, ppd: before.ppd) { + throw CupsAdminError.usage("user code \(value): \(error)") + } + let changes: [OptionChange] + switch action.changes(for: before.ppd, value: value) { + case .success(let resolved): changes = resolved + case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) isn’t available on \(queue): \(unavailable.reason)") + } + + let (executable, arguments) = OptionApplier.command(queue: queue, changes: changes) + let status = try runTool(executable, arguments) + guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); nothing verified") } + let after = try await OptionState.load(client: client, queue: queue) + let checks = changes.map { OptionApplier.verify($0, state: after) } + + print() + printTable(headers: ["SETTING", "BEFORE", "AFTER", "RESULT"], rows: zip(changes, checks).map { change, check in + [change.key, before.queueValue(change.key) ?? "-", after.queueValue(change.key) ?? "-", + check.applied ? "ok" : "NOT APPLIED (wanted \(change.value))"] + }) + let failed = checks.filter { !$0.applied } + guard failed.isEmpty else { + throw CupsAdminError.failed("lpadmin exited 0 but \(failed.count) setting(s) not applied: " + + failed.map(\.key).joined(separator: ", ")) + } + return "OK: \(queue) \(action.id) applied, \(changes.count) setting(s) verified" + } +} diff --git a/Sources/cupsadmin/Commands/TableOutput.swift b/Sources/cupsadmin/Commands/TableOutput.swift new file mode 100644 index 0000000..6b6a1a7 --- /dev/null +++ b/Sources/cupsadmin/Commands/TableOutput.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Left-aligned columns, two spaces apart; last column unpadded. +func printTable(headers: [String], rows: [[String]]) { + var widths = headers.map(\.count) + for row in rows { + for (i, cell) in row.enumerated() where i < widths.count { + widths[i] = max(widths[i], cell.count) + } + } + func line(_ cells: [String]) -> String { + cells.enumerated().map { i, cell in + i == cells.count - 1 ? cell : cell + String(repeating: " ", count: widths[i] - cell.count) + }.joined(separator: " ") + } + print(line(headers)) + rows.forEach { print(line($0)) } +} diff --git a/Sources/cupsadmin/main.swift b/Sources/cupsadmin/main.swift new file mode 100644 index 0000000..de24090 --- /dev/null +++ b/Sources/cupsadmin/main.swift @@ -0,0 +1,138 @@ +import Foundation +import CupsKit + +// Hard rule: started/status/finished on every exit path. These go to stderr so stdout stays pipeable. + +let startTime = Date() + +func clock(_ date: Date) -> String { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss" + return f.string(from: date) +} + +func printErr(_ line: String) { + fflush(stdout) + FileHandle.standardError.write(Data((line + "\n").utf8)) +} + +func finish(_ code: Int32, _ status: String) -> Never { + printErr(status) + let end = Date() + let minutes = String(format: "%.1f", end.timeIntervalSince(startTime) / 60) + printErr("finished \(clock(end)) (total \(minutes) min)") + exit(code) +} + +let usage = """ + usage: cupsadmin [args] + + printers all queues: state, reasons, accepting, shared, device URI + printer [--all] one queue in detail (--all dumps every IPP attribute) + jobs [queue] [--completed | --all-jobs] + active jobs (all queues unless one is given) + cancel cancel one job (123 or Queue-123) + options [--full] PPD options (lpoptions -l style) and IPP *-default/*-supported + add -v [-m model | -P ppd] [-D description] [-L location] [-o key=value ...] [--shared] + create, enable and accept a queue via lpadmin, then verify + (not shared unless --shared) + set [-o key=value ...] [-v uri] [-D description] [-L location] [-m model | -P ppd] + change a queue via lpadmin, then show before/after + quick list quick actions (task-named queue defaults) + quick [code | --clear] + apply one via lpadmin, then verify + help show this help + """ + +printErr("started \(clock(startTime))") + +// Ctrl-C / kill still reports finished. +var signalSources: [DispatchSourceSignal] = [] +for sig in [SIGINT, SIGTERM] { + signal(sig, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: sig, queue: .main) + source.setEventHandler { finish(128 + sig, "ERROR: interrupted by signal \(sig)") } + source.resume() + signalSources.append(source) +} + +func runCommand(_ args: [String]) async throws -> String { + guard let command = args.first else { throw CupsAdminError.usage("no command given") } + var rest = Array(args.dropFirst()) + let client = CupsClient() + + func takeFlag(_ flag: String) -> Bool { + guard let i = rest.firstIndex(of: flag) else { return false } + rest.remove(at: i) + return true + } + func rejectUnknownFlags() throws { + if let flag = rest.first(where: { $0.hasPrefix("-") }) { + throw CupsAdminError.usage("unknown option \(flag) for \(command)") + } + } + + switch command { + case "help", "-h", "--help": + print(usage) + return "OK: help" + + case "printers": + try rejectUnknownFlags() + guard rest.isEmpty else { throw CupsAdminError.usage("printers takes no arguments") } + return try await PrintersCommand.run(client: client) + + case "printer": + let dumpAll = takeFlag("--all") + try rejectUnknownFlags() + guard rest.count == 1 else { throw CupsAdminError.usage("printer needs exactly one queue name") } + return try await PrinterCommand.run(client: client, queue: rest[0], dumpAll: dumpAll) + + case "jobs": + let completed = takeFlag("--completed") + let all = takeFlag("--all-jobs") + try rejectUnknownFlags() + guard rest.count <= 1 else { throw CupsAdminError.usage("jobs takes at most one queue name") } + guard !(completed && all) else { throw CupsAdminError.usage("use --completed or --all-jobs, not both") } + let which = completed ? "completed" : all ? "all" : "not-completed" + return try await JobsCommand.run(client: client, queue: rest.first, which: which) + + case "cancel": + try rejectUnknownFlags() + guard rest.count == 1 else { throw CupsAdminError.usage("cancel needs exactly one job id") } + return try await CancelCommand.run(client: client, jobID: try CancelCommand.parseJobID(rest[0])) + + case "options": + let full = takeFlag("--full") + try rejectUnknownFlags() + guard rest.count == 1 else { throw CupsAdminError.usage("options needs exactly one queue name") } + return try await OptionsCommand.run(client: client, queue: rest[0], full: full) + + case "add": + let shared = takeFlag("--shared") + let (queue, spec) = try QueueSpec.parse(rest, command: command) + return try await AddCommand.run(client: client, queue: queue, spec: spec, shared: shared) + + case "set": + let (queue, spec) = try QueueSpec.parse(rest, command: command) + return try await SetCommand.run(client: client, queue: queue, spec: spec) + + case "quick": + let clear = takeFlag("--clear") + try rejectUnknownFlags() + return try await QuickCommand.run(client: client, args: rest, clear: clear) + + default: + throw CupsAdminError.usage("unknown command \(command)") + } +} + +do { + let status = try await runCommand(Array(CommandLine.arguments.dropFirst())) + finish(0, status) +} catch CupsAdminError.usage(let message) { + printErr(usage) + finish(2, "ERROR: \(message)") +} catch { + finish(1, "ERROR: \(error)") +} diff --git a/Tests/CupsKitTests/OptionEditingTests.swift b/Tests/CupsKitTests/OptionEditingTests.swift new file mode 100644 index 0000000..01c8162 --- /dev/null +++ b/Tests/CupsKitTests/OptionEditingTests.swift @@ -0,0 +1,218 @@ +@testable import CupsKit +import Foundation +import Testing + +/// Parser/control tests read Ricoh's vendor PPDs (read-only); each test is skipped when its PPD isn't installed. +let vendorPPDs = "/Library/Printers/PPDs/Contents/Resources/" + +func vendorPPD(_ name: String) -> PPD? { + (try? String(contentsOfFile: vendorPPDs + name, encoding: .isoLatin1)).map(PPD.init(text:)) +} + +func hasVendorPPD(_ name: String) -> Bool { + FileManager.default.fileExists(atPath: vendorPPDs + name) +} + +@Suite struct PPDParsing { + @Test(.enabled(if: hasVendorPPD("RICOH IM C4500"))) + func ricohGroupsTypesAndCustomParameters() throws { + let ppd = try #require(vendorPPD("RICOH IM C4500")) + #expect(ppd.groups.contains("User Authentication")) + #expect(ppd.groups.contains(PPD.generalGroup)) + + let duplex = try #require(ppd.option("Duplex")) + #expect(duplex.group == PPD.generalGroup) + #expect(duplex.choiceList.contains(PPD.Choice(keyword: "DuplexNoTumble", text: "Long Edge"))) + + let enable = try #require(ppd.option("RIEnableUserCode")) + #expect(enable.group == "Job Log") + #expect(enable.control == .toggle(on: "True", off: "False")) + + guard case .field(let code) = try #require(ppd.option("RIUserCode")).control else { + Issue.record("RIUserCode should be a field"); return + } + #expect(code.kind == .string) + #expect(code.maximum == 8) + #expect(!code.isSecret) + + guard case .field(let pin) = try #require(ppd.option("RIPassword")).control else { + Issue.record("RIPassword should be a field"); return + } + #expect(pin.kind == .passcode) + #expect(pin.isSecret) + #expect(pin.minimum == 4 && pin.maximum == 8) + + guard case .field(let loginPassword) = try #require(ppd.option("RIAuthLoginPassword")).control else { + Issue.record("RIAuthLoginPassword should be a field"); return + } + #expect(loginPassword.kind == .password) + + // PageSize has a multi-parameter points custom size: stays a picker. + guard case .picker = try #require(ppd.option("PageSize")).control else { + Issue.record("PageSize should be a picker"); return + } + guard case .picker = try #require(ppd.option("ColorModel")).control else { + Issue.record("ColorModel should be a picker"); return + } + } + + @Test(.enabled(if: hasVendorPPD("RICOH M C251FW.ppd"))) + func booleanWithThreeChoicesFallsBackToPicker() throws { + let ppd = try #require(vendorPPD("RICOH M C251FW.ppd")) + let mode = try #require(ppd.option("HQPrintMode")) + #expect(mode.uiType == .boolean) + #expect(mode.choices.count == 3) + guard case .picker(let choices) = mode.control else { Issue.record("expected picker"); return } + #expect(choices.map(\.keyword) == ["661", "662", "664"]) + } + + @Test func inlineGroupsAndJCL() { + let ppd = PPD(text: """ + *PPD-Adobe: "4.3" + *OpenUI *Duplex/Two-Sided: PickOne + *DefaultDuplex: None + *Duplex None/Off: "" + *Duplex DuplexNoTumble/Long Edge: "" + *CloseUI: *Duplex + *OpenGroup: Auth/Authentication + *OpenSubGroup: Codes/Codes + *JCLOpenUI *JCLCode/Code: PickOne + *DefaultJCLCode: None + *JCLCode None/None: "" + *JCLCloseUI: *JCLCode + *CustomJCLCode True/Custom: "" + *ParamCustomJCLCode Code/Account Code: 1 string 1 12 + *CloseSubGroup: Codes + *CloseGroup: Auth + """) + #expect(ppd.groups == ["General", "Authentication / Codes"]) + let code = ppd.option("JCLCode") + #expect(code?.isJCL == true) + #expect(code?.customParameters.first?.text == "Account Code") + #expect(code?.customChoice == "Custom.STRING") + } +} + +@Suite struct CustomValues { + let field: CustomField = { + let ppd = PPD(text: """ + *OpenUI *RIPassword/Password: PickOne + *DefaultRIPassword: None + *RIPassword None/None: "" + *CloseUI: *RIPassword + *CustomRIPassword True/Custom: "" + *ParamCustomRIPassword Password: 1 passcode 4 8 + """) + guard case .field(let f) = ppd.option("RIPassword")!.control else { fatalError() } + return f + }() + + @Test func prefixIsInvisible() { + #expect(field.text(fromValue: "Custom.1234") == "1234") + #expect(field.text(fromValue: "None") == "") + #expect(field.value(fromText: "1234") == "Custom.1234") + #expect(field.value(fromText: "") == "None") + } + + @Test func passcodeValidation() { + #expect(field.validationError("123") == "At least 4 characters") + #expect(field.validationError("123456789") == "At most 8 characters") + #expect(field.validationError("12a4") == "Digits only") + #expect(field.validationError("1234") == nil) + #expect(field.validationError("") == nil) + } + + @Test func quotingAndMasking() { + #expect(OptionEncoding.argument(key: "RIUserCode", value: "Custom.12345") == "RIUserCode=Custom.12345") + #expect(OptionEncoding.argument(key: "RIAuthLoginUserNameText", value: "Custom.John Smith") + == "RIAuthLoginUserNameText=\"Custom.John Smith\"") + #expect(OptionEncoding.argument(key: "k", value: "a\"b") == "k=\"a\\\"b\"") + #expect(OptionEncoding.masked("Custom.1234") == "Custom.••••") + let command = OptionApplier.displayCommand(queue: "Q", changes: [ + OptionChange(key: "RIPassword", value: "Custom.9876", isSecret: true), + OptionChange(key: "RIUserCode", value: "Custom.42", isSecret: false), + ]) + #expect(command == "/usr/sbin/lpadmin -p Q -o RIPassword=Custom.•••• -o RIUserCode=Custom.42") + #expect(!command.contains("9876")) + } +} + +@Suite struct ReadBackAndOrder { + private func state(_ ppdText: String, attributes: [IPPAttribute] = []) -> OptionState { + OptionState(queue: "Q", snapshot: QueueSnapshot(attributes: IPPGroup(tag: .printerAttributes, attributes: attributes), + ppd: PPD(text: ppdText)), userOptions: [:]) + } + + @Test func readBackFlagsTyposAndValuesThatDidNotStick() { + let s = state("*OpenUI *Duplex/Duplex: PickOne\n*DefaultDuplex: None\n*Duplex None/Off: \"\"\n*CloseUI: *Duplex\n", + attributes: [.string("job-sheets-default", .nameWithoutLanguage, ["none", "none"])]) + #expect(OptionApplier.verify(OptionChange(key: "Duplex", value: "None", isSecret: false), state: s).applied) + #expect(!OptionApplier.verify(OptionChange(key: "Duplex", value: "DuplexTumble", isSecret: false), state: s).applied) + let typo = OptionApplier.verify(OptionChange(key: "Duplx", value: "None", isSecret: false), state: s) + #expect(!typo.applied) + #expect(typo.reason == "Not applied: Q has no option Duplx") + #expect(OptionApplier.verify(OptionChange(key: "job-sheets-default", value: "none", isSecret: false), state: s).applied) + #expect(OptionApplier.command(queue: "Q", changes: []).executable == "/usr/sbin/lpadmin") + } + + @Test(.enabled(if: hasVendorPPD("RICOH IM C4500"))) + func generalBasicPaperFirstInstallableOptionsLast() throws { + let ppdText = try #require(try? String(contentsOfFile: vendorPPDs + "RICOH IM C4500", encoding: .isoLatin1)) + let titles = state(ppdText).sections().map(\.title) + #expect(Array(titles.prefix(3)) == ["General", "Basic", "Paper"]) + #expect(titles.last == "Installable Options") + #expect(titles.filter { $0 == "Installable Options" }.count == 1) + } +} + +@Suite struct LpOptionsParsing { + @Test func destLineWithQuotesAndBraces() { + let text = """ + Default OldPrinter + Dest Office sides=two-sided-long-edge RIAuthLoginUserNameText="Custom.My Name" PageSize={Width=100 Height=200} + Dest Office/draft print-quality=3 + """ + let office = LpOptionsFile.options(for: "Office", in: text) + #expect(office["sides"] == "two-sided-long-edge") + #expect(office["RIAuthLoginUserNameText"] == "Custom.My Name") + #expect(office["PageSize"] == "{Width=100 Height=200}") + #expect(office["print-quality"] == nil) // instance line ignored + #expect(LpOptionsFile.options(for: "OldPrinter", in: text).isEmpty) + } +} + +/// Live apply + read-back through the same code path the app uses. +/// Opt-in: CUPSKIT_LIVE_QUEUE= swift test. Restores every value it changes. +@Suite(.enabled(if: ProcessInfo.processInfo.environment["CUPSKIT_LIVE_QUEUE"] != nil)) +struct LiveApply { + // Queue defaults only: the app writes with lpadmin, never lpoptions. + let queue = ProcessInfo.processInfo.environment["CUPSKIT_LIVE_QUEUE"] ?? "" + + @Test func queueScopeCustomCodeToggleAndIPP() async throws { + let client = CupsClient() + let before = try await OptionState.load(client: client, queue: queue) + let originals = ["RIEnableUserCode", "RIUserCode", "RIAuthLoginUserNameText", "copies-default", "RIPassword"] + .map { ($0, before.queueValue($0)) } + + let changes = [ + OptionChange(key: "RIEnableUserCode", value: "True", isSecret: false), + OptionChange(key: "RIUserCode", value: "Custom.12345", isSecret: false), + OptionChange(key: "RIAuthLoginUserNameText", value: "Custom.John Smith", isSecret: false), + OptionChange(key: "RIPassword", value: "Custom.2468", isSecret: true), + OptionChange(key: "copies-default", value: "2", isSecret: false), + OptionChange(key: "RIUsrCode", value: "Custom.1", isSecret: false), // typo: must fail read-back + ] + let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: changes) + #expect(result.succeeded) + let byKey = Dictionary(uniqueKeysWithValues: readBack.map { ($0.key, $0) }) + for key in ["RIEnableUserCode", "RIUserCode", "RIAuthLoginUserNameText", "RIPassword", "copies-default"] { + #expect(byKey[key]?.applied == true, "\(key): \(byKey[key]?.reason ?? "")") + } + #expect(byKey["RIUsrCode"]?.applied == false) + + let restore = originals.compactMap { key, value in value.map { OptionChange(key: key, value: $0, isSecret: false) } } + let (_, restored, _) = try await OptionApplier.apply(client: client, queue: queue, changes: restore) + let allRestored = restored.allSatisfy { $0.applied } + #expect(allRestored, "\(restored.filter { !$0.applied })") + } +} diff --git a/Tests/CupsKitTests/QueueActionsTests.swift b/Tests/CupsKitTests/QueueActionsTests.swift new file mode 100644 index 0000000..8fbbab6 --- /dev/null +++ b/Tests/CupsKitTests/QueueActionsTests.swift @@ -0,0 +1,59 @@ +import CupsKit +import Foundation +import Testing + +/// Opt-in: CUPSKIT_LIVE_JOBS=, ./test.sh — two throwaway, paused queues. +/// Submits /etc/hosts as jobs, drives every action, and leaves both queues paused with no active jobs. +@Suite(.enabled(if: ProcessInfo.processInfo.environment["CUPSKIT_LIVE_JOBS"] != nil), .serialized) +struct LiveQueueActions { + let queues = (ProcessInfo.processInfo.environment["CUPSKIT_LIVE_JOBS"] ?? "").split(separator: ",").map(String.init) + + private func submit(_ queue: String) throws -> Int { + let result = try CupsTools.run(CupsTools.lpPath, ["-d", queue, "-t", "cupskit live test", "/etc/hosts"]) + let text = String(decoding: result.standardOutput, as: UTF8.self) // "request id is Q-123 (1 file(s))" + guard result.succeeded, let id = text.split(separator: " ").compactMap({ $0.split(separator: "-").last.flatMap { Int($0) } }).first else { + throw CupsAdminError.failed("lp failed: \(result.message)") + } + return id + } + + @Test func jobHoldReleaseMoveCancel() async throws { + try #require(queues.count == 2) + let client = CupsClient() + let id = try submit(queues[0]) + + let held = await QueueActions.perform(.hold, jobIDs: [id], client: client)[id] + #expect(held?.succeeded == true, "\(String(describing: held))") + #expect(held?.command == "/usr/bin/lp -i \(id) -H hold") + + let released = await QueueActions.perform(.release, jobIDs: [id], client: client)[id] + #expect(released?.succeeded == true, "\(String(describing: released))") + + let moved = await QueueActions.perform(.move(to: queues[1]), jobIDs: [id], client: client)[id] + #expect(moved?.succeeded == true, "\(String(describing: moved))") + + let canceled = await QueueActions.perform(.cancel, jobIDs: [id], client: client)[id] + #expect(canceled?.succeeded == true, "\(String(describing: canceled))") + + // Releasing an already-canceled job must fail, not report success. + let bogus = await QueueActions.perform(.hold, jobIDs: [id], client: client)[id] + #expect(bogus?.succeeded == false) + } + + @Test func cancelAllPauseResumeAcceptReject() async throws { + try #require(queues.count == 2) + let client = CupsClient() + let queue = queues[1] + _ = try submit(queue) + _ = try submit(queue) + + let cancelAll = try await QueueActions.perform(.cancelAllJobs, queue: queue, client: client) + #expect(cancelAll.succeeded, "\(cancelAll)") + #expect(try await client.getJobs(queue: queue, which: "not-completed", requested: ["job-id"]).isEmpty) + + for action in [PrinterAction.reject, .accept, .resume, .pause] { + let outcome = try await QueueActions.perform(action, queue: queue, client: client) + #expect(outcome.succeeded, "\(action): \(outcome)") + } + } +} diff --git a/Tests/CupsKitTests/QuickActionTests.swift b/Tests/CupsKitTests/QuickActionTests.swift new file mode 100644 index 0000000..8de6617 --- /dev/null +++ b/Tests/CupsKitTests/QuickActionTests.swift @@ -0,0 +1,95 @@ +import CupsKit +import Foundation +import Testing + +private func available(_ id: String, _ ppd: PPD?) -> Bool { + QuickAction.named(id)!.isAvailable(for: ppd) +} + +@Suite struct QuickActionAvailability { + @Test(.enabled(if: hasVendorPPD("RICOH IM C4500"))) + func ricohPSHasEverything() throws { + let ppd = try #require(vendorPPD("RICOH IM C4500")) + for id in ["color", "bw", "usercode", "duplex", "simplex", "letter", "default"] { + #expect(available(id, ppd), "\(id)") + } + let letter = try QuickAction.named("letter")!.changes(for: ppd).get() + #expect(letter == [OptionChange(key: "PageSize", value: "Letter", isSecret: false), + OptionChange(key: "RIPaperPolicy", value: "NearestSizeAdjust", isSecret: false)]) + let code = try QuickAction.named("usercode")!.changes(for: ppd, value: "4321").get() + #expect(code.map(\.value) == ["True", "Custom.4321"]) + let clear = try QuickAction.named("usercode")!.changes(for: ppd, value: "").get() + #expect(clear.map(\.value) == ["False", "None"]) + } + + @Test(.enabled(if: hasVendorPPD("RICOH SP 3710DN.ppd") && hasVendorPPD("RICOH M C251FW.ppd"))) + func otherDriversOnlyGetWhatTheirPPDHas() throws { + // SP 3710DN: mono PCL — no color option, no fit-to-paper; has Duplex. + let sp = try #require(vendorPPD("RICOH SP 3710DN.ppd")) + #expect(!available("color", sp) && !available("bw", sp)) + #expect(!available("letter", sp) && !available("usercode", sp)) + #expect(available("duplex", sp) && available("simplex", sp)) + + // M C251FW: color via HQColorMode. + let fw = try #require(vendorPPD("RICOH M C251FW.ppd")) + #expect(try QuickAction.named("color")!.changes(for: fw).get() == [OptionChange(key: "HQColorMode", value: "COLOR", isSecret: false)]) + + // AirPrint-generated PPD whose printer reported only grayscale: B&W yes, color no. + let airprint = PPD(text: """ + *OpenUI *ColorModel/Color Mode: PickOne + *DefaultColorModel: Gray + *ColorModel Gray/Grayscale: "" + *CloseUI: *ColorModel + *OpenUI *PageSize/Media Size: PickOne + *DefaultPageSize: Letter + *PageSize Letter/US Letter: "" + *CloseUI: *PageSize + """) + #expect(!available("color", airprint)) + #expect(available("bw", airprint)) + #expect(!available("duplex", airprint)) + } + + @Test func userCodeValidation() { + let action = QuickAction.named("usercode")! + let ppd = vendorPPD("RICOH IM C4500") + #expect(action.validationError("12345", ppd: ppd) == nil) + #expect(action.validationError("12a", ppd: ppd) == "Digits only") + #expect(action.validationError("123456789", ppd: ppd) == "At most 8 digits") + } +} + +/// Opt-in: CUPSKIT_LIVE_QUICK=, ./test.sh +/// Changes both queues; the generic one becomes the server default and is DELETED at the end. +@Suite(.enabled(if: ProcessInfo.processInfo.environment["CUPSKIT_LIVE_QUICK"] != nil), .serialized) +struct LiveQuickActions { + let queues = (ProcessInfo.processInfo.environment["CUPSKIT_LIVE_QUICK"] ?? "").split(separator: ",").map(String.init) + + @Test func everyActionOnRicohQueue() async throws { + try #require(queues.count == 2) + let client = CupsClient() + for (id, value) in [("letter", nil), ("bw", nil), ("color", nil), ("usercode", "2468"), + ("usercode", ""), ("simplex", nil), ("duplex", nil)] as [(String, String?)] { + let outcome = try await QuickAction.named(id)!.run(queue: queues[0], value: value, client: client) + #expect(outcome.succeeded, "\(id): \(outcome.message ?? "")") + } + } + + @Test func testPageDefaultAndDelete() async throws { + try #require(queues.count == 2) + let client = CupsClient() + let (page, jobID) = try await QueueActions.printTestPage(queue: queues[1], client: client) + #expect(page.succeeded && jobID != nil, "\(page)") + + let unavailable = try? await QuickAction.named("letter")!.run(queue: queues[1], client: client) + #expect(unavailable == nil) // generic PPD has no RIPaperPolicy: throws, writes nothing + + let made = try await QuickAction.named("default")!.run(queue: queues[1], client: client) + #expect(made.succeeded, "\(made.message ?? "")") + #expect(try await client.getDefaultPrinter() == queues[1]) + + let deleted = try await QueueActions.delete(queue: queues[1], client: client) + #expect(deleted.succeeded, "\(deleted)") + #expect(try await client.queueExists(queues[1]) == false) + } +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..5a2b414 --- /dev/null +++ b/build.sh @@ -0,0 +1,245 @@ +#!/bin/bash +# Universal release build -> signed CLI binary + signed "CUPS Admin.app" -> signed payload-free PKG +# (postinstall installs /usr/local/bin/cupsadmin) -> notarize -> staple -> spctl. +# Usage: ./build.sh full pipeline (VERSION=1.2.0 ./build.sh to set the version) +# ./build.sh --build-only build CLI and app, ad-hoc signed; no identities, no pkg, no notarization +# +# Signing settings come from the environment, or from an untracked build.env next to this script +# (plain KEY=value lines; a variable already set in the environment wins): +# CODESIGN_APP_IDENTITY SHA-1 of your "Developer ID Application" certificate +# CODESIGN_PKG_IDENTITY SHA-1 of your "Developer ID Installer" certificate +# NOTARY_PROFILE keychain profile from `xcrun notarytool store-credentials` +# CODESIGN_TEAM_ID optional: fail if the signatures aren't from this team +set -euo pipefail + +BUILD_ONLY=0 +for arg in "$@"; do + case "$arg" in + --build-only) BUILD_ONLY=1 ;; + *) echo "started $(date +%H:%M:%S)"; echo "ERROR: unknown argument $arg (use --build-only)" + echo "finished $(date +%H:%M:%S) (total 0.0 min)"; exit 2 ;; + esac +done + +START_EPOCH=$(date +%s) +echo "started $(date +%H:%M:%S)" +STATUS="ERROR: build.sh exited unexpectedly" + +finish() { + local rc=$? + if [ $rc -ne 0 ] && [ "${STATUS#OK}" != "$STATUS" ]; then STATUS="ERROR: exit $rc"; fi + echo "$STATUS" + echo "finished $(date +%H:%M:%S) (total $(awk -v s="$(( $(date +%s) - START_EPOCH ))" 'BEGIN{printf "%.1f", s/60}') min)" +} +trap finish EXIT +trap 'STATUS="ERROR: interrupted"; exit 130' INT TERM + +step() { STATUS="ERROR: failed at: $1"; echo; echo "==> $1"; } + +# --- configuration ------------------------------------------------------------- +cd "$(dirname "$0")" +VERSION="${VERSION:-1.0.1}" +IDENTIFIER="edu.wku.cupsadmin" + +# build.env fills in only what the environment doesn't already set. +SETTINGS="CODESIGN_APP_IDENTITY CODESIGN_PKG_IDENTITY NOTARY_PROFILE CODESIGN_TEAM_ID" +for name in $SETTINGS; do + if [ -n "${!name+set}" ]; then eval "FROM_ENV_$name=\"\${$name}\""; fi +done +if [ -f build.env ]; then + set -a; . ./build.env; set +a + echo "read build.env" +fi +for name in $SETTINGS; do + from_env="FROM_ENV_$name" + if [ -n "${!from_env+set}" ]; then eval "$name=\"\${$from_env}\""; fi +done + +require() { + [ -n "${!1:-}" ] || { STATUS="ERROR: $1 is not set ($2). Export it or add it to build.env"; exit 1; } +} +if [ $BUILD_ONLY -eq 0 ]; then + # SHA-1 hashes rather than names: a keychain can hold several identities with the same name. + require CODESIGN_APP_IDENTITY "SHA-1 of your Developer ID Application certificate" + require CODESIGN_PKG_IDENTITY "SHA-1 of your Developer ID Installer certificate" + require NOTARY_PROFILE "keychain profile from xcrun notarytool store-credentials" + APP_SIGN_ID="$CODESIGN_APP_IDENTITY" + INSTALLER_SIGN_ID="$CODESIGN_PKG_IDENTITY" +else + APP_SIGN_ID="-" # ad-hoc: runs on this Mac, not distributable +fi +TEAM_ID="${CODESIGN_TEAM_ID:-}" + +DIST="dist" +# Payload-free: pkgbuild always gives a payload a "." entry (the install-location directory, root:wheel 755), +# which would reset ownership of an existing /usr/local/bin. The binary rides in Scripts instead and +# pkg/postinstall copies it, creating /usr/local/bin only if missing. +SCRIPTS="$DIST/scripts" +BINARY="$SCRIPTS/cupsadmin" +APP="$DIST/CUPS Admin.app" +APP_IDENTIFIER="edu.wku.cupsadmin.app" +APP_EXECUTABLE="CUPSAdminApp" # can't be "CUPSAdmin": APFS is case-insensitive and would collide with cupsadmin +APP_ICON_SOURCE="Icon/AppIcon-1024.png" +PKG="$DIST/cupsadmin-$VERSION.pkg" + +# --- preflight ------------------------------------------------------------------ +step "preflight" +if [ $BUILD_ONLY -eq 0 ]; then + security find-identity -v -p codesigning | grep "$APP_SIGN_ID" >/dev/null \ + || { STATUS="ERROR: CODESIGN_APP_IDENTITY $APP_SIGN_ID is not a valid codesigning identity in the keychain"; exit 1; } + security find-identity -v | grep "$INSTALLER_SIGN_ID" >/dev/null \ + || { STATUS="ERROR: CODESIGN_PKG_IDENTITY $INSTALLER_SIGN_ID is not an identity in the keychain"; exit 1; } + xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1 \ + || { STATUS="ERROR: NOTARY_PROFILE '$NOTARY_PROFILE' missing or invalid (create it with xcrun notarytool store-credentials)"; exit 1; } + echo "identities and notary profile OK" +else + echo "build only: ad-hoc signing, no identities or notary profile needed" +fi + +# --- universal build ------------------------------------------------------------ +# `swift build --arch arm64 --arch x86_64` needs full Xcode (XCBuild); with only the Command Line Tools +# we build each triple and lipo them together. Same result. +step "universal release build" +# Only clear what this run rebuilds; earlier notarized pkgs in dist/ stay. +rm -rf "$SCRIPTS" "$APP" "$DIST/expanded" "$DIST/AppIcon.iconset" +mkdir -p "$SCRIPTS" +for arch in arm64 x86_64; do + swift build -c release --triple "$arch-apple-macosx14.0" +done +lipo -create -output "$BINARY" \ + .build/arm64-apple-macosx/release/cupsadmin \ + .build/x86_64-apple-macosx/release/cupsadmin +chmod 755 "$BINARY" +echo "architectures: $(lipo -archs "$BINARY")" + +# --- sign binary ---------------------------------------------------------------- +step "sign binary" +if [ $BUILD_ONLY -eq 0 ]; then + codesign --force --sign "$APP_SIGN_ID" --options runtime --timestamp --identifier "$IDENTIFIER" "$BINARY" +else + codesign --force --sign - --identifier "$IDENTIFIER" "$BINARY" +fi +codesign --verify --strict --verbose=2 "$BINARY" +codesign -dvv "$BINARY" 2>&1 | grep -E '^(Identifier|Authority|TeamIdentifier|Timestamp|Runtime Version)=' || true +if [ -n "$TEAM_ID" ] && [ $BUILD_ONLY -eq 0 ]; then + codesign -dvv "$BINARY" 2>&1 | grep "^TeamIdentifier=$TEAM_ID$" >/dev/null \ + || { STATUS="ERROR: binary signed with wrong team (expected CODESIGN_TEAM_ID=$TEAM_ID)"; exit 1; } +fi +"$BINARY" help >/dev/null 2>&1 || { STATUS="ERROR: signed binary failed to run"; exit 1; } + +# --- assemble + sign app -------------------------------------------------------- +step "assemble and sign app" +mkdir -p "$APP/Contents/MacOS" +lipo -create -output "$APP/Contents/MacOS/$APP_EXECUTABLE" \ + ".build/arm64-apple-macosx/release/$APP_EXECUTABLE" \ + ".build/x86_64-apple-macosx/release/$APP_EXECUTABLE" +cat > "$APP/Contents/Info.plist" < + + + + CFBundleIdentifier $APP_IDENTIFIER + CFBundleName CUPS Admin + CFBundleDisplayName CUPS Admin + CFBundleExecutable $APP_EXECUTABLE + CFBundleIconFile AppIcon + CFBundlePackageType APPL + CFBundleShortVersionString $VERSION + CFBundleVersion $VERSION + CFBundleInfoDictionaryVersion 6.0 + LSMinimumSystemVersion 14.0 + LSApplicationCategoryType public.app-category.utilities + NSHighResolutionCapable + NSPrincipalClass NSApplication + NSHumanReadableCopyright Western Kentucky University + + +PLIST +plutil -lint "$APP/Contents/Info.plist" + +# App icon: every iconset size from the 1024 px PNG, then one .icns. +[ -f "$APP_ICON_SOURCE" ] || { STATUS="ERROR: app icon $APP_ICON_SOURCE not found"; exit 1; } +ICONSET="$DIST/AppIcon.iconset" +rm -rf "$ICONSET"; mkdir -p "$ICONSET" "$APP/Contents/Resources" +for size in 16 32 128 256 512; do + sips -z $size $size "$APP_ICON_SOURCE" --out "$ICONSET/icon_${size}x${size}.png" >/dev/null + sips -z $((size * 2)) $((size * 2)) "$APP_ICON_SOURCE" --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null +done +iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns" +rm -rf "$ICONSET" +echo "icon: $(ls -l "$APP/Contents/Resources/AppIcon.icns" | awk '{print $5}') bytes" +printf 'APPL????' > "$APP/Contents/PkgInfo" +if [ $BUILD_ONLY -eq 0 ]; then + codesign --force --sign "$APP_SIGN_ID" --options runtime --timestamp "$APP" +else + codesign --force --sign - "$APP" +fi +codesign --verify --deep --strict --verbose=2 "$APP" +codesign -dvv "$APP" 2>&1 | grep -E '^(Identifier=|Authority=Developer ID|TeamIdentifier=|Runtime Version=)' || true +if [ -n "$TEAM_ID" ] && [ $BUILD_ONLY -eq 0 ]; then + codesign -dvv "$APP" 2>&1 | grep "^TeamIdentifier=$TEAM_ID$" >/dev/null \ + || { STATUS="ERROR: app signed with wrong team (expected CODESIGN_TEAM_ID=$TEAM_ID)"; exit 1; } +fi +echo "app architectures: $(lipo -archs "$APP/Contents/MacOS/$APP_EXECUTABLE")" + +if [ $BUILD_ONLY -eq 1 ]; then + STATUS="OK: built $BINARY and $APP, ad-hoc signed (build only: no pkg, no notarization)" + exit 0 +fi + +# --- build + sign pkg ----------------------------------------------------------- +step "build and sign pkg" +rm -f "$PKG" +cp pkg/postinstall "$SCRIPTS/postinstall" +chmod 755 "$SCRIPTS/postinstall" +pkgbuild --nopayload --scripts "$SCRIPTS" --identifier "$IDENTIFIER" --version "$VERSION" \ + --sign "$INSTALLER_SIGN_ID" --timestamp "$PKG" +pkgutil --check-signature "$PKG" + +step "verify pkg contents" +echo "pkgutil --payload-files $PKG:" +PAYLOAD_FILES=$(pkgutil --payload-files "$PKG" 2>&1 || true) +echo "${PAYLOAD_FILES:-(no output)}" +if [ -n "$(printf '%s' "$PAYLOAD_FILES" | grep -v -i 'no payload' || true)" ]; then + STATUS="ERROR: pkg has payload entries; expected none" + exit 1 +fi +EXPANDED="$DIST/expanded" +pkgutil --expand "$PKG" "$EXPANDED" +# pkgutil --expand unpacks the Scripts archive into a directory. +echo "Scripts: $(ls "$EXPANDED/Scripts" | tr '\n' ' ')" +[ -x "$EXPANDED/Scripts/postinstall" ] && [ -f "$EXPANDED/Scripts/cupsadmin" ] \ + || { STATUS="ERROR: Scripts missing postinstall or cupsadmin"; exit 1; } +codesign --verify --strict "$EXPANDED/Scripts/cupsadmin" \ + || { STATUS="ERROR: binary inside pkg Scripts lost its signature"; exit 1; } +echo "binary inside pkg: signature intact, $(lipo -archs "$EXPANDED/Scripts/cupsadmin")" +rm -rf "$EXPANDED" + +# --- notarize ------------------------------------------------------------------- +step "notarize (this uploads the pkg to Apple and waits)" +SUBMIT_JSON="$DIST/notary-submit.json" +xcrun notarytool submit "$PKG" --keychain-profile "$NOTARY_PROFILE" --wait --output-format json > "$SUBMIT_JSON" || true +NOTARY_STATUS=$(plutil -extract status raw "$SUBMIT_JSON" 2>/dev/null || echo "unknown") +SUBMISSION_ID=$(plutil -extract id raw "$SUBMIT_JSON" 2>/dev/null || echo "") +echo "submission $SUBMISSION_ID: $NOTARY_STATUS" +if [ -n "$SUBMISSION_ID" ]; then + xcrun notarytool log "$SUBMISSION_ID" --keychain-profile "$NOTARY_PROFILE" "$DIST/notary-log.json" >/dev/null 2>&1 || true +fi +if [ "$NOTARY_STATUS" != "Accepted" ]; then + STATUS="ERROR: notarization $NOTARY_STATUS (see $SUBMIT_JSON and $DIST/notary-log.json)" + exit 1 +fi + +# --- staple + verify ------------------------------------------------------------ +step "staple" +xcrun stapler staple "$PKG" +xcrun stapler validate "$PKG" + +step "spctl assessment" +spctl -a -vv -t install "$PKG" 2>&1 | tee "$DIST/spctl.txt" +grep -q ': accepted' "$DIST/spctl.txt" && grep -q 'source=Notarized Developer ID' "$DIST/spctl.txt" \ + || { STATUS="ERROR: spctl did not accept $PKG as Notarized Developer ID"; exit 1; } + +echo +echo "install: sudo installer -pkg $PKG -target /" +STATUS="OK: $PKG signed, notarized ($SUBMISSION_ID), stapled, spctl accepted" diff --git a/docs/ricoh-ppd-options.md b/docs/ricoh-ppd-options.md new file mode 100644 index 0000000..2d084ad --- /dev/null +++ b/docs/ricoh-ppd-options.md @@ -0,0 +1,1100 @@ +# Ricoh driver PPD options + +Reference for the options in Ricoh's macOS PPDs, generated from the vendor PPDs as installed under +`/Library/Printers/PPDs/Contents/Resources`. Defaults are the vendor defaults. + +Every `*OpenGroup` and `*OpenUI`/`*JCLOpenUI` option: keyword, label, UI type, default, choices +(`keyword (label)`), and custom-value parameters from `*CustomX True` + `*ParamCustomX`. +Options with no `*OpenGroup` are listed under **General**, which is where libcups and the CUPS web UI put them. + +Custom values are set as `-o Keyword=Custom.value` (one parameter) or `-o Keyword={Param=value ...}` (several). +Passcode and password values set as a queue default are stored in plain text in the world-readable queue PPD. + +## Drivers + +| PPD | NickName | PCFileName | Version | Groups | Options | Custom-value options | +|---|---|---|---|---|---|---| +| `RICOH IM C2000` | RICOH IM C2000 PS | RI3645E3.PPD | 1.3 | 11 | 84 | 23 | +| `RICOH IM C4500` | RICOH IM C4500 PS | RI3643E3.PPD | 1.2 | 11 | 88 | 23 | +| `RICOH MP C2004ex` | RICOH MP C2004ex PS | RI3625E3.PPD | 3.1 | 10 | 78 | 22 | +| `RICOH MP C3004ex` | RICOH MP C3004ex PS | RI3621E3.PPD | 3.1 | 10 | 79 | 22 | +| `RICOH MP C307` | RICOH MP C307 PS | RI3571E3.PPD | 3.1 | 11 | 76 | 23 | +| `RICOH MP C3504` | RICOH MP C3504 PS | RI3522E3.PPD | 3.1 | 10 | 78 | 22 | +| `RICOH MP 5055` | RICOH MP 5055 PS | RI1795E3.PPD | 3.1 | 11 | 76 | 20 | +| `RICOH M C251FW.ppd` | RICOH M C251FW PS | RICOH M C251FW.ppd | 1.01 | 8 | 31 | 1 | +| `RICOH SP 3710DN.ppd` | RICOH SP 3710DN | RICOH SP 3710DN.ppd | 1.02 | 1 | 6 | 0 | + +## RICOH IM C2000 PS + +### Installable Options + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `OptionTray` | Option Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `1Cassette` (Lower Paper Tray), `2Cassette` (Lower Paper Trays) | | +| `InnerTray2` | Internal Tray 2 | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `ShiftTray` | Internal Shift Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `ExternalTray` | External Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `Finisher` | Finisher | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `FinRUBICONC` (Finisher SR3250), `FinAMURCBK` (Finisher SR3270), `FinUYUNIB` (Finisher SR3300) | | +| `RIPostScript` | PostScript | PickOne | `IRIPS` | `IRIPS` (PostScript Emulation), `Adobe` (Adobe PostScript) | | + +### General + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `PageSize` | PageSize | PickOne | `A4` | `A3` (A3 (297 x 420 mm)), `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B4` (B4 JIS (257 x 364 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGermanLegal` (8.5 x 13), `11x17` (11 x 17), `12x18` (12 x 18), `11x15` (11 x 15), `10x14` (10 x 14), `SRA3` (SRA3 (320 x 450 mm)), `SRA4` (SRA4 (225 x 320 mm)), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `8Kai` (8K (267 x 390 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4), `A3.FullBleed` (A3 (297 x 420 mm) (Full Bleed)), `A4.FullBleed` (A4 (210 x 297 mm) (Full Bleed)), `A5.FullBleed` (A5 (148 x 210 mm) (Full Bleed)), `A6.FullBleed` (A6 (105 x 148 mm) (Full Bleed)), `B4.FullBleed` (B4 (JIS) (257 x 364 mm) (Full Bleed)), `B5.FullBleed` (B5 (JIS) (182 x 257 mm) (Full Bleed)), `B6.FullBleed` (B6 (JIS) (128 x 182 mm) (Full Bleed)), `Legal.FullBleed` (Legal (8.5 x 14) (Full Bleed)), `GovernmentLG.FullBleed` (8.25 x 14 (Full Bleed)), `EngQuatro.FullBleed` (8 x 10 (Full Bleed)), `Letter.FullBleed` (Letter (8.5 x 11) (Full Bleed)), `HalfLetter.FullBleed` (5.5 x 8.5 (Full Bleed)), `F.FullBleed` (8 x 13 (Full Bleed)), `Folio.FullBleed` (8.25 x 13 (Full Bleed)), `FanFoldGermanLegal.FullBleed` (8.5 x 13 (Full Bleed)), `11x17.FullBleed` (11 x 17 (Full Bleed)), `12x18.FullBleed` (12 x 18 (Full Bleed)), `11x15.FullBleed` (11 x 15 (Full Bleed)), `10x14.FullBleed` (10 x 14 (Full Bleed)), `SRA3.FullBleed` (SRA3 (320 x 450 mm) (Full Bleed)), `SRA4.FullBleed` (SRA4 (225 x 320 mm) (Full Bleed)), `Executive.FullBleed` (Executive (7.25 x 10.5) (Full Bleed)), `Env10.FullBleed` (Com10 Env. (4.125 x 9.5) (Full Bleed)), `EnvMonarch.FullBleed` (Monarch Env. (3.875 x 7.5) (Full Bleed)), `EnvC5.FullBleed` (C5 Env. (162 x 229 mm) (Full Bleed)), `EnvC6.FullBleed` (C6 Env. (114 x 162 mm) (Full Bleed)), `DLEnv.FullBleed` (DL Env. (110 x 220 mm) (Full Bleed)), `8Kai.FullBleed` (8K (267 x 390 mm) (Full Bleed)), `16Kai.FullBleed` (16K (195 x 267 mm) (Full Bleed)), `Oficio.FullBleed` (8.5 x 13.4 (Full Bleed)) | `Width` points 255–908; `Height` points 419–3572; `WidthOffset` points 0–0; `HeightOffset` points 0–0; `Orientation` int 1–1 | +| `PageRegion` | PageRegion | PickOne | `A4` | `A3` (A3 (297 x 420 mm)), `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B4` (B4 JIS (257 x 364 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGermanLegal` (8.5 x 13), `11x17` (11 x 17), `12x18` (12 x 18), `11x15` (11 x 15), `10x14` (10 x 14), `SRA3` (SRA3 (320 x 450 mm)), `SRA4` (SRA4 (225 x 320 mm)), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `8Kai` (8K (267 x 390 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4), `A3.FullBleed` (A3 (297 x 420 mm) (Full Bleed)), `A4.FullBleed` (A4 (210 x 297 mm) (Full Bleed)), `A5.FullBleed` (A5 (148 x 210 mm) (Full Bleed)), `A6.FullBleed` (A6 (105 x 148 mm) (Full Bleed)), `B4.FullBleed` (B4 (JIS) (257 x 364 mm) (Full Bleed)), `B5.FullBleed` (B5 (JIS) (182 x 257 mm) (Full Bleed)), `B6.FullBleed` (B6 (JIS) (128 x 182 mm) (Full Bleed)), `Legal.FullBleed` (Legal (8.5 x 14) (Full Bleed)), `GovernmentLG.FullBleed` (8.25 x 14 (Full Bleed)), `EngQuatro.FullBleed` (8 x 10 (Full Bleed)), `Letter.FullBleed` (Letter (8.5 x 11) (Full Bleed)), `HalfLetter.FullBleed` (5.5 x 8.5 (Full Bleed)), `F.FullBleed` (8 x 13 (Full Bleed)), `Folio.FullBleed` (8.25 x 13 (Full Bleed)), `FanFoldGermanLegal.FullBleed` (8.5 x 13 (Full Bleed)), `11x17.FullBleed` (11 x 17 (Full Bleed)), `12x18.FullBleed` (12 x 18 (Full Bleed)), `11x15.FullBleed` (11 x 15 (Full Bleed)), `10x14.FullBleed` (10 x 14 (Full Bleed)), `SRA3.FullBleed` (SRA3 (320 x 450 mm) (Full Bleed)), `SRA4.FullBleed` (SRA4 (225 x 320 mm) (Full Bleed)), `Executive.FullBleed` (Executive (7.25 x 10.5) (Full Bleed)), `Env10.FullBleed` (Com10 Env. (4.125 x 9.5) (Full Bleed)), `EnvMonarch.FullBleed` (Monarch Env. (3.875 x 7.5) (Full Bleed)), `EnvC5.FullBleed` (C5 Env. (162 x 229 mm) (Full Bleed)), `EnvC6.FullBleed` (C6 Env. (114 x 162 mm) (Full Bleed)), `DLEnv.FullBleed` (DL Env. (110 x 220 mm) (Full Bleed)), `8Kai.FullBleed` (8K (267 x 390 mm) (Full Bleed)), `16Kai.FullBleed` (16K (195 x 267 mm) (Full Bleed)), `Oficio.FullBleed` (8.5 x 13.4 (Full Bleed)) | | +| `InputSlot` | InputSlot | PickOne | `1Tray` | `MultiTray` (Bypass Tray), `1Tray` (Tray 1), `2Tray` (Tray 2), `3Tray` (Tray 3), `4Tray` (Tray 4) | | +| `Duplex` | Duplex | PickOne | `DuplexNoTumble` | `None` (Off), `DuplexNoTumble` (Long Edge), `DuplexTumble` (Short Edge) | | +| `Collate` | Collate | PickOne | `False` | `False` (Off), `True` (On) | | + +### Basic + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIPaperPolicy` | Fit to Paper | PickOne | `PromptUser` | `PromptUser` (Prompt User), `NearestSizeAdjust` (Nearest Size and Scale), `NearestSizeNoAdjust` (Nearest Size and Crop) | | +| `ColorModel` | Color Mode | PickOne | `CMYK` | `CMYK` (Color), `Gray` (Black and White) | | +| `RIRotateBy180` | Rotate by 180 degrees | PickOne | `Off` | `Off`, `On` | | + +### Paper + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `MediaType` | Paper Type | PickOne | `Auto` | `Auto` (Plain/Recycled), `Plain1` (Plain 1 (60 - 74 g/m2)), `Plain2` (Plain 2 (75 - 81 g/m2)), `Recycled`, `Special1` (Special 1), `Special2` (Special 2), `Special3` (Special 3), `Colored` (Color), `Letterhead`, `Preprinted`, `Labels`, `Bond`, `Cardstock`, `OHP` (Transparency), `Thick1` (Thick 1 (106 - 169 g/m2)), `Thick2` (Thick 2 (170 - 220 g/m2)), `Thick3` (Thick 3 (221 - 256 g/m2)), `Thick4` (Thick 4 (257 - 300 g/m2)), `Thin` (Thin (52 - 59 g/m2)), `Middlethick` (Middle Thick (82 - 105 g/m2)), `GlossCoated` (Coated (Glossy)), `MatCoated` (Coated (Matte)), `Envelope`, `None` | | +| `OutputBin` | Destination | PickOne | `Default` | `Default` (Printer Default), `Standard` (Internal Tray 1), `Bin1` (Internal Tray 2), `Shift` (Internal Shift Tray), `External` (External Tray), `FinRUBICONCShift` (Finisher SR3250 Shift Tray), `FinAMURCBKUpper` (Finisher SR3270 Upper Tray), `FinAMURCBKShift` (Finisher SR3270 Shift Tray), `FinAMURCBKBKLower` (Finisher SR3270 Booklet Tray), `FinUYUNIBShift` (Finisher SR3300 Shift Tray) | | +| `RIBannerPagePrint` | Banner Page | PickOne | `False` | `False` (Off), `True` (On) | | +| `RIBannerPageInputSlot` | Banner Page Input Tray | PickOne | `Auto` | `Auto` (Auto Tray Select), `MultiTray` (Bypass Tray), `1Tray` (Tray 1), `2Tray` (Tray 2), `3Tray` (Tray 3), `4Tray` (Tray 4) | | +| `RIBannerPageMediaType` | Banner Page Paper Type | PickOne | `Auto` | `Auto` (Plain/Recycled), `Plain1` (Plain 1 (60 - 74 g/m2)), `Plain2` (Plain 2 (75 - 81 g/m2)), `Recycled`, `Special1` (Special 1), `Special2` (Special 2), `Special3` (Special 3), `Colored` (Color), `Letterhead`, `Preprinted`, `Labels`, `Bond`, `Cardstock`, `OHP` (Transparency), `Thick1` (Thick 1 (106 - 169 g/m2)), `Thick2` (Thick 2 (170 - 220 g/m2)), `Thick3` (Thick 3 (221 - 256 g/m2)), `Thick4` (Thick 4 (257 - 300 g/m2)), `Thin` (Thin (52 - 59 g/m2)), `Middlethick` (Middle Thick (82 - 105 g/m2)), `GlossCoated` (Coated (Glossy)), `MatCoated` (Coated (Matte)), `Envelope` | | + +### Finishing + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIOrientOvr` | Orientation Override | PickOne | `Off` | `Off`, `Landscape`, `Portrait` | | +| `RICollateKind` | Collate Type | PickOne | `Normal` | `Normal` (Collate), `RotateCollate` (Rotating Collate), `ShiftCollate` (Shift Collate) | | +| `StapleLocation` | Staple | PickOne | `None` | `None` (Off), `StaplessUpperLeft` (Top left (stapleless)), `StaplessUpperRight` (Top right (stapleless)), `UpperLeft` (Top left), `UpperRight` (Top right), `LeftW` (2 at left), `RightW` (2 at right), `UpperW` (2 at top), `CenterW` (2 at center) | | +| `RIPunch` | Punch | PickOne | `None` | `None` (Off), `Left2` (2 at left), `Left3` (3 at left), `Left4` (4 at left), `Right2` (2 at right), `Right3` (3 at right), `Right4` (4 at right), `Upper2` (2 at top), `Upper3` (3 at top), `Upper4` (4 at top) | | +| `RIFoldType` | Fold Type | PickOne | `None` | `None` (Off), `OutsideTwofold` (Half Fold - Print Outside (Finisher Booklet Tray)) | | +| `Booklet` | Booklet | PickOne | `None` | `None` (Off), `OpenToLeft` (Open to Left/Top), `OpenToRight` (Open to Right/Bottom), `OpenToLeftNoneRed` (Open to Left/Top (Full Size)), `OpenToRightNoneRed` (Open to Right/Bottom (Full Size)) | | +| `RIBookletPageSize` | Booklet - Paper Size | PickOne | `Default` | `Default` (Printer Default), `A3` (A3 (297 x 420 mm)), `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B4` (B4 JIS (257 x 364 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGermanLegal` (8.5 x 13), `11x17` (11 x 17), `12x18` (12 x 18), `11x15` (11 x 15), `10x14` (10 x 14), `SRA3` (SRA3 (320 x 450 mm)), `SRA4` (SRA4 (225 x 320 mm)), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `8Kai` (8K (267 x 390 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4) | | + +### Print Quality + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `Resolution` | Resolution | PickOne | `600dpi` | `600dpi` (600 dpi), `1200dpi` (1200 dpi) | | +| `RPSBitsPerPixel` | Gradation | PickOne | `2BitsPerPixel` | `2BitsPerPixel` (Standard), `1BitsPerPixel` (Fast), `4BitsPerPixel` (Fine) | | +| `RIPrintMode` | Print Mode | PickOne | `0rhit` | `0rhit` (Off), `3rhit` (Toner Saving) | | +| `Rimagesm` | Image Smoothing | PickOne | `Off` | `Off`, `On`, `Auto`, `90ppi` (Less than 90 ppi), `150ppi` (Less than 150 ppi), `200ppi` (Less than 200 ppi), `300ppi` (Less than 300 ppi) | | +| `RPSDitherType` | Dithering | PickOne | `Auto` | `Auto`, `Photo` (Photographic), `Letter` (Text), `User` (User Setting), `Dispersion` (Reduce Missing Colors and Blurring) | | +| `RPSRGBcorrect` | Color Setting | PickOne | `DetailBright` | `None` (Off), `DetailNormal` (Fine), `DetailBright` (Super Fine) | | +| `RPSColorRendDict` | Color Profile | PickOne | `Auto` | `Auto`, `AutoBright` (Auto (Brighter)), `AutoDeep` (Auto (Darker)), `Photograph` (Photographic), `PhotographBright` (Photographic (Brighter)), `PhotographDeep` (Photographic (Darker)), `Business` (Presentation), `BusinessBright` (Presentation (Brighter)), `BusinessDeep` (Presentation (Darker)), `Colorimetric` (Solid Color), `ColorimetricBright` (Solid Color (Brighter)), `ColorimetricDeep` (Solid Color (Darker)), `POP` (POP Display), `User` (User Setting), `Clpsimulation1` (Soft), `Clpsimulation2` (Sharp), `Clpsimulation4` (Vivid), `Clpsimulation7` (Deep), `Clpsimulation` (CLP Simulation) | | +| `Rcmyksimulation` | CMYK Simulation Profile | PickOne | `Off` | `Off`, `USOffsetPrint` (US OffsetPrint), `Euroscale`, `JapanColor` (JapanColor2001), `PANTONE` (PaletteColor), `Trans01` (Make Bluer) | | +| `RPSBlackMode` | Gray Reproduction | PickOne | `gray` | `gray` (Black/Gray by K (Text/Line Art)), `1Color` (Black by K), `4Color` (CMY+K), `grayText` (Black/Gray by K (Text only)), `1ColorText` (Black by K (Text only)), `grayAll` (Black/Gray by K (Strong UCR)) | | +| `RPSBlackOverPrint` | Black Over Print | PickOne | `False` | `False` (Off), `True` (On) | | +| `RPSColorSep` | Separate into CMYK | PickOne | `None` | `None` (Do not Separate), `Cyan`, `Magenta`, `Yellow`, `Black`, `Red` (Magenta and Yellow), `Green` (Cyan and Yellow), `Blue` (Cyan and Magenta), `KCyan` (Black and Cyan), `KMagenta` (Black and Magenta), `KYellow` (Black and Yellow) | | + +### Effects + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIWatermark` | Watermark | PickOne | `Off` | `Off`, `On` | | +| `RIWMText` | Watermark Text | PickOne | `Confidential` | `Confidential` (CONFIDENTIAL), `Copy` (COPY), `Copyright` (DRAFT), `Final` (FINAL), `FileCopy` (FILE COPY), `Proof` (PROOF), `TopSecret` (TOP SECRET) | | +| `RIwmFont` | Watermark Font | PickOne | `Default` | `Default` (Printer Default), `HelveticaB` (Helvetica Bold), `CourierB` (Courier Bold), `TimesB` (Times Bold), `NimbusSansB` (NimbusSans Bold), `NimbusMonoPSB` (NimbusMonoPS Bold), `NimbusRomanB` (NimbusRoman Bold) | | +| `RIwmAngle` | Watermark Angle | PickOne | `45Deg` | `180Deg` (180 Degrees), `135Deg` (135 Degrees), `90Deg` (90 Degrees), `45Deg` (45 Degrees), `0Deg` (0 Degrees), `M45Deg` (-45 Degrees), `M90Deg` (-90 Degrees), `M135Deg` (-135 Degrees), `M180Deg` (-180 Degrees) | | +| `RIwmSize` | Watermark Size | PickOne | `36` | `24` (24 Point), `36` (36 Point), `48` (48 Point), `60` (60 Point), `72` (72 Point) | | +| `RIwmTextStyle` | Watermark Style | PickOne | `Gray` | `Gray`, `Outline` (Outlined) | | + +### Job Log + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIUserId` | User ID | PickOne | `None` | `None` | `UserId` string 0–8 | +| `RIJobType` | Job Type | PickOne | `Normal` | `Normal` (Normal Print), `SamplePrint` (Sample Print), `LockedPrint` (Locked Print), `HoldPrint` (Hold Print), `StoredPrint` (Stored Print), `StoreandPrint` (Store and Print), `DocServer` (Document Server) | | +| `RIFileName` | File Name | PickOne | `None` | `None` | `FileName` string 0–16 | +| `RIPassword` | Password | PickOne | `None` | `None` | `Password` passcode 4–8 | +| `RIEnableUserCode` | Enable User Code | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIUserCode` | User Code | PickOne | `None` | `None` | `UserCode` string 0–8 | +| `RIEnableSpecifyTime` | Set Print Time | Boolean | `False` | `False` (Off), `True` (On) | | +| `RITimeHour` | Hour (0 to 23) | PickOne | `0` | `0` | `TimeHour` int 0–23 | +| `RITimeMin` | Minute (0 to 59) | PickOne | `0` | `0` | `TimeMin` int 0–59 | +| `RIFolderNumber` | Folder Number | PickOne | `0` | `0` | `FolderNumber` int 0–200 | +| `RIFolderPassword` | Folder Password | PickOne | `None` | `None` | `FolderPassword` passcode 4–8 | + +### Unauthorized Copy Prevention + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIPreventionType` | Prevention Type | PickOne | `Off` | `Off`, `PreventionCopyPattern` (Unauthorized Copy Prevention for Pattern), `CopyGuard` (Data Security for Copying) | | +| `RITypeText` | Text Type | PickOne | `UserText` | `UserText` (User Text), `LoginUserName` (Login User Name), `JobName` (Job Name), `JobTime` (Date & Time), `LoginUserNameJobName` (Login User Name + Job Name), `LoginUserNameJobTime` (Login User Name + Date & Time), `JobNameTime` (Job Name + Date & Time), `LoginUserNameJobNameTime` (Login User Name + Job Name + Date & Time) | | +| `RIText` | Enter User Text | PickOne | `Copy` | `Copy` (COPY) | `Text` string 0–64 | +| `RIFont` | Font | PickOne | `Default` | `Default` (Printer Default), `NimbusSansBold` (NimbusSans-Bold), `NimbusMonoPSBold` (NimbusMonoPS-Bold), `NimbusRomanBold` (NimbusRoman-Bold), `AlbertusMT`, `AlbertusMTItalic` (AlbertusMT-Italic), `AlbertusMTLight` (AlbertusMT-Light), `AntiqueOliveBold` (AntiqueOlive-Bold), `AntiqueOliveCompact` (AntiqueOlive-Compact), `AntiqueOliveItalic` (AntiqueOlive-Italic), `AntiqueOliveRoman` (AntiqueOlive-Roman), `AppleChancery` (Apple-Chancery), `ArialMT`, `ArialBoldMT` (Arial-BoldMT), `ArialBoldItalicMT` (Arial-BoldItalicMT), `ArialItalicMT` (Arial-ItalicMT), `AvantGardeBook` (AvantGarde-Book), `AvantGardeBookOblique` (AvantGarde-BookOblique), `AvantGardeDemi` (AvantGarde-Demi), `AvantGardeDemiOblique` (AvantGarde-DemiOblique), `Bodoni`, `BodoniBold` (Bodoni-Bold), `BodoniBoldItalic` (Bodoni-BoldItalic), `BodoniItalic` (Bodoni-Italic), `BodoniPoster` (Bodoni-Poster), `BodoniPosterCompressed` (Bodoni-PosterCompressed), `BookmanDemi` (Bookman-Demi), `BookmanDemiItalic` (Bookman-DemiItalic), `BookmanLight` (Bookman-Light), `BookmanLightItalic` (Bookman-LightItalic), `Carta`, `Chicago`, `ClarendonBold` (Clarendon-Bold), `ClarendonLight` (Clarendon-Light), `Clarendon`, `CooperBlackItalic` (CooperBlack-Italic), `CooperBlack`, `CopperplateThirtyThreeBC` (Copperplate-ThirtyThreeBC), `CopperplateThirtyTwoBC` (Copperplate-ThirtyTwoBC), `CoronetRegular` (Coronet-Regular), `CourierBold` (Courier-Bold), `CourierBoldOblique` (Courier-BoldOblique), `CourierOblique` (Courier-Oblique), `Courier`, `EurostileBold` (Eurostile-Bold), `EurostileBoldExtendedTwo` (Eurostile-BoldExtendedTwo), `EurostileExtendedTwo` (Eurostile-ExtendedTwo), `Eurostile`, `Geneva`, `GillSans`, `GillSansBold` (GillSans-Bold), `GillSansBoldCondensed` (GillSans-BoldCondensed), `GillSansBoldItalic` (GillSans-BoldItalic), `GillSansCondensed` (GillSans-Condensed), `GillSansExtraBold` (GillSans-ExtraBold), `GillSansItalic` (GillSans-Italic), `GillSansLight` (GillSans-Light), `GillSansLightItalic` (GillSans-LightItalic), `Goudy`, `GoudyBold` (Goudy-Bold), `GoudyBoldItalic` (Goudy-BoldItalic), `GoudyExtraBold` (Goudy-ExtraBold), `GoudyItalic` (Goudy-Italic), `Helvetica`, `HelveticaBold` (Helvetica-Bold), `HelveticaBoldOblique` (Helvetica-BoldOblique), `HelveticaCondensedBold` (Helvetica-Condensed-Bold), `HelveticaCondensedBoldObl` (Helvetica-Condensed-BoldObl), `HelveticaCondensedOblique` (Helvetica-Condensed-Oblique), `HelveticaCondensed` (Helvetica-Condensed), `HelveticaNarrowBold` (Helvetica-Narrow-Bold), `HelveticaNarrowBoldOblique` (Helvetica-Narrow-BoldOblique), `HelveticaNarrowOblique` (Helvetica-Narrow-Oblique), `HelveticaNarrow` (Helvetica-Narrow), `HelveticaOblique` (Helvetica-Oblique), `HoeflerTextBlack` (HoeflerText-Black), `HoeflerTextBlackItalic` (HoeflerText-BlackItalic), `HoeflerTextItalic` (HoeflerText-Italic), `HoeflerTextOrnaments` (HoeflerText-Ornaments), `HoeflerTextRegular` (HoeflerText-Regular), `JoannaMT`, `JoannaMTBold` (JoannaMT-Bold), `JoannaMTBoldItalic` (JoannaMT-BoldItalic), `JoannaMTItalic` (JoannaMT-Italic), `LetterGothic`, `LetterGothicBold` (LetterGothic-Bold), `LetterGothicBoldSlanted` (LetterGothic-BoldSlanted), `LetterGothicSlanted` (LetterGothic-Slanted), `LubalinGraphBook` (LubalinGraph-Book), `LubalinGraphBookOblique` (LubalinGraph-BookOblique), `LubalinGraphDemi` (LubalinGraph-Demi), `LubalinGraphDemiOblique` (LubalinGraph-DemiOblique), `Marigold`, `MonaLisaRecut` (MonaLisa-Recut), `Monaco`, `NewCenturySchlbkBold` (NewCenturySchlbk-Bold), `NewCenturySchlbkBoldItalic` (NewCenturySchlbk-BoldItalic), `NewCenturySchlbkItalic` (NewCenturySchlbk-Italic), `NewCenturySchlbkRoman` (NewCenturySchlbk-Roman), `NewYork`, `OptimaBold` (Optima-Bold), `OptimaBoldItalic` (Optima-BoldItalic), `OptimaItalic` (Optima-Italic), `Optima`, `Oxford`, `PalatinoBold` (Palatino-Bold), `PalatinoBoldItalic` (Palatino-BoldItalic), `PalatinoItalic` (Palatino-Italic), `PalatinoRoman` (Palatino-Roman), `StempelGaramondBold` (StempelGaramond-Bold), `StempelGaramondBoldItalic` (StempelGaramond-BoldItalic), `StempelGaramondItalic` (StempelGaramond-Italic), `StempelGaramondRoman` (StempelGaramond-Roman), `Symbol`, `Tekton`, `TimesBold` (Times-Bold), `TimesBoldItalic` (Times-BoldItalic), `TimesItalic` (Times-Italic), `TimesRoman` (Times-Roman), `TimesNewRomanPSBoldItalicMT` (TimesNewRomanPS-BoldItalicMT), `TimesNewRomanPSBoldMT` (TimesNewRomanPS-BoldMT), `TimesNewRomanPSItalicMT` (TimesNewRomanPS-ItalicMT), `TimesNewRomanPSMT`, `Univers`, `UniversBold` (Univers-Bold), `UniversBoldExt` (Univers-BoldExt), `UniversBoldExtObl` (Univers-BoldExtObl), `UniversBoldOblique` (Univers-BoldOblique), `UniversCondensed` (Univers-Condensed), `UniversCondensedBold` (Univers-CondensedBold), `UniversCondensedBoldOblique` (Univers-CondensedBoldOblique), `UniversCondensedOblique` (Univers-CondensedOblique), `UniversExtended` (Univers-Extended), `UniversExtendedObl` (Univers-ExtendedObl), `UniversLight` (Univers-Light), `UniversLightOblique` (Univers-LightOblique), `UniversOblique` (Univers-Oblique), `WingdingsRegular` (Wingdings-Regular), `ZapfChanceryMediumItalic` (ZapfChancery-MediumItalic), `ZapfDingbats` | | +| `RISize` | Size | PickOne | `70` | `70` | `Size` int 50–300 | +| `RIAngel` | Angle | PickOne | `30` | `30` | `Angel` int 0–359 | +| `RIEffects` | Text/Pattern Effects | PickOne | `Normal` | `Normal` (Text and Background), `ReversePatterns` (Reverse Patterns (Text/Background)), `BackgroundOnly` (Background Only), `TextOnly` (Text Only) | | +| `RIRepeat` | Repeat | PickOne | `Off` | `Off`, `Repeat` (On), `RepeatandRotateCarriageReturn` (On (Rotate 180 Degrees at Carriage Return)) | | +| `RILineSpace` | Line Space | PickOne | `70` | `70` | `LineSpace` int 50–300 | +| `RIPosition` | Position | PickOne | `Center` | `Center`, `TopLeft` (Top Left), `TopCenter` (Top Center), `TopRight` (Top Right), `BottomLeft` (Bottom Left), `BottomCenter` (Bottom Center), `BottomRight` (Bottom Right) | | +| `RIColor` | Color | PickOne | `Black` | `Black`, `Cyan`, `Magenta` | | +| `RIDensity` | Density | PickOne | `Medium` | `VeryLight` (Very Light), `Light`, `Medium`, `Dark`, `VeryDark` (Very Dark) | | +| `RIMaskType` | Mask Type | PickOne | `None` | `None`, `WaveCrest` (Type 1), `Mesh` (Type 2), `Lattice1` (Type 3), `Lattice2` (Type 4), `InterlockingCircles` (Type 5), `Shokkoh` (Type 6), `Matsukawabishi` (Type 7), `Scale` (Type 8), `Higaki` (Type 9), `Hexagonal` (Type 10) | | + +### Color Balance Details + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIBrightness` | Brightness ( -50 to 50 ) | PickOne | `0` | `0` | `Brightness` real -50–50 | +| `RIContrast` | Contrast ( -50 to 50 ) | PickOne | `0` | `0` | `Contrast` real -50–50 | +| `RIBlack` | Black ( -50 to 50 ) | PickOne | `0` | `0` | `Black` real -50–50 | +| `RICyan` | Cyan ( -50 to 50 ) | PickOne | `0` | `0` | `Cyan` real -50–50 | +| `RIMagenta` | Magenta ( -50 to 50 ) | PickOne | `0` | `0` | `Magenta` real -50–50 | +| `RIYellow` | Yellow ( -50 to 50 ) | PickOne | `0` | `0` | `Yellow` real -50–50 | + +### Background Numbering + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIBackgroundNumbering` | Background Numbering | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIBNSize` | Size | PickOne | `Normal` | `Small`, `Normal`, `Large` | | +| `RIBNDensity` | Density | PickOne | `Normal` | `Light`, `Normal`, `Dark`, `VeryDark` (Very Dark) | | +| `RIBNColor` | Color | PickOne | `Black` | `Yellow`, `Red`, `Cyan`, `Magenta`, `Green`, `Blue`, `Black` | | +| `RIStartNumber` | Start Number (1 to 9999) | PickOne | `1` | `1` | `StartNumber` int 1–9999 | + +### User Authentication + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIEnableUserAuth` | User Authentication | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIAuthLoginUserNameType` | Login User Name | PickOne | `DefinedUserID` | `DefinedUserID` (Defined User ID), `LoginUserName` (Mac Login Name) | | +| `RIAuthLoginUserNameText` | Enter Login User Name | PickOne | `None` | `None` | `AuthLoginUserNameText` string 0–128 | +| `RIAuthLoginPassword` | Login Password | PickOne | `None` | `None` | `AuthLoginPassword` password 0–128 | +| `RIAuthEnableEncryption` | Driver Encryption Key | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIAuthEncryptionKey` | Driver Encryption Key | PickOne | `None` | `None` | `AuthEncryptionKey` password 0–32 | + +## RICOH IM C4500 PS + +### Installable Options + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `OptionTray` | Option Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `1Cassette` (Lower Paper Tray), `LCT` (Tray 3 (LCT)), `2Cassette` (Lower Paper Trays) | | +| `LargeCapacityTray` | Large Capacity Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `InnerTray2` | Internal Tray 2 | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `ShiftTray` | Internal Shift Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `ExternalTray` | External Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `Finisher` | Finisher | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `FinRUBICONC` (Finisher SR3250), `FinVOLGAEBK` (Finisher SR3290), `FinVOLGAE` (Finisher SR3280), `FinAMURCBK` (Finisher SR3270), `FinAMURCHY` (Finisher SR3260) | | +| `MultiFold` | Folding Unit | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `RIPostScript` | PostScript | PickOne | `IRIPS` | `IRIPS` (PostScript Emulation), `Adobe` (Adobe PostScript) | | + +### General + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `PageSize` | PageSize | PickOne | `A4` | `A3` (A3 (297 x 420 mm)), `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B4` (B4 JIS (257 x 364 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGermanLegal` (8.5 x 13), `11x17` (11 x 17), `12x18` (12 x 18), `11x15` (11 x 15), `10x14` (10 x 14), `SRA3` (SRA3 (320 x 450 mm)), `SRA4` (SRA4 (225 x 320 mm)), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `8Kai` (8K (267 x 390 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4), `A3.FullBleed` (A3 (297 x 420 mm) (Full Bleed)), `A4.FullBleed` (A4 (210 x 297 mm) (Full Bleed)), `A5.FullBleed` (A5 (148 x 210 mm) (Full Bleed)), `A6.FullBleed` (A6 (105 x 148 mm) (Full Bleed)), `B4.FullBleed` (B4 (JIS) (257 x 364 mm) (Full Bleed)), `B5.FullBleed` (B5 (JIS) (182 x 257 mm) (Full Bleed)), `B6.FullBleed` (B6 (JIS) (128 x 182 mm) (Full Bleed)), `Legal.FullBleed` (Legal (8.5 x 14) (Full Bleed)), `GovernmentLG.FullBleed` (8.25 x 14 (Full Bleed)), `EngQuatro.FullBleed` (8 x 10 (Full Bleed)), `Letter.FullBleed` (Letter (8.5 x 11) (Full Bleed)), `HalfLetter.FullBleed` (5.5 x 8.5 (Full Bleed)), `F.FullBleed` (8 x 13 (Full Bleed)), `Folio.FullBleed` (8.25 x 13 (Full Bleed)), `FanFoldGermanLegal.FullBleed` (8.5 x 13 (Full Bleed)), `11x17.FullBleed` (11 x 17 (Full Bleed)), `12x18.FullBleed` (12 x 18 (Full Bleed)), `11x15.FullBleed` (11 x 15 (Full Bleed)), `10x14.FullBleed` (10 x 14 (Full Bleed)), `SRA3.FullBleed` (SRA3 (320 x 450 mm) (Full Bleed)), `SRA4.FullBleed` (SRA4 (225 x 320 mm) (Full Bleed)), `Executive.FullBleed` (Executive (7.25 x 10.5) (Full Bleed)), `Env10.FullBleed` (Com10 Env. (4.125 x 9.5) (Full Bleed)), `EnvMonarch.FullBleed` (Monarch Env. (3.875 x 7.5) (Full Bleed)), `EnvC5.FullBleed` (C5 Env. (162 x 229 mm) (Full Bleed)), `EnvC6.FullBleed` (C6 Env. (114 x 162 mm) (Full Bleed)), `DLEnv.FullBleed` (DL Env. (110 x 220 mm) (Full Bleed)), `8Kai.FullBleed` (8K (267 x 390 mm) (Full Bleed)), `16Kai.FullBleed` (16K (195 x 267 mm) (Full Bleed)), `Oficio.FullBleed` (8.5 x 13.4 (Full Bleed)) | `Width` points 255–908; `Height` points 419–3572; `WidthOffset` points 0–0; `HeightOffset` points 0–0; `Orientation` int 1–1 | +| `PageRegion` | PageRegion | PickOne | `A4` | `A3` (A3 (297 x 420 mm)), `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B4` (B4 JIS (257 x 364 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGermanLegal` (8.5 x 13), `11x17` (11 x 17), `12x18` (12 x 18), `11x15` (11 x 15), `10x14` (10 x 14), `SRA3` (SRA3 (320 x 450 mm)), `SRA4` (SRA4 (225 x 320 mm)), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `8Kai` (8K (267 x 390 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4), `A3.FullBleed` (A3 (297 x 420 mm) (Full Bleed)), `A4.FullBleed` (A4 (210 x 297 mm) (Full Bleed)), `A5.FullBleed` (A5 (148 x 210 mm) (Full Bleed)), `A6.FullBleed` (A6 (105 x 148 mm) (Full Bleed)), `B4.FullBleed` (B4 (JIS) (257 x 364 mm) (Full Bleed)), `B5.FullBleed` (B5 (JIS) (182 x 257 mm) (Full Bleed)), `B6.FullBleed` (B6 (JIS) (128 x 182 mm) (Full Bleed)), `Legal.FullBleed` (Legal (8.5 x 14) (Full Bleed)), `GovernmentLG.FullBleed` (8.25 x 14 (Full Bleed)), `EngQuatro.FullBleed` (8 x 10 (Full Bleed)), `Letter.FullBleed` (Letter (8.5 x 11) (Full Bleed)), `HalfLetter.FullBleed` (5.5 x 8.5 (Full Bleed)), `F.FullBleed` (8 x 13 (Full Bleed)), `Folio.FullBleed` (8.25 x 13 (Full Bleed)), `FanFoldGermanLegal.FullBleed` (8.5 x 13 (Full Bleed)), `11x17.FullBleed` (11 x 17 (Full Bleed)), `12x18.FullBleed` (12 x 18 (Full Bleed)), `11x15.FullBleed` (11 x 15 (Full Bleed)), `10x14.FullBleed` (10 x 14 (Full Bleed)), `SRA3.FullBleed` (SRA3 (320 x 450 mm) (Full Bleed)), `SRA4.FullBleed` (SRA4 (225 x 320 mm) (Full Bleed)), `Executive.FullBleed` (Executive (7.25 x 10.5) (Full Bleed)), `Env10.FullBleed` (Com10 Env. (4.125 x 9.5) (Full Bleed)), `EnvMonarch.FullBleed` (Monarch Env. (3.875 x 7.5) (Full Bleed)), `EnvC5.FullBleed` (C5 Env. (162 x 229 mm) (Full Bleed)), `EnvC6.FullBleed` (C6 Env. (114 x 162 mm) (Full Bleed)), `DLEnv.FullBleed` (DL Env. (110 x 220 mm) (Full Bleed)), `8Kai.FullBleed` (8K (267 x 390 mm) (Full Bleed)), `16Kai.FullBleed` (16K (195 x 267 mm) (Full Bleed)), `Oficio.FullBleed` (8.5 x 13.4 (Full Bleed)) | | +| `InputSlot` | InputSlot | PickOne | `1Tray` | `MultiTray` (Bypass Tray), `1Tray` (Tray 1), `2Tray` (Tray 2), `3Tray` (Tray 3), `4Tray` (Tray 4), `5Tray` (Large Capacity Tray) | | +| `Duplex` | Duplex | PickOne | `DuplexNoTumble` | `None` (Off), `DuplexNoTumble` (Long Edge), `DuplexTumble` (Short Edge) | | +| `Collate` | Collate | PickOne | `False` | `False` (Off), `True` (On) | | + +### Basic + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIPaperPolicy` | Fit to Paper | PickOne | `PromptUser` | `PromptUser` (Prompt User), `NearestSizeAdjust` (Nearest Size and Scale), `NearestSizeNoAdjust` (Nearest Size and Crop) | | +| `ColorModel` | Color Mode | PickOne | `CMYK` | `CMYK` (Color), `Gray` (Black and White) | | +| `RIRotateBy180` | Rotate by 180 degrees | PickOne | `Off` | `Off`, `On` | | + +### Paper + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `MediaType` | Paper Type | PickOne | `Auto` | `Auto` (Plain/Recycled), `Plain1` (Plain 1 (60 - 74 g/m2)), `Plain2` (Plain 2 (75 - 81 g/m2)), `Recycled`, `Special1` (Special 1), `Special2` (Special 2), `Special3` (Special 3), `Colored` (Color), `Letterhead`, `Preprinted`, `Labels`, `Bond`, `Cardstock`, `OHP` (Transparency), `Thick1` (Thick 1 (106 - 169 g/m2)), `Thick2` (Thick 2 (170 - 220 g/m2)), `Thick3` (Thick 3 (221 - 256 g/m2)), `Thick4` (Thick 4 (257 - 300 g/m2)), `Thin` (Thin (52 - 59 g/m2)), `Middlethick` (Middle Thick (82 - 105 g/m2)), `GlossCoated` (Coated (Glossy)), `MatCoated` (Coated (Matte)), `Envelope`, `None` | | +| `OutputBin` | Destination | PickOne | `Default` | `Default` (Printer Default), `Standard` (Internal Tray 1), `Bin1` (Internal Tray 2), `Shift` (Internal Shift Tray), `External` (External Tray), `FinRUBICONCShift` (Finisher SR3250 Shift Tray), `FinVOLGAEBKUpper` (Finisher SR3290 Upper Tray), `FinVOLGAEBKShift` (Finisher SR3290 Shift Tray), `FinVOLGAEBKBKLower` (Finisher SR3290 Booklet Tray), `FinVOLGAEUpper` (Finisher SR3280 Upper Tray), `FinVOLGAEShift` (Finisher SR3280 Shift Tray), `FinAMURCBKUpper` (Finisher SR3270 Upper Tray), `FinAMURCBKShift` (Finisher SR3270 Shift Tray), `FinAMURCBKBKLower` (Finisher SR3270 Booklet Tray), `FinAMURCUpper` (Finisher SR3260 Upper Tray), `FinAMURCShift` (Finisher SR3260 Shift Tray), `FinDONAUProof` (Folding Unit Tray) | | +| `RIBannerPagePrint` | Banner Page | PickOne | `False` | `False` (Off), `True` (On) | | +| `RIBannerPageInputSlot` | Banner Page Input Tray | PickOne | `Auto` | `Auto` (Auto Tray Select), `MultiTray` (Bypass Tray), `1Tray` (Tray 1), `2Tray` (Tray 2), `3Tray` (Tray 3), `4Tray` (Tray 4), `5Tray` (Large Capacity Tray) | | +| `RIBannerPageMediaType` | Banner Page Paper Type | PickOne | `Auto` | `Auto` (Plain/Recycled), `Plain1` (Plain 1 (60 - 74 g/m2)), `Plain2` (Plain 2 (75 - 81 g/m2)), `Recycled`, `Special1` (Special 1), `Special2` (Special 2), `Special3` (Special 3), `Colored` (Color), `Letterhead`, `Preprinted`, `Labels`, `Bond`, `Cardstock`, `OHP` (Transparency), `Thick1` (Thick 1 (106 - 169 g/m2)), `Thick2` (Thick 2 (170 - 220 g/m2)), `Thick3` (Thick 3 (221 - 256 g/m2)), `Thick4` (Thick 4 (257 - 300 g/m2)), `Thin` (Thin (52 - 59 g/m2)), `Middlethick` (Middle Thick (82 - 105 g/m2)), `GlossCoated` (Coated (Glossy)), `MatCoated` (Coated (Matte)), `Envelope` | | + +### Finishing + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIOrientOvr` | Orientation Override | PickOne | `Off` | `Off`, `Landscape`, `Portrait` | | +| `RICollateKind` | Collate Type | PickOne | `Normal` | `Normal` (Collate), `RotateCollate` (Rotating Collate), `ShiftCollate` (Shift Collate) | | +| `StapleLocation` | Staple | PickOne | `None` | `None` (Off), `StaplessUpperLeft` (Top left (stapleless)), `StaplessUpperRight` (Top right (stapleless)), `UpperLeft` (Top left), `UpperRight` (Top right), `LeftW` (2 at left), `RightW` (2 at right), `UpperW` (2 at top), `CenterW` (2 at center) | | +| `RIPunch` | Punch | PickOne | `None` | `None` (Off), `Left2` (2 at left), `Left3` (3 at left), `Left4` (4 at left), `Right2` (2 at right), `Right3` (3 at right), `Right4` (4 at right), `Upper2` (2 at top), `Upper3` (3 at top), `Upper4` (4 at top) | | +| `RIZfold` | Z-fold | PickOne | `None` | `None` (Off), `Bottom` (Bottom Fold), `Right` (Right Fold), `Left` (Left Fold) | | +| `RIFoldType` | Fold Type | PickOne | `None` | `None` (Off), `Twofold` (Half Fold), `Threefold` (Letter Fold-in), `ThreefoldOut` (Letter Fold-out), `OutsideTwofold` (Half Fold - Print Outside (Finisher Booklet Tray)) | | +| `OverlapFold` | Multi-sheet Fold | PickOne | `Off` | `Off`, `On` | | +| `Booklet` | Booklet | PickOne | `None` | `None` (Off), `OpenToLeft` (Open to Left/Top), `OpenToRight` (Open to Right/Bottom), `OpenToLeftNoneRed` (Open to Left/Top (Full Size)), `OpenToRightNoneRed` (Open to Right/Bottom (Full Size)) | | +| `RIBookletPageSize` | Booklet - Paper Size | PickOne | `Default` | `Default` (Printer Default), `A3` (A3 (297 x 420 mm)), `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B4` (B4 JIS (257 x 364 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGermanLegal` (8.5 x 13), `11x17` (11 x 17), `12x18` (12 x 18), `11x15` (11 x 15), `10x14` (10 x 14), `SRA3` (SRA3 (320 x 450 mm)), `SRA4` (SRA4 (225 x 320 mm)), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `8Kai` (8K (267 x 390 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4) | | + +### Print Quality + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `Resolution` | Resolution | PickOne | `600dpi` | `600dpi` (600 dpi), `1200dpi` (1200 dpi) | | +| `RPSBitsPerPixel` | Gradation | PickOne | `2BitsPerPixel` | `2BitsPerPixel` (Standard), `1BitsPerPixel` (Fast), `4BitsPerPixel` (Fine) | | +| `RIPrintMode` | Print Mode | PickOne | `0rhit` | `0rhit` (Off), `3rhit` (Toner Saving) | | +| `Rimagesm` | Image Smoothing | PickOne | `Off` | `Off`, `On`, `Auto`, `90ppi` (Less than 90 ppi), `150ppi` (Less than 150 ppi), `200ppi` (Less than 200 ppi), `300ppi` (Less than 300 ppi) | | +| `RPSDitherType` | Dithering | PickOne | `Auto` | `Auto`, `Photo` (Photographic), `Letter` (Text), `User` (User Setting), `Dispersion` (Reduce Missing Colors and Blurring) | | +| `RPSRGBcorrect` | Color Setting | PickOne | `DetailBright` | `None` (Off), `DetailNormal` (Fine), `DetailBright` (Super Fine) | | +| `RPSColorRendDict` | Color Profile | PickOne | `Auto` | `Auto`, `AutoBright` (Auto (Brighter)), `AutoDeep` (Auto (Darker)), `Photograph` (Photographic), `PhotographBright` (Photographic (Brighter)), `PhotographDeep` (Photographic (Darker)), `Business` (Presentation), `BusinessBright` (Presentation (Brighter)), `BusinessDeep` (Presentation (Darker)), `Colorimetric` (Solid Color), `ColorimetricBright` (Solid Color (Brighter)), `ColorimetricDeep` (Solid Color (Darker)), `POP` (POP Display), `User` (User Setting), `Clpsimulation1` (Soft), `Clpsimulation2` (Sharp), `Clpsimulation4` (Vivid), `Clpsimulation7` (Deep), `Clpsimulation` (CLP Simulation) | | +| `Rcmyksimulation` | CMYK Simulation Profile | PickOne | `Off` | `Off`, `USOffsetPrint` (US OffsetPrint), `Euroscale`, `JapanColor` (JapanColor2001), `PANTONE` (PaletteColor), `Trans01` (Make Bluer) | | +| `RPSBlackMode` | Gray Reproduction | PickOne | `gray` | `gray` (Black/Gray by K (Text/Line Art)), `1Color` (Black by K), `4Color` (CMY+K), `grayText` (Black/Gray by K (Text only)), `1ColorText` (Black by K (Text only)), `grayAll` (Black/Gray by K (Strong UCR)) | | +| `RPSBlackOverPrint` | Black Over Print | PickOne | `False` | `False` (Off), `True` (On) | | +| `RPSColorSep` | Separate into CMYK | PickOne | `None` | `None` (Do not Separate), `Cyan`, `Magenta`, `Yellow`, `Black`, `Red` (Magenta and Yellow), `Green` (Cyan and Yellow), `Blue` (Cyan and Magenta), `KCyan` (Black and Cyan), `KMagenta` (Black and Magenta), `KYellow` (Black and Yellow) | | + +### Effects + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIWatermark` | Watermark | PickOne | `Off` | `Off`, `On` | | +| `RIWMText` | Watermark Text | PickOne | `Confidential` | `Confidential` (CONFIDENTIAL), `Copy` (COPY), `Copyright` (DRAFT), `Final` (FINAL), `FileCopy` (FILE COPY), `Proof` (PROOF), `TopSecret` (TOP SECRET) | | +| `RIwmFont` | Watermark Font | PickOne | `Default` | `Default` (Printer Default), `HelveticaB` (Helvetica Bold), `CourierB` (Courier Bold), `TimesB` (Times Bold), `NimbusSansB` (NimbusSans Bold), `NimbusMonoPSB` (NimbusMonoPS Bold), `NimbusRomanB` (NimbusRoman Bold) | | +| `RIwmAngle` | Watermark Angle | PickOne | `45Deg` | `180Deg` (180 Degrees), `135Deg` (135 Degrees), `90Deg` (90 Degrees), `45Deg` (45 Degrees), `0Deg` (0 Degrees), `M45Deg` (-45 Degrees), `M90Deg` (-90 Degrees), `M135Deg` (-135 Degrees), `M180Deg` (-180 Degrees) | | +| `RIwmSize` | Watermark Size | PickOne | `36` | `24` (24 Point), `36` (36 Point), `48` (48 Point), `60` (60 Point), `72` (72 Point) | | +| `RIwmTextStyle` | Watermark Style | PickOne | `Gray` | `Gray`, `Outline` (Outlined) | | + +### Job Log + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIUserId` | User ID | PickOne | `None` | `None` | `UserId` string 0–8 | +| `RIJobType` | Job Type | PickOne | `Normal` | `Normal` (Normal Print), `SamplePrint` (Sample Print), `LockedPrint` (Locked Print), `HoldPrint` (Hold Print), `StoredPrint` (Stored Print), `StoreandPrint` (Store and Print), `DocServer` (Document Server) | | +| `RIFileName` | File Name | PickOne | `None` | `None` | `FileName` string 0–16 | +| `RIPassword` | Password | PickOne | `None` | `None` | `Password` passcode 4–8 | +| `RIEnableUserCode` | Enable User Code | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIUserCode` | User Code | PickOne | `None` | `None` | `UserCode` string 0–8 | +| `RIEnableSpecifyTime` | Set Print Time | Boolean | `False` | `False` (Off), `True` (On) | | +| `RITimeHour` | Hour (0 to 23) | PickOne | `0` | `0` | `TimeHour` int 0–23 | +| `RITimeMin` | Minute (0 to 59) | PickOne | `0` | `0` | `TimeMin` int 0–59 | +| `RIFolderNumber` | Folder Number | PickOne | `0` | `0` | `FolderNumber` int 0–200 | +| `RIFolderPassword` | Folder Password | PickOne | `None` | `None` | `FolderPassword` passcode 4–8 | + +### Unauthorized Copy Prevention + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIPreventionType` | Prevention Type | PickOne | `Off` | `Off`, `PreventionCopyPattern` (Unauthorized Copy Prevention for Pattern), `CopyGuard` (Data Security for Copying) | | +| `RITypeText` | Text Type | PickOne | `UserText` | `UserText` (User Text), `LoginUserName` (Login User Name), `JobName` (Job Name), `JobTime` (Date & Time), `LoginUserNameJobName` (Login User Name + Job Name), `LoginUserNameJobTime` (Login User Name + Date & Time), `JobNameTime` (Job Name + Date & Time), `LoginUserNameJobNameTime` (Login User Name + Job Name + Date & Time) | | +| `RIText` | Enter User Text | PickOne | `Copy` | `Copy` (COPY) | `Text` string 0–64 | +| `RIFont` | Font | PickOne | `Default` | `Default` (Printer Default), `NimbusSansBold` (NimbusSans-Bold), `NimbusMonoPSBold` (NimbusMonoPS-Bold), `NimbusRomanBold` (NimbusRoman-Bold), `AlbertusMT`, `AlbertusMTItalic` (AlbertusMT-Italic), `AlbertusMTLight` (AlbertusMT-Light), `AntiqueOliveBold` (AntiqueOlive-Bold), `AntiqueOliveCompact` (AntiqueOlive-Compact), `AntiqueOliveItalic` (AntiqueOlive-Italic), `AntiqueOliveRoman` (AntiqueOlive-Roman), `AppleChancery` (Apple-Chancery), `ArialMT`, `ArialBoldMT` (Arial-BoldMT), `ArialBoldItalicMT` (Arial-BoldItalicMT), `ArialItalicMT` (Arial-ItalicMT), `AvantGardeBook` (AvantGarde-Book), `AvantGardeBookOblique` (AvantGarde-BookOblique), `AvantGardeDemi` (AvantGarde-Demi), `AvantGardeDemiOblique` (AvantGarde-DemiOblique), `Bodoni`, `BodoniBold` (Bodoni-Bold), `BodoniBoldItalic` (Bodoni-BoldItalic), `BodoniItalic` (Bodoni-Italic), `BodoniPoster` (Bodoni-Poster), `BodoniPosterCompressed` (Bodoni-PosterCompressed), `BookmanDemi` (Bookman-Demi), `BookmanDemiItalic` (Bookman-DemiItalic), `BookmanLight` (Bookman-Light), `BookmanLightItalic` (Bookman-LightItalic), `Carta`, `Chicago`, `ClarendonBold` (Clarendon-Bold), `ClarendonLight` (Clarendon-Light), `Clarendon`, `CooperBlackItalic` (CooperBlack-Italic), `CooperBlack`, `CopperplateThirtyThreeBC` (Copperplate-ThirtyThreeBC), `CopperplateThirtyTwoBC` (Copperplate-ThirtyTwoBC), `CoronetRegular` (Coronet-Regular), `CourierBold` (Courier-Bold), `CourierBoldOblique` (Courier-BoldOblique), `CourierOblique` (Courier-Oblique), `Courier`, `EurostileBold` (Eurostile-Bold), `EurostileBoldExtendedTwo` (Eurostile-BoldExtendedTwo), `EurostileExtendedTwo` (Eurostile-ExtendedTwo), `Eurostile`, `Geneva`, `GillSans`, `GillSansBold` (GillSans-Bold), `GillSansBoldCondensed` (GillSans-BoldCondensed), `GillSansBoldItalic` (GillSans-BoldItalic), `GillSansCondensed` (GillSans-Condensed), `GillSansExtraBold` (GillSans-ExtraBold), `GillSansItalic` (GillSans-Italic), `GillSansLight` (GillSans-Light), `GillSansLightItalic` (GillSans-LightItalic), `Goudy`, `GoudyBold` (Goudy-Bold), `GoudyBoldItalic` (Goudy-BoldItalic), `GoudyExtraBold` (Goudy-ExtraBold), `GoudyItalic` (Goudy-Italic), `Helvetica`, `HelveticaBold` (Helvetica-Bold), `HelveticaBoldOblique` (Helvetica-BoldOblique), `HelveticaCondensedBold` (Helvetica-Condensed-Bold), `HelveticaCondensedBoldObl` (Helvetica-Condensed-BoldObl), `HelveticaCondensedOblique` (Helvetica-Condensed-Oblique), `HelveticaCondensed` (Helvetica-Condensed), `HelveticaNarrowBold` (Helvetica-Narrow-Bold), `HelveticaNarrowBoldOblique` (Helvetica-Narrow-BoldOblique), `HelveticaNarrowOblique` (Helvetica-Narrow-Oblique), `HelveticaNarrow` (Helvetica-Narrow), `HelveticaOblique` (Helvetica-Oblique), `HoeflerTextBlack` (HoeflerText-Black), `HoeflerTextBlackItalic` (HoeflerText-BlackItalic), `HoeflerTextItalic` (HoeflerText-Italic), `HoeflerTextOrnaments` (HoeflerText-Ornaments), `HoeflerTextRegular` (HoeflerText-Regular), `JoannaMT`, `JoannaMTBold` (JoannaMT-Bold), `JoannaMTBoldItalic` (JoannaMT-BoldItalic), `JoannaMTItalic` (JoannaMT-Italic), `LetterGothic`, `LetterGothicBold` (LetterGothic-Bold), `LetterGothicBoldSlanted` (LetterGothic-BoldSlanted), `LetterGothicSlanted` (LetterGothic-Slanted), `LubalinGraphBook` (LubalinGraph-Book), `LubalinGraphBookOblique` (LubalinGraph-BookOblique), `LubalinGraphDemi` (LubalinGraph-Demi), `LubalinGraphDemiOblique` (LubalinGraph-DemiOblique), `Marigold`, `MonaLisaRecut` (MonaLisa-Recut), `Monaco`, `NewCenturySchlbkBold` (NewCenturySchlbk-Bold), `NewCenturySchlbkBoldItalic` (NewCenturySchlbk-BoldItalic), `NewCenturySchlbkItalic` (NewCenturySchlbk-Italic), `NewCenturySchlbkRoman` (NewCenturySchlbk-Roman), `NewYork`, `OptimaBold` (Optima-Bold), `OptimaBoldItalic` (Optima-BoldItalic), `OptimaItalic` (Optima-Italic), `Optima`, `Oxford`, `PalatinoBold` (Palatino-Bold), `PalatinoBoldItalic` (Palatino-BoldItalic), `PalatinoItalic` (Palatino-Italic), `PalatinoRoman` (Palatino-Roman), `StempelGaramondBold` (StempelGaramond-Bold), `StempelGaramondBoldItalic` (StempelGaramond-BoldItalic), `StempelGaramondItalic` (StempelGaramond-Italic), `StempelGaramondRoman` (StempelGaramond-Roman), `Symbol`, `Tekton`, `TimesBold` (Times-Bold), `TimesBoldItalic` (Times-BoldItalic), `TimesItalic` (Times-Italic), `TimesRoman` (Times-Roman), `TimesNewRomanPSBoldItalicMT` (TimesNewRomanPS-BoldItalicMT), `TimesNewRomanPSBoldMT` (TimesNewRomanPS-BoldMT), `TimesNewRomanPSItalicMT` (TimesNewRomanPS-ItalicMT), `TimesNewRomanPSMT`, `Univers`, `UniversBold` (Univers-Bold), `UniversBoldExt` (Univers-BoldExt), `UniversBoldExtObl` (Univers-BoldExtObl), `UniversBoldOblique` (Univers-BoldOblique), `UniversCondensed` (Univers-Condensed), `UniversCondensedBold` (Univers-CondensedBold), `UniversCondensedBoldOblique` (Univers-CondensedBoldOblique), `UniversCondensedOblique` (Univers-CondensedOblique), `UniversExtended` (Univers-Extended), `UniversExtendedObl` (Univers-ExtendedObl), `UniversLight` (Univers-Light), `UniversLightOblique` (Univers-LightOblique), `UniversOblique` (Univers-Oblique), `WingdingsRegular` (Wingdings-Regular), `ZapfChanceryMediumItalic` (ZapfChancery-MediumItalic), `ZapfDingbats` | | +| `RISize` | Size | PickOne | `70` | `70` | `Size` int 50–300 | +| `RIAngel` | Angle | PickOne | `30` | `30` | `Angel` int 0–359 | +| `RIEffects` | Text/Pattern Effects | PickOne | `Normal` | `Normal` (Text and Background), `ReversePatterns` (Reverse Patterns (Text/Background)), `BackgroundOnly` (Background Only), `TextOnly` (Text Only) | | +| `RIRepeat` | Repeat | PickOne | `Off` | `Off`, `Repeat` (On), `RepeatandRotateCarriageReturn` (On (Rotate 180 Degrees at Carriage Return)) | | +| `RILineSpace` | Line Space | PickOne | `70` | `70` | `LineSpace` int 50–300 | +| `RIPosition` | Position | PickOne | `Center` | `Center`, `TopLeft` (Top Left), `TopCenter` (Top Center), `TopRight` (Top Right), `BottomLeft` (Bottom Left), `BottomCenter` (Bottom Center), `BottomRight` (Bottom Right) | | +| `RIColor` | Color | PickOne | `Black` | `Black`, `Cyan`, `Magenta` | | +| `RIDensity` | Density | PickOne | `Medium` | `VeryLight` (Very Light), `Light`, `Medium`, `Dark`, `VeryDark` (Very Dark) | | +| `RIMaskType` | Mask Type | PickOne | `None` | `None`, `WaveCrest` (Type 1), `Mesh` (Type 2), `Lattice1` (Type 3), `Lattice2` (Type 4), `InterlockingCircles` (Type 5), `Shokkoh` (Type 6), `Matsukawabishi` (Type 7), `Scale` (Type 8), `Higaki` (Type 9), `Hexagonal` (Type 10) | | + +### Color Balance Details + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIBrightness` | Brightness ( -50 to 50 ) | PickOne | `0` | `0` | `Brightness` real -50–50 | +| `RIContrast` | Contrast ( -50 to 50 ) | PickOne | `0` | `0` | `Contrast` real -50–50 | +| `RIBlack` | Black ( -50 to 50 ) | PickOne | `0` | `0` | `Black` real -50–50 | +| `RICyan` | Cyan ( -50 to 50 ) | PickOne | `0` | `0` | `Cyan` real -50–50 | +| `RIMagenta` | Magenta ( -50 to 50 ) | PickOne | `0` | `0` | `Magenta` real -50–50 | +| `RIYellow` | Yellow ( -50 to 50 ) | PickOne | `0` | `0` | `Yellow` real -50–50 | + +### Background Numbering + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIBackgroundNumbering` | Background Numbering | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIBNSize` | Size | PickOne | `Normal` | `Small`, `Normal`, `Large` | | +| `RIBNDensity` | Density | PickOne | `Normal` | `Light`, `Normal`, `Dark`, `VeryDark` (Very Dark) | | +| `RIBNColor` | Color | PickOne | `Black` | `Yellow`, `Red`, `Cyan`, `Magenta`, `Green`, `Blue`, `Black` | | +| `RIStartNumber` | Start Number (1 to 9999) | PickOne | `1` | `1` | `StartNumber` int 1–9999 | + +### User Authentication + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIEnableUserAuth` | User Authentication | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIAuthLoginUserNameType` | Login User Name | PickOne | `DefinedUserID` | `DefinedUserID` (Defined User ID), `LoginUserName` (Mac Login Name) | | +| `RIAuthLoginUserNameText` | Enter Login User Name | PickOne | `None` | `None` | `AuthLoginUserNameText` string 0–128 | +| `RIAuthLoginPassword` | Login Password | PickOne | `None` | `None` | `AuthLoginPassword` password 0–128 | +| `RIAuthEnableEncryption` | Driver Encryption Key | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIAuthEncryptionKey` | Driver Encryption Key | PickOne | `None` | `None` | `AuthEncryptionKey` password 0–32 | + +## RICOH MP C2004ex PS + +### Installable Options + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `OptionTray` | Option Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `1Cassette` (Lower Paper Tray), `2Cassette` (Lower Paper Trays) | | +| `InnerTray2` | Internal Tray 2 | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `ShiftTray` | Internal Shift Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `ExternalTray` | External Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `Finisher` | Finisher | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `FinRUBICONB` (Finisher SR3130), `FinAMURBBK` (Finisher SR3220), `FinUYUNI` (Finisher SR3180) | | +| `RIPostScript` | PostScript | PickOne | `IRIPS` | `IRIPS` (PostScript Emulation), `Adobe` (Adobe PostScript) | | + +### General + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `PageSize` | PageSize | PickOne | `A4` | `A3` (A3 (297 x 420 mm)), `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B4` (B4 JIS (257 x 364 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGermanLegal` (8.5 x 13), `11x17` (11 x 17), `12x18` (12 x 18), `11x15` (11 x 15), `10x14` (10 x 14), `SRA3` (SRA3 (320 x 450 mm)), `SRA4` (SRA4 (225 x 320 mm)), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `8Kai` (8K (267 x 390 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4), `A3.FullBleed` (A3 (297 x 420 mm) (Full bleed)), `A4.FullBleed` (A4 (210 x 297 mm) (Full Bleed)), `A5.FullBleed` (A5 (148 x 210 mm) (Full Bleed)), `A6.FullBleed` (A6 (105 x 148 mm) (Full Bleed)), `B4.FullBleed` (B4 (JIS) (257 x 364 mm) (Full Bleed)), `B5.FullBleed` (B5 (JIS) (182 x 257 mm) (Full Bleed)), `B6.FullBleed` (B6 (JIS) (128 x 182 mm) (Full Bleed)), `Legal.FullBleed` (Legal (8.5 x 14) (Full Bleed)), `GovernmentLG.FullBleed` (8.25 x 14 (Full Bleed)), `EngQuatro.FullBleed` (8 x 10 (Full Bleed)), `Letter.FullBleed` (Letter (8.5 x 11) (Full Bleed)), `HalfLetter.FullBleed` (5.5 x 8.5 (Full Bleed)), `F.FullBleed` (8 x 13 (Full Bleed)), `Folio.FullBleed` (8.25 x 13 (Full Bleed)), `FanFoldGermanLegal.FullBleed` (8.5 x 13 (Full Bleed)), `11x17.FullBleed` (11 x 17 (Full Bleed)), `12x18.FullBleed` (12 x 18 (Full Bleed)), `11x15.FullBleed` (11 x 15 (Full Bleed)), `10x14.FullBleed` (10 x 14 (Full Bleed)), `SRA3.FullBleed` (SRA3 (320 x 450 mm) (Full Bleed)), `SRA4.FullBleed` (SRA4 (225 x 320 mm) (Full Bleed)), `Executive.FullBleed` (Executive (7.25 x 10.5) (Full Bleed)), `Env10.FullBleed` (Com10 Env. (4.125 x 9.5) (Full Bleed)), `EnvMonarch.FullBleed` (Monarch Env. (3.875 x 7.5) (Full Bleed)), `EnvC5.FullBleed` (C5 Env. (162 x 229 mm) (Full Bleed)), `EnvC6.FullBleed` (C6 Env. (114 x 162 mm) (Full Bleed)), `DLEnv.FullBleed` (DL Env. (110 x 220 mm) (Full Bleed)), `8Kai.FullBleed` (8K (267 x 390 mm) (Full Bleed)), `16Kai.FullBleed` (16K (195 x 267 mm) (Full Bleed)), `Oficio.FullBleed` (8.5 x 13.4 (Full Bleed)) | `Width` points 255–908; `Height` points 419–3572; `WidthOffset` points 0–0; `HeightOffset` points 0–0; `Orientation` int 1–1 | +| `PageRegion` | PageRegion | PickOne | `A4` | `A3` (A3 (297 x 420 mm)), `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B4` (B4 JIS (257 x 364 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGermanLegal` (8.5 x 13), `11x17` (11 x 17), `12x18` (12 x 18), `11x15` (11 x 15), `10x14` (10 x 14), `SRA3` (SRA3 (320 x 450 mm)), `SRA4` (SRA4 (225 x 320 mm)), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `8Kai` (8K (267 x 390 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4), `A3.FullBleed` (A3 (297 x 420 mm) (Full bleed)), `A4.FullBleed` (A4 (210 x 297 mm) (Full Bleed)), `A5.FullBleed` (A5 (148 x 210 mm) (Full Bleed)), `A6.FullBleed` (A6 (105 x 148 mm) (Full Bleed)), `B4.FullBleed` (B4 (JIS) (257 x 364 mm) (Full Bleed)), `B5.FullBleed` (B5 (JIS) (182 x 257 mm) (Full Bleed)), `B6.FullBleed` (B6 (JIS) (128 x 182 mm) (Full Bleed)), `Legal.FullBleed` (Legal (8.5 x 14) (Full Bleed)), `GovernmentLG.FullBleed` (8.25 x 14 (Full Bleed)), `EngQuatro.FullBleed` (8 x 10 (Full Bleed)), `Letter.FullBleed` (Letter (8.5 x 11) (Full Bleed)), `HalfLetter.FullBleed` (5.5 x 8.5 (Full Bleed)), `F.FullBleed` (8 x 13 (Full Bleed)), `Folio.FullBleed` (8.25 x 13 (Full Bleed)), `FanFoldGermanLegal.FullBleed` (8.5 x 13 (Full Bleed)), `11x17.FullBleed` (11 x 17 (Full Bleed)), `12x18.FullBleed` (12 x 18 (Full Bleed)), `11x15.FullBleed` (11 x 15 (Full Bleed)), `10x14.FullBleed` (10 x 14 (Full Bleed)), `SRA3.FullBleed` (SRA3 (320 x 450 mm) (Full Bleed)), `SRA4.FullBleed` (SRA4 (225 x 320 mm) (Full Bleed)), `Executive.FullBleed` (Executive (7.25 x 10.5) (Full Bleed)), `Env10.FullBleed` (Com10 Env. (4.125 x 9.5) (Full Bleed)), `EnvMonarch.FullBleed` (Monarch Env. (3.875 x 7.5) (Full Bleed)), `EnvC5.FullBleed` (C5 Env. (162 x 229 mm) (Full Bleed)), `EnvC6.FullBleed` (C6 Env. (114 x 162 mm) (Full Bleed)), `DLEnv.FullBleed` (DL Env. (110 x 220 mm) (Full Bleed)), `8Kai.FullBleed` (8K (267 x 390 mm) (Full Bleed)), `16Kai.FullBleed` (16K (195 x 267 mm) (Full Bleed)), `Oficio.FullBleed` (8.5 x 13.4 (Full Bleed)) | | +| `InputSlot` | InputSlot | PickOne | `1Tray` | `MultiTray` (Bypass Tray), `1Tray` (Tray 1), `2Tray` (Tray 2), `3Tray` (Tray 3), `4Tray` (Tray 4) | | +| `Duplex` | Duplex | PickOne | `DuplexNoTumble` | `None` (Off), `DuplexNoTumble` (Long Edge), `DuplexTumble` (Short Edge) | | +| `Collate` | Collate | PickOne | `False` | `False` (Off), `True` (On) | | + +### Basic + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIPaperPolicy` | Fit to Paper | PickOne | `PromptUser` | `PromptUser` (Prompt User), `NearestSizeAdjust` (Nearest Size and Scale), `NearestSizeNoAdjust` (Nearest Size and Crop) | | +| `ColorModel` | Color Mode | PickOne | `CMYK` | `CMYK` (Color), `Gray` (Black and White) | | +| `RIRotateBy180` | Rotate by 180 degrees | PickOne | `Off` | `Off`, `On` | | + +### Paper + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `MediaType` | Paper Type | PickOne | `Auto` | `Auto` (Plain/Recycled), `Plain1` (Plain 1 (60 - 74 g/m2)), `Plain2` (Plain 2 (75 - 81 g/m2)), `Recycled`, `Special1` (Special 1), `Special2` (Special 2), `Special3` (Special 3), `Colored` (Color), `Letterhead`, `Preprinted`, `Labels`, `Bond`, `Cardstock`, `OHP` (Transparency), `Thick1` (Thick 1 (106 - 169 g/m2)), `Thick2` (Thick 2 (170 - 220 g/m2)), `Thick3` (Thick 3 (221 - 256 g/m2)), `Thick4` (Thick 4 (257 - 300 g/m2)), `Thin` (Thin (52 - 59 g/m2)), `Middlethick` (Middle Thick (82 - 105 g/m2)), `GlossCoated` (Coated (Glossy)), `MatCoated` (Coated (Matte)), `Envelope`, `None` | | +| `OutputBin` | Destination | PickOne | `Default` | `Default` (Printer Default), `Standard` (Internal Tray 1), `Bin1` (Internal Tray 2), `Shift` (Internal Shift Tray), `External` (External Tray), `FinRUBICONBShift` (Finisher SR3130 Shift Tray), `FinAMURBBKUpper` (Finisher SR3220 Upper Tray), `FinAMURBBKShift` (Finisher SR3220 Shift Tray), `FinAMURBBKLower` (Finisher SR3220 Booklet Tray), `FinUYUNIShift` (Finisher SR3180 Shift Tray) | | +| `RIBannerPagePrint` | Banner Page | PickOne | `False` | `False` (Off), `True` (On) | | +| `RIBannerPageInputSlot` | Banner Page Input Tray | PickOne | `Auto` | `Auto` (Auto Tray Select), `MultiTray` (Bypass Tray), `1Tray` (Tray 1), `2Tray` (Tray 2), `3Tray` (Tray 3), `4Tray` (Tray 4) | | +| `RIBannerPageMediaType` | Banner Page Paper Type | PickOne | `Auto` | `Auto` (Plain/Recycled), `Plain1` (Plain 1 (60 - 74 g/m2)), `Plain2` (Plain 2 (75 - 81 g/m2)), `Recycled`, `Special1` (Special 1), `Special2` (Special 2), `Special3` (Special 3), `Colored` (Color), `Letterhead`, `Preprinted`, `Labels`, `Bond`, `Cardstock`, `OHP` (Transparency), `Thick1` (Thick 1 (106 - 169 g/m2)), `Thick2` (Thick 2 (170 - 220 g/m2)), `Thick3` (Thick 3 (221 - 256 g/m2)), `Thick4` (Thick 4 (257 - 300 g/m2)), `Thin` (Thin (52 - 59 g/m2)), `Middlethick` (Middle Thick (82 - 105 g/m2)), `GlossCoated` (Coated (Glossy)), `MatCoated` (Coated (Matte)), `Envelope` | | + +### Finishing + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIOrientOvr` | Orientation Override | PickOne | `Off` | `Off`, `Landscape`, `Portrait` | | +| `RICollateKind` | Collate Type | PickOne | `Normal` | `Normal` (Collate), `RotateCollate` (Rotating Collate), `ShiftCollate` (Shift Collate) | | +| `StapleLocation` | Staple | PickOne | `None` | `None` (Off), `StaplessUpperLeft` (Top left (stapleless)), `StaplessUpperRight` (Top right (stapleless)), `UpperLeft` (Top left), `UpperRight` (Top right), `LeftW` (2 at left), `RightW` (2 at right), `UpperW` (2 at top), `CenterW` (2 at center) | | +| `RIPunch` | Punch | PickOne | `None` | `None` (Off), `Left2` (2 at left), `Left3` (3 at left), `Left4` (4 at left), `Right2` (2 at right), `Right3` (3 at right), `Right4` (4 at right), `Upper2` (2 at top), `Upper3` (3 at top), `Upper4` (4 at top) | | +| `RIFoldType` | Fold Type | PickOne | `None` | `None` (Off), `OutsideTwofold` (Half Fold - Print Outside (Finisher Booklet Tray)) | | +| `Booklet` | Booklet | PickOne | `None` | `None` (Off), `OpenToLeft` (Open to Left/Top), `OpenToRight` (Open to Right/Bottom) | | + +### Print Quality + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `Resolution` | Resolution | PickOne | `600dpi` | `600dpi` (600 dpi), `1200dpi` (1200 dpi) | | +| `RPSBitsPerPixel` | Gradation | PickOne | `2BitsPerPixel` | `2BitsPerPixel` (Standard), `1BitsPerPixel` (Fast), `4BitsPerPixel` (Fine) | | +| `RIPrintMode` | Print Mode | PickOne | `0rhit` | `0rhit` (Off), `3rhit` (Toner Saving) | | +| `Rimagesm` | Image Smoothing | PickOne | `Off` | `Off`, `On`, `Auto`, `90ppi` (Less than 90 ppi), `150ppi` (Less than 150 ppi), `200ppi` (Less than 200 ppi), `300ppi` (Less than 300 ppi) | | +| `RPSDitherType` | Dithering | PickOne | `Auto` | `Auto`, `Photo` (Photographic), `Letter` (Text), `User` (User Setting), `Dispersion` (Reduce Missing Colors and Blurring) | | +| `RPSRGBcorrect` | Color Setting | PickOne | `DetailBright` | `None` (Off), `DetailNormal` (Fine), `DetailBright` (Super Fine) | | +| `RPSColorRendDict` | Color Profile | PickOne | `Auto` | `Auto`, `Photograph` (Photographic), `Business` (Presentation), `Colorimetric` (Solid Color), `POP` (POP Display), `User` (User Setting), `Clpsimulation1` (Soft), `Clpsimulation2` (Sharp), `Clpsimulation4` (Vivid), `Clpsimulation` (CLP Simulation) | | +| `Rcmyksimulation` | CMYK Simulation Profile | PickOne | `Off` | `Off`, `USOffsetPrint` (US OffsetPrint), `Euroscale`, `JapanColor` (JapanColor2001), `PANTONE` (PaletteColor), `Trans01` (Make Bluer) | | +| `RPSBlackMode` | Gray Reproduction | PickOne | `gray` | `gray` (Black/Gray by K (Text/Line Art)), `1Color` (Black by K), `4Color` (CMY+K), `grayText` (Black/Gray by K (Text only)), `1ColorText` (Black by K (Text only)), `grayAll` (Black/Gray by K (Strong UCR)) | | +| `RPSBlackOverPrint` | Black Over Print | PickOne | `False` | `False` (Off), `True` (On) | | +| `RPSColorSep` | Separate into CMYK | PickOne | `None` | `None` (Do not Separate), `Cyan`, `Magenta`, `Yellow`, `Black`, `Red` (Magenta and Yellow), `Green` (Cyan and Yellow), `Blue` (Cyan and Magenta), `KCyan` (Black and Cyan), `KMagenta` (Black and Magenta), `KYellow` (Black and Yellow) | | + +### Effects + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIWatermark` | Watermark | PickOne | `Off` | `Off`, `On` | | +| `RIWMText` | Watermark Text | PickOne | `Confidential` | `Confidential` (CONFIDENTIAL), `Copy` (COPY), `Copyright` (DRAFT), `Final` (FINAL), `FileCopy` (FILE COPY), `Proof` (PROOF), `TopSecret` (TOP SECRET) | | +| `RIwmFont` | Watermark Font | PickOne | `Default` | `Default` (Printer Default), `HelveticaB` (Helvetica Bold), `CourierB` (Courier Bold), `TimesB` (Times Bold), `NimbusSansB` (NimbusSans Bold), `NimbusMonoPSB` (NimbusMonoPS Bold), `NimbusRomanB` (NimbusRoman Bold) | | +| `RIwmAngle` | Watermark Angle | PickOne | `45Deg` | `180Deg` (180 Degrees), `135Deg` (135 Degrees), `90Deg` (90 Degrees), `45Deg` (45 Degrees), `0Deg` (0 Degrees), `M45Deg` (-45 Degrees), `M90Deg` (-90 Degrees), `M135Deg` (-135 Degrees), `M180Deg` (-180 Degrees) | | +| `RIwmSize` | Watermark Size | PickOne | `36` | `24` (24 Point), `36` (36 Point), `48` (48 Point), `60` (60 Point), `72` (72 Point) | | +| `RIwmTextStyle` | Watermark Style | PickOne | `Gray` | `Gray`, `Outline` (Outlined) | | + +### Job Log + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIUserId` | User ID | PickOne | `None` | `None` | `UserId` string 0–8 | +| `RIJobType` | Job Type | PickOne | `Normal` | `Normal` (Normal Print), `SamplePrint` (Sample Print), `LockedPrint` (Locked Print), `HoldPrint` (Hold Print), `StoredPrint` (Stored Print), `StoreandPrint` (Store and Print), `DocServer` (Document Server) | | +| `RIFileName` | File Name | PickOne | `None` | `None` | `FileName` string 0–16 | +| `RIPassword` | Password | PickOne | `None` | `None` | `Password` passcode 4–8 | +| `RIEnableUserCode` | Enable User Code | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIUserCode` | User Code | PickOne | `None` | `None` | `UserCode` string 0–8 | +| `RIEnableSpecifyTime` | Set Print Time | Boolean | `False` | `False` (Off), `True` (On) | | +| `RITimeHour` | Hour (0 to 23) | PickOne | `0` | `0` | `TimeHour` int 0–23 | +| `RITimeMin` | Minute (0 to 59) | PickOne | `0` | `0` | `TimeMin` int 0–59 | +| `RIFolderNumber` | Folder Number | PickOne | `0` | `0` | `FolderNumber` int 0–200 | +| `RIFolderPassword` | Folder Password | PickOne | `None` | `None` | `FolderPassword` passcode 4–8 | + +### Unauthorized Copy Prevention + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIPreventionType` | Prevention Type | PickOne | `Off` | `Off`, `PreventionCopyPattern` (Unauthorized Copy Prevention for Pattern), `CopyGuard` (Data Security for Copying) | | +| `RITypeText` | Text Type | PickOne | `UserText` | `UserText` (User Text), `LoginUserName` (Login User Name), `JobName` (Job Name), `JobTime` (Date & Time), `LoginUserNameJobName` (Login User Name + Job Name), `LoginUserNameJobTime` (Login User Name + Date & Time), `JobNameTime` (Job Name + Date & Time), `LoginUserNameJobNameTime` (Login User Name + Job Name + Date & Time) | | +| `RIText` | Enter User Text | PickOne | `Copy` | `Copy` (COPY) | `Text` string 0–64 | +| `RIFont` | Font | PickOne | `Default` | `Default` (Printer Default), `NimbusSansBold` (NimbusSans-Bold), `NimbusMonoPSBold` (NimbusMonoPS-Bold), `NimbusRomanBold` (NimbusRoman-Bold), `AlbertusMT`, `AlbertusMTItalic` (AlbertusMT-Italic), `AlbertusMTLight` (AlbertusMT-Light), `AntiqueOliveBold` (AntiqueOlive-Bold), `AntiqueOliveCompact` (AntiqueOlive-Compact), `AntiqueOliveItalic` (AntiqueOlive-Italic), `AntiqueOliveRoman` (AntiqueOlive-Roman), `AppleChancery` (Apple-Chancery), `ArialMT`, `ArialBoldMT` (Arial-BoldMT), `ArialBoldItalicMT` (Arial-BoldItalicMT), `ArialItalicMT` (Arial-ItalicMT), `AvantGardeBook` (AvantGarde-Book), `AvantGardeBookOblique` (AvantGarde-BookOblique), `AvantGardeDemi` (AvantGarde-Demi), `AvantGardeDemiOblique` (AvantGarde-DemiOblique), `Bodoni`, `BodoniBold` (Bodoni-Bold), `BodoniBoldItalic` (Bodoni-BoldItalic), `BodoniItalic` (Bodoni-Italic), `BodoniPoster` (Bodoni-Poster), `BodoniPosterCompressed` (Bodoni-PosterCompressed), `BookmanDemi` (Bookman-Demi), `BookmanDemiItalic` (Bookman-DemiItalic), `BookmanLight` (Bookman-Light), `BookmanLightItalic` (Bookman-LightItalic), `Carta`, `Chicago`, `ClarendonBold` (Clarendon-Bold), `ClarendonLight` (Clarendon-Light), `Clarendon`, `CooperBlackItalic` (CooperBlack-Italic), `CooperBlack`, `CopperplateThirtyThreeBC` (Copperplate-ThirtyThreeBC), `CopperplateThirtyTwoBC` (Copperplate-ThirtyTwoBC), `CoronetRegular` (Coronet-Regular), `CourierBold` (Courier-Bold), `CourierBoldOblique` (Courier-BoldOblique), `CourierOblique` (Courier-Oblique), `Courier`, `EurostileBold` (Eurostile-Bold), `EurostileBoldExtendedTwo` (Eurostile-BoldExtendedTwo), `EurostileExtendedTwo` (Eurostile-ExtendedTwo), `Eurostile`, `Geneva`, `GillSans`, `GillSansBold` (GillSans-Bold), `GillSansBoldCondensed` (GillSans-BoldCondensed), `GillSansBoldItalic` (GillSans-BoldItalic), `GillSansCondensed` (GillSans-Condensed), `GillSansExtraBold` (GillSans-ExtraBold), `GillSansItalic` (GillSans-Italic), `GillSansLight` (GillSans-Light), `GillSansLightItalic` (GillSans-LightItalic), `Goudy`, `GoudyBold` (Goudy-Bold), `GoudyBoldItalic` (Goudy-BoldItalic), `GoudyExtraBold` (Goudy-ExtraBold), `GoudyItalic` (Goudy-Italic), `Helvetica`, `HelveticaBold` (Helvetica-Bold), `HelveticaBoldOblique` (Helvetica-BoldOblique), `HelveticaCondensedBold` (Helvetica-Condensed-Bold), `HelveticaCondensedBoldObl` (Helvetica-Condensed-BoldObl), `HelveticaCondensedOblique` (Helvetica-Condensed-Oblique), `HelveticaCondensed` (Helvetica-Condensed), `HelveticaNarrowBold` (Helvetica-Narrow-Bold), `HelveticaNarrowBoldOblique` (Helvetica-Narrow-BoldOblique), `HelveticaNarrowOblique` (Helvetica-Narrow-Oblique), `HelveticaNarrow` (Helvetica-Narrow), `HelveticaOblique` (Helvetica-Oblique), `HoeflerTextBlack` (HoeflerText-Black), `HoeflerTextBlackItalic` (HoeflerText-BlackItalic), `HoeflerTextItalic` (HoeflerText-Italic), `HoeflerTextOrnaments` (HoeflerText-Ornaments), `HoeflerTextRegular` (HoeflerText-Regular), `JoannaMT`, `JoannaMTBold` (JoannaMT-Bold), `JoannaMTBoldItalic` (JoannaMT-BoldItalic), `JoannaMTItalic` (JoannaMT-Italic), `LetterGothic`, `LetterGothicBold` (LetterGothic-Bold), `LetterGothicBoldSlanted` (LetterGothic-BoldSlanted), `LetterGothicSlanted` (LetterGothic-Slanted), `LubalinGraphBook` (LubalinGraph-Book), `LubalinGraphBookOblique` (LubalinGraph-BookOblique), `LubalinGraphDemi` (LubalinGraph-Demi), `LubalinGraphDemiOblique` (LubalinGraph-DemiOblique), `Marigold`, `MonaLisaRecut` (MonaLisa-Recut), `Monaco`, `NewCenturySchlbkBold` (NewCenturySchlbk-Bold), `NewCenturySchlbkBoldItalic` (NewCenturySchlbk-BoldItalic), `NewCenturySchlbkItalic` (NewCenturySchlbk-Italic), `NewCenturySchlbkRoman` (NewCenturySchlbk-Roman), `NewYork`, `OptimaBold` (Optima-Bold), `OptimaBoldItalic` (Optima-BoldItalic), `OptimaItalic` (Optima-Italic), `Optima`, `Oxford`, `PalatinoBold` (Palatino-Bold), `PalatinoBoldItalic` (Palatino-BoldItalic), `PalatinoItalic` (Palatino-Italic), `PalatinoRoman` (Palatino-Roman), `StempelGaramondBold` (StempelGaramond-Bold), `StempelGaramondBoldItalic` (StempelGaramond-BoldItalic), `StempelGaramondItalic` (StempelGaramond-Italic), `StempelGaramondRoman` (StempelGaramond-Roman), `Symbol`, `Tekton`, `TimesBold` (Times-Bold), `TimesBoldItalic` (Times-BoldItalic), `TimesItalic` (Times-Italic), `TimesRoman` (Times-Roman), `TimesNewRomanPSBoldItalicMT` (TimesNewRomanPS-BoldItalicMT), `TimesNewRomanPSBoldMT` (TimesNewRomanPS-BoldMT), `TimesNewRomanPSItalicMT` (TimesNewRomanPS-ItalicMT), `TimesNewRomanPSMT`, `Univers`, `UniversBold` (Univers-Bold), `UniversBoldExt` (Univers-BoldExt), `UniversBoldExtObl` (Univers-BoldExtObl), `UniversBoldOblique` (Univers-BoldOblique), `UniversCondensed` (Univers-Condensed), `UniversCondensedBold` (Univers-CondensedBold), `UniversCondensedBoldOblique` (Univers-CondensedBoldOblique), `UniversCondensedOblique` (Univers-CondensedOblique), `UniversExtended` (Univers-Extended), `UniversExtendedObl` (Univers-ExtendedObl), `UniversLight` (Univers-Light), `UniversLightOblique` (Univers-LightOblique), `UniversOblique` (Univers-Oblique), `WingdingsRegular` (Wingdings-Regular), `ZapfChanceryMediumItalic` (ZapfChancery-MediumItalic), `ZapfDingbats` | | +| `RISize` | Size | PickOne | `70` | `70` | `Size` int 50–300 | +| `RIAngel` | Angle | PickOne | `30` | `30` | `Angel` int 0–359 | +| `RIEffects` | Text/Pattern Effects | PickOne | `Normal` | `Normal` (Text and Background), `ReversePatterns` (Reverse Patterns (Text/Background)), `BackgroundOnly` (Background Only), `TextOnly` (Text Only) | | +| `RIRepeat` | Repeat | PickOne | `Off` | `Off`, `Repeat` (On), `RepeatandRotateCarriageReturn` (On (Rotate 180 Degrees at Carriage Return)) | | +| `RILineSpace` | Line Space | PickOne | `70` | `70` | `LineSpace` int 50–300 | +| `RIPosition` | Position | PickOne | `Center` | `Center`, `TopLeft` (Top Left), `TopCenter` (Top Center), `TopRight` (Top Right), `BottomLeft` (Bottom Left), `BottomCenter` (Bottom Center), `BottomRight` (Bottom Right) | | +| `RIColor` | Color | PickOne | `Black` | `Black`, `Cyan`, `Magenta` | | +| `RIDensity` | Density | PickOne | `Medium` | `VeryLight` (Very Light), `Light`, `Medium`, `Dark`, `VeryDark` (Very Dark) | | +| `RIMaskType` | Mask Type | PickOne | `None` | `None`, `WaveCrest` (Type 1), `Mesh` (Type 2), `Lattice1` (Type 3), `Lattice2` (Type 4), `InterlockingCircles` (Type 5), `Shokkoh` (Type 6), `Matsukawabishi` (Type 7), `Scale` (Type 8), `Higaki` (Type 9), `Hexagonal` (Type 10) | | + +### Color Balance Details + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIBrightness` | Brightness ( -50 to 50 ) | PickOne | `0` | `0` | `Brightness` real -50–50 | +| `RIContrast` | Contrast ( -50 to 50 ) | PickOne | `0` | `0` | `Contrast` real -50–50 | +| `RIBlack` | Black ( -50 to 50 ) | PickOne | `0` | `0` | `Black` real -50–50 | +| `RICyan` | Cyan ( -50 to 50 ) | PickOne | `0` | `0` | `Cyan` real -50–50 | +| `RIMagenta` | Magenta ( -50 to 50 ) | PickOne | `0` | `0` | `Magenta` real -50–50 | +| `RIYellow` | Yellow ( -50 to 50 ) | PickOne | `0` | `0` | `Yellow` real -50–50 | + +### User Authentication + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIEnableUserAuth` | User Authentication | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIAuthLoginUserNameType` | Login User Name | PickOne | `DefinedUserID` | `DefinedUserID` (Defined User ID), `LoginUserName` (Mac Login Name) | | +| `RIAuthLoginUserNameText` | Enter Login User Name | PickOne | `None` | `None` | `AuthLoginUserNameText` string 0–128 | +| `RIAuthLoginPassword` | Login Password | PickOne | `None` | `None` | `AuthLoginPassword` password 0–128 | +| `RIAuthEnableEncryption` | Driver Encryption Key | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIAuthEncryptionKey` | Driver Encryption Key | PickOne | `None` | `None` | `AuthEncryptionKey` password 0–32 | + +## RICOH MP C3004ex PS + +### Installable Options + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `OptionTray` | Option Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `1Cassette` (Lower Paper Tray), `LCT` (Tray 3 (LCT)), `2Cassette` (Lower Paper Trays) | | +| `LargeCapacityTray` | Large Capacity Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `InnerTray2` | Internal Tray 2 | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `ShiftTray` | Internal Shift Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `ExternalTray` | External Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `Finisher` | Finisher | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `FinRUBICONB` (Finisher SR3130), `FinAMURBBK` (Finisher SR3220), `FinAMURHY` (Finisher SR3210), `FinUYUNI` (Finisher SR3180) | | +| `RIPostScript` | PostScript | PickOne | `IRIPS` | `IRIPS` (PostScript Emulation), `Adobe` (Adobe PostScript) | | + +### General + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `PageSize` | PageSize | PickOne | `A4` | `A3` (A3 (297 x 420 mm)), `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B4` (B4 JIS (257 x 364 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGermanLegal` (8.5 x 13), `11x17` (11 x 17), `12x18` (12 x 18), `11x15` (11 x 15), `10x14` (10 x 14), `SRA3` (SRA3 (320 x 450 mm)), `SRA4` (SRA4 (225 x 320 mm)), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `8Kai` (8K (267 x 390 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4), `A3.FullBleed` (A3 (297 x 420 mm) (Full bleed)), `A4.FullBleed` (A4 (210 x 297 mm) (Full Bleed)), `A5.FullBleed` (A5 (148 x 210 mm) (Full Bleed)), `A6.FullBleed` (A6 (105 x 148 mm) (Full Bleed)), `B4.FullBleed` (B4 (JIS) (257 x 364 mm) (Full Bleed)), `B5.FullBleed` (B5 (JIS) (182 x 257 mm) (Full Bleed)), `B6.FullBleed` (B6 (JIS) (128 x 182 mm) (Full Bleed)), `Legal.FullBleed` (Legal (8.5 x 14) (Full Bleed)), `GovernmentLG.FullBleed` (8.25 x 14 (Full Bleed)), `EngQuatro.FullBleed` (8 x 10 (Full Bleed)), `Letter.FullBleed` (Letter (8.5 x 11) (Full Bleed)), `HalfLetter.FullBleed` (5.5 x 8.5 (Full Bleed)), `F.FullBleed` (8 x 13 (Full Bleed)), `Folio.FullBleed` (8.25 x 13 (Full Bleed)), `FanFoldGermanLegal.FullBleed` (8.5 x 13 (Full Bleed)), `11x17.FullBleed` (11 x 17 (Full Bleed)), `12x18.FullBleed` (12 x 18 (Full Bleed)), `11x15.FullBleed` (11 x 15 (Full Bleed)), `10x14.FullBleed` (10 x 14 (Full Bleed)), `SRA3.FullBleed` (SRA3 (320 x 450 mm) (Full Bleed)), `SRA4.FullBleed` (SRA4 (225 x 320 mm) (Full Bleed)), `Executive.FullBleed` (Executive (7.25 x 10.5) (Full Bleed)), `Env10.FullBleed` (Com10 Env. (4.125 x 9.5) (Full Bleed)), `EnvMonarch.FullBleed` (Monarch Env. (3.875 x 7.5) (Full Bleed)), `EnvC5.FullBleed` (C5 Env. (162 x 229 mm) (Full Bleed)), `EnvC6.FullBleed` (C6 Env. (114 x 162 mm) (Full Bleed)), `DLEnv.FullBleed` (DL Env. (110 x 220 mm) (Full Bleed)), `8Kai.FullBleed` (8K (267 x 390 mm) (Full Bleed)), `16Kai.FullBleed` (16K (195 x 267 mm) (Full Bleed)), `Oficio.FullBleed` (8.5 x 13.4 (Full Bleed)) | `Width` points 255–908; `Height` points 419–3572; `WidthOffset` points 0–0; `HeightOffset` points 0–0; `Orientation` int 1–1 | +| `PageRegion` | PageRegion | PickOne | `A4` | `A3` (A3 (297 x 420 mm)), `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B4` (B4 JIS (257 x 364 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGermanLegal` (8.5 x 13), `11x17` (11 x 17), `12x18` (12 x 18), `11x15` (11 x 15), `10x14` (10 x 14), `SRA3` (SRA3 (320 x 450 mm)), `SRA4` (SRA4 (225 x 320 mm)), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `8Kai` (8K (267 x 390 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4), `A3.FullBleed` (A3 (297 x 420 mm) (Full bleed)), `A4.FullBleed` (A4 (210 x 297 mm) (Full Bleed)), `A5.FullBleed` (A5 (148 x 210 mm) (Full Bleed)), `A6.FullBleed` (A6 (105 x 148 mm) (Full Bleed)), `B4.FullBleed` (B4 (JIS) (257 x 364 mm) (Full Bleed)), `B5.FullBleed` (B5 (JIS) (182 x 257 mm) (Full Bleed)), `B6.FullBleed` (B6 (JIS) (128 x 182 mm) (Full Bleed)), `Legal.FullBleed` (Legal (8.5 x 14) (Full Bleed)), `GovernmentLG.FullBleed` (8.25 x 14 (Full Bleed)), `EngQuatro.FullBleed` (8 x 10 (Full Bleed)), `Letter.FullBleed` (Letter (8.5 x 11) (Full Bleed)), `HalfLetter.FullBleed` (5.5 x 8.5 (Full Bleed)), `F.FullBleed` (8 x 13 (Full Bleed)), `Folio.FullBleed` (8.25 x 13 (Full Bleed)), `FanFoldGermanLegal.FullBleed` (8.5 x 13 (Full Bleed)), `11x17.FullBleed` (11 x 17 (Full Bleed)), `12x18.FullBleed` (12 x 18 (Full Bleed)), `11x15.FullBleed` (11 x 15 (Full Bleed)), `10x14.FullBleed` (10 x 14 (Full Bleed)), `SRA3.FullBleed` (SRA3 (320 x 450 mm) (Full Bleed)), `SRA4.FullBleed` (SRA4 (225 x 320 mm) (Full Bleed)), `Executive.FullBleed` (Executive (7.25 x 10.5) (Full Bleed)), `Env10.FullBleed` (Com10 Env. (4.125 x 9.5) (Full Bleed)), `EnvMonarch.FullBleed` (Monarch Env. (3.875 x 7.5) (Full Bleed)), `EnvC5.FullBleed` (C5 Env. (162 x 229 mm) (Full Bleed)), `EnvC6.FullBleed` (C6 Env. (114 x 162 mm) (Full Bleed)), `DLEnv.FullBleed` (DL Env. (110 x 220 mm) (Full Bleed)), `8Kai.FullBleed` (8K (267 x 390 mm) (Full Bleed)), `16Kai.FullBleed` (16K (195 x 267 mm) (Full Bleed)), `Oficio.FullBleed` (8.5 x 13.4 (Full Bleed)) | | +| `InputSlot` | InputSlot | PickOne | `1Tray` | `MultiTray` (Bypass Tray), `1Tray` (Tray 1), `2Tray` (Tray 2), `3Tray` (Tray 3), `4Tray` (Tray 4), `5Tray` (Large Capacity Tray) | | +| `Duplex` | Duplex | PickOne | `DuplexNoTumble` | `None` (Off), `DuplexNoTumble` (Long Edge), `DuplexTumble` (Short Edge) | | +| `Collate` | Collate | PickOne | `False` | `False` (Off), `True` (On) | | + +### Basic + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIPaperPolicy` | Fit to Paper | PickOne | `PromptUser` | `PromptUser` (Prompt User), `NearestSizeAdjust` (Nearest Size and Scale), `NearestSizeNoAdjust` (Nearest Size and Crop) | | +| `ColorModel` | Color Mode | PickOne | `CMYK` | `CMYK` (Color), `Gray` (Black and White) | | +| `RIRotateBy180` | Rotate by 180 degrees | PickOne | `Off` | `Off`, `On` | | + +### Paper + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `MediaType` | Paper Type | PickOne | `Auto` | `Auto` (Plain/Recycled), `Plain1` (Plain 1 (60 - 74 g/m2)), `Plain2` (Plain 2 (75 - 81 g/m2)), `Recycled`, `Special1` (Special 1), `Special2` (Special 2), `Special3` (Special 3), `Colored` (Color), `Letterhead`, `Preprinted`, `Labels`, `Bond`, `Cardstock`, `OHP` (Transparency), `Thick1` (Thick 1 (106 - 169 g/m2)), `Thick2` (Thick 2 (170 - 220 g/m2)), `Thick3` (Thick 3 (221 - 256 g/m2)), `Thick4` (Thick 4 (257 - 300 g/m2)), `Thin` (Thin (52 - 59 g/m2)), `Middlethick` (Middle Thick (82 - 105 g/m2)), `GlossCoated` (Coated (Glossy)), `MatCoated` (Coated (Matte)), `Envelope`, `None` | | +| `OutputBin` | Destination | PickOne | `Default` | `Default` (Printer Default), `Standard` (Internal Tray 1), `Bin1` (Internal Tray 2), `Shift` (Internal Shift Tray), `External` (External Tray), `FinRUBICONBShift` (Finisher SR3130 Shift Tray), `FinAMURBBKUpper` (Finisher SR3220 Upper Tray), `FinAMURBBKShift` (Finisher SR3220 Shift Tray), `FinAMURBBKLower` (Finisher SR3220 Booklet Tray), `FinAMURHYUpper` (Finisher SR3210 Upper Tray), `FinAMURHYShift` (Finisher SR3210 Shift Tray), `FinUYUNIShift` (Finisher SR3180 Shift Tray) | | +| `RIBannerPagePrint` | Banner Page | PickOne | `False` | `False` (Off), `True` (On) | | +| `RIBannerPageInputSlot` | Banner Page Input Tray | PickOne | `Auto` | `Auto` (Auto Tray Select), `MultiTray` (Bypass Tray), `1Tray` (Tray 1), `2Tray` (Tray 2), `3Tray` (Tray 3), `4Tray` (Tray 4), `5Tray` (Large Capacity Tray) | | +| `RIBannerPageMediaType` | Banner Page Paper Type | PickOne | `Auto` | `Auto` (Plain/Recycled), `Plain1` (Plain 1 (60 - 74 g/m2)), `Plain2` (Plain 2 (75 - 81 g/m2)), `Recycled`, `Special1` (Special 1), `Special2` (Special 2), `Special3` (Special 3), `Colored` (Color), `Letterhead`, `Preprinted`, `Labels`, `Bond`, `Cardstock`, `OHP` (Transparency), `Thick1` (Thick 1 (106 - 169 g/m2)), `Thick2` (Thick 2 (170 - 220 g/m2)), `Thick3` (Thick 3 (221 - 256 g/m2)), `Thick4` (Thick 4 (257 - 300 g/m2)), `Thin` (Thin (52 - 59 g/m2)), `Middlethick` (Middle Thick (82 - 105 g/m2)), `GlossCoated` (Coated (Glossy)), `MatCoated` (Coated (Matte)), `Envelope` | | + +### Finishing + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIOrientOvr` | Orientation Override | PickOne | `Off` | `Off`, `Landscape`, `Portrait` | | +| `RICollateKind` | Collate Type | PickOne | `Normal` | `Normal` (Collate), `RotateCollate` (Rotating Collate), `ShiftCollate` (Shift Collate) | | +| `StapleLocation` | Staple | PickOne | `None` | `None` (Off), `StaplessUpperLeft` (Top left (stapleless)), `StaplessUpperRight` (Top right (stapleless)), `UpperLeft` (Top left), `UpperRight` (Top right), `LeftW` (2 at left), `RightW` (2 at right), `UpperW` (2 at top), `CenterW` (2 at center) | | +| `RIPunch` | Punch | PickOne | `None` | `None` (Off), `Left2` (2 at left), `Left3` (3 at left), `Left4` (4 at left), `Right2` (2 at right), `Right3` (3 at right), `Right4` (4 at right), `Upper2` (2 at top), `Upper3` (3 at top), `Upper4` (4 at top) | | +| `RIFoldType` | Fold Type | PickOne | `None` | `None` (Off), `OutsideTwofold` (Half Fold - Print Outside (Finisher Booklet Tray)) | | +| `Booklet` | Booklet | PickOne | `None` | `None` (Off), `OpenToLeft` (Open to Left/Top), `OpenToRight` (Open to Right/Bottom) | | + +### Print Quality + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `Resolution` | Resolution | PickOne | `600dpi` | `600dpi` (600 dpi), `1200dpi` (1200 dpi) | | +| `RPSBitsPerPixel` | Gradation | PickOne | `2BitsPerPixel` | `2BitsPerPixel` (Standard), `1BitsPerPixel` (Fast), `4BitsPerPixel` (Fine) | | +| `RIPrintMode` | Print Mode | PickOne | `0rhit` | `0rhit` (Off), `3rhit` (Toner Saving) | | +| `Rimagesm` | Image Smoothing | PickOne | `Off` | `Off`, `On`, `Auto`, `90ppi` (Less than 90 ppi), `150ppi` (Less than 150 ppi), `200ppi` (Less than 200 ppi), `300ppi` (Less than 300 ppi) | | +| `RPSDitherType` | Dithering | PickOne | `Auto` | `Auto`, `Photo` (Photographic), `Letter` (Text), `User` (User Setting), `Dispersion` (Reduce Missing Colors and Blurring) | | +| `RPSRGBcorrect` | Color Setting | PickOne | `DetailBright` | `None` (Off), `DetailNormal` (Fine), `DetailBright` (Super Fine) | | +| `RPSColorRendDict` | Color Profile | PickOne | `Auto` | `Auto`, `Photograph` (Photographic), `Business` (Presentation), `Colorimetric` (Solid Color), `POP` (POP Display), `User` (User Setting), `Clpsimulation1` (Soft), `Clpsimulation2` (Sharp), `Clpsimulation4` (Vivid), `Clpsimulation` (CLP Simulation) | | +| `Rcmyksimulation` | CMYK Simulation Profile | PickOne | `Off` | `Off`, `USOffsetPrint` (US OffsetPrint), `Euroscale`, `JapanColor` (JapanColor2001), `PANTONE` (PaletteColor), `Trans01` (Make Bluer) | | +| `RPSBlackMode` | Gray Reproduction | PickOne | `gray` | `gray` (Black/Gray by K (Text/Line Art)), `1Color` (Black by K), `4Color` (CMY+K), `grayText` (Black/Gray by K (Text only)), `1ColorText` (Black by K (Text only)), `grayAll` (Black/Gray by K (Strong UCR)) | | +| `RPSBlackOverPrint` | Black Over Print | PickOne | `False` | `False` (Off), `True` (On) | | +| `RPSColorSep` | Separate into CMYK | PickOne | `None` | `None` (Do not Separate), `Cyan`, `Magenta`, `Yellow`, `Black`, `Red` (Magenta and Yellow), `Green` (Cyan and Yellow), `Blue` (Cyan and Magenta), `KCyan` (Black and Cyan), `KMagenta` (Black and Magenta), `KYellow` (Black and Yellow) | | + +### Effects + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIWatermark` | Watermark | PickOne | `Off` | `Off`, `On` | | +| `RIWMText` | Watermark Text | PickOne | `Confidential` | `Confidential` (CONFIDENTIAL), `Copy` (COPY), `Copyright` (DRAFT), `Final` (FINAL), `FileCopy` (FILE COPY), `Proof` (PROOF), `TopSecret` (TOP SECRET) | | +| `RIwmFont` | Watermark Font | PickOne | `Default` | `Default` (Printer Default), `HelveticaB` (Helvetica Bold), `CourierB` (Courier Bold), `TimesB` (Times Bold), `NimbusSansB` (NimbusSans Bold), `NimbusMonoPSB` (NimbusMonoPS Bold), `NimbusRomanB` (NimbusRoman Bold) | | +| `RIwmAngle` | Watermark Angle | PickOne | `45Deg` | `180Deg` (180 Degrees), `135Deg` (135 Degrees), `90Deg` (90 Degrees), `45Deg` (45 Degrees), `0Deg` (0 Degrees), `M45Deg` (-45 Degrees), `M90Deg` (-90 Degrees), `M135Deg` (-135 Degrees), `M180Deg` (-180 Degrees) | | +| `RIwmSize` | Watermark Size | PickOne | `36` | `24` (24 Point), `36` (36 Point), `48` (48 Point), `60` (60 Point), `72` (72 Point) | | +| `RIwmTextStyle` | Watermark Style | PickOne | `Gray` | `Gray`, `Outline` (Outlined) | | + +### Job Log + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIUserId` | User ID | PickOne | `None` | `None` | `UserId` string 0–8 | +| `RIJobType` | Job Type | PickOne | `Normal` | `Normal` (Normal Print), `SamplePrint` (Sample Print), `LockedPrint` (Locked Print), `HoldPrint` (Hold Print), `StoredPrint` (Stored Print), `StoreandPrint` (Store and Print), `DocServer` (Document Server) | | +| `RIFileName` | File Name | PickOne | `None` | `None` | `FileName` string 0–16 | +| `RIPassword` | Password | PickOne | `None` | `None` | `Password` passcode 4–8 | +| `RIEnableUserCode` | Enable User Code | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIUserCode` | User Code | PickOne | `None` | `None` | `UserCode` string 0–8 | +| `RIEnableSpecifyTime` | Set Print Time | Boolean | `False` | `False` (Off), `True` (On) | | +| `RITimeHour` | Hour (0 to 23) | PickOne | `0` | `0` | `TimeHour` int 0–23 | +| `RITimeMin` | Minute (0 to 59) | PickOne | `0` | `0` | `TimeMin` int 0–59 | +| `RIFolderNumber` | Folder Number | PickOne | `0` | `0` | `FolderNumber` int 0–200 | +| `RIFolderPassword` | Folder Password | PickOne | `None` | `None` | `FolderPassword` passcode 4–8 | + +### Unauthorized Copy Prevention + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIPreventionType` | Prevention Type | PickOne | `Off` | `Off`, `PreventionCopyPattern` (Unauthorized Copy Prevention for Pattern), `CopyGuard` (Data Security for Copying) | | +| `RITypeText` | Text Type | PickOne | `UserText` | `UserText` (User Text), `LoginUserName` (Login User Name), `JobName` (Job Name), `JobTime` (Date & Time), `LoginUserNameJobName` (Login User Name + Job Name), `LoginUserNameJobTime` (Login User Name + Date & Time), `JobNameTime` (Job Name + Date & Time), `LoginUserNameJobNameTime` (Login User Name + Job Name + Date & Time) | | +| `RIText` | Enter User Text | PickOne | `Copy` | `Copy` (COPY) | `Text` string 0–64 | +| `RIFont` | Font | PickOne | `Default` | `Default` (Printer Default), `NimbusSansBold` (NimbusSans-Bold), `NimbusMonoPSBold` (NimbusMonoPS-Bold), `NimbusRomanBold` (NimbusRoman-Bold), `AlbertusMT`, `AlbertusMTItalic` (AlbertusMT-Italic), `AlbertusMTLight` (AlbertusMT-Light), `AntiqueOliveBold` (AntiqueOlive-Bold), `AntiqueOliveCompact` (AntiqueOlive-Compact), `AntiqueOliveItalic` (AntiqueOlive-Italic), `AntiqueOliveRoman` (AntiqueOlive-Roman), `AppleChancery` (Apple-Chancery), `ArialMT`, `ArialBoldMT` (Arial-BoldMT), `ArialBoldItalicMT` (Arial-BoldItalicMT), `ArialItalicMT` (Arial-ItalicMT), `AvantGardeBook` (AvantGarde-Book), `AvantGardeBookOblique` (AvantGarde-BookOblique), `AvantGardeDemi` (AvantGarde-Demi), `AvantGardeDemiOblique` (AvantGarde-DemiOblique), `Bodoni`, `BodoniBold` (Bodoni-Bold), `BodoniBoldItalic` (Bodoni-BoldItalic), `BodoniItalic` (Bodoni-Italic), `BodoniPoster` (Bodoni-Poster), `BodoniPosterCompressed` (Bodoni-PosterCompressed), `BookmanDemi` (Bookman-Demi), `BookmanDemiItalic` (Bookman-DemiItalic), `BookmanLight` (Bookman-Light), `BookmanLightItalic` (Bookman-LightItalic), `Carta`, `Chicago`, `ClarendonBold` (Clarendon-Bold), `ClarendonLight` (Clarendon-Light), `Clarendon`, `CooperBlackItalic` (CooperBlack-Italic), `CooperBlack`, `CopperplateThirtyThreeBC` (Copperplate-ThirtyThreeBC), `CopperplateThirtyTwoBC` (Copperplate-ThirtyTwoBC), `CoronetRegular` (Coronet-Regular), `CourierBold` (Courier-Bold), `CourierBoldOblique` (Courier-BoldOblique), `CourierOblique` (Courier-Oblique), `Courier`, `EurostileBold` (Eurostile-Bold), `EurostileBoldExtendedTwo` (Eurostile-BoldExtendedTwo), `EurostileExtendedTwo` (Eurostile-ExtendedTwo), `Eurostile`, `Geneva`, `GillSans`, `GillSansBold` (GillSans-Bold), `GillSansBoldCondensed` (GillSans-BoldCondensed), `GillSansBoldItalic` (GillSans-BoldItalic), `GillSansCondensed` (GillSans-Condensed), `GillSansExtraBold` (GillSans-ExtraBold), `GillSansItalic` (GillSans-Italic), `GillSansLight` (GillSans-Light), `GillSansLightItalic` (GillSans-LightItalic), `Goudy`, `GoudyBold` (Goudy-Bold), `GoudyBoldItalic` (Goudy-BoldItalic), `GoudyExtraBold` (Goudy-ExtraBold), `GoudyItalic` (Goudy-Italic), `Helvetica`, `HelveticaBold` (Helvetica-Bold), `HelveticaBoldOblique` (Helvetica-BoldOblique), `HelveticaCondensedBold` (Helvetica-Condensed-Bold), `HelveticaCondensedBoldObl` (Helvetica-Condensed-BoldObl), `HelveticaCondensedOblique` (Helvetica-Condensed-Oblique), `HelveticaCondensed` (Helvetica-Condensed), `HelveticaNarrowBold` (Helvetica-Narrow-Bold), `HelveticaNarrowBoldOblique` (Helvetica-Narrow-BoldOblique), `HelveticaNarrowOblique` (Helvetica-Narrow-Oblique), `HelveticaNarrow` (Helvetica-Narrow), `HelveticaOblique` (Helvetica-Oblique), `HoeflerTextBlack` (HoeflerText-Black), `HoeflerTextBlackItalic` (HoeflerText-BlackItalic), `HoeflerTextItalic` (HoeflerText-Italic), `HoeflerTextOrnaments` (HoeflerText-Ornaments), `HoeflerTextRegular` (HoeflerText-Regular), `JoannaMT`, `JoannaMTBold` (JoannaMT-Bold), `JoannaMTBoldItalic` (JoannaMT-BoldItalic), `JoannaMTItalic` (JoannaMT-Italic), `LetterGothic`, `LetterGothicBold` (LetterGothic-Bold), `LetterGothicBoldSlanted` (LetterGothic-BoldSlanted), `LetterGothicSlanted` (LetterGothic-Slanted), `LubalinGraphBook` (LubalinGraph-Book), `LubalinGraphBookOblique` (LubalinGraph-BookOblique), `LubalinGraphDemi` (LubalinGraph-Demi), `LubalinGraphDemiOblique` (LubalinGraph-DemiOblique), `Marigold`, `MonaLisaRecut` (MonaLisa-Recut), `Monaco`, `NewCenturySchlbkBold` (NewCenturySchlbk-Bold), `NewCenturySchlbkBoldItalic` (NewCenturySchlbk-BoldItalic), `NewCenturySchlbkItalic` (NewCenturySchlbk-Italic), `NewCenturySchlbkRoman` (NewCenturySchlbk-Roman), `NewYork`, `OptimaBold` (Optima-Bold), `OptimaBoldItalic` (Optima-BoldItalic), `OptimaItalic` (Optima-Italic), `Optima`, `Oxford`, `PalatinoBold` (Palatino-Bold), `PalatinoBoldItalic` (Palatino-BoldItalic), `PalatinoItalic` (Palatino-Italic), `PalatinoRoman` (Palatino-Roman), `StempelGaramondBold` (StempelGaramond-Bold), `StempelGaramondBoldItalic` (StempelGaramond-BoldItalic), `StempelGaramondItalic` (StempelGaramond-Italic), `StempelGaramondRoman` (StempelGaramond-Roman), `Symbol`, `Tekton`, `TimesBold` (Times-Bold), `TimesBoldItalic` (Times-BoldItalic), `TimesItalic` (Times-Italic), `TimesRoman` (Times-Roman), `TimesNewRomanPSBoldItalicMT` (TimesNewRomanPS-BoldItalicMT), `TimesNewRomanPSBoldMT` (TimesNewRomanPS-BoldMT), `TimesNewRomanPSItalicMT` (TimesNewRomanPS-ItalicMT), `TimesNewRomanPSMT`, `Univers`, `UniversBold` (Univers-Bold), `UniversBoldExt` (Univers-BoldExt), `UniversBoldExtObl` (Univers-BoldExtObl), `UniversBoldOblique` (Univers-BoldOblique), `UniversCondensed` (Univers-Condensed), `UniversCondensedBold` (Univers-CondensedBold), `UniversCondensedBoldOblique` (Univers-CondensedBoldOblique), `UniversCondensedOblique` (Univers-CondensedOblique), `UniversExtended` (Univers-Extended), `UniversExtendedObl` (Univers-ExtendedObl), `UniversLight` (Univers-Light), `UniversLightOblique` (Univers-LightOblique), `UniversOblique` (Univers-Oblique), `WingdingsRegular` (Wingdings-Regular), `ZapfChanceryMediumItalic` (ZapfChancery-MediumItalic), `ZapfDingbats` | | +| `RISize` | Size | PickOne | `70` | `70` | `Size` int 50–300 | +| `RIAngel` | Angle | PickOne | `30` | `30` | `Angel` int 0–359 | +| `RIEffects` | Text/Pattern Effects | PickOne | `Normal` | `Normal` (Text and Background), `ReversePatterns` (Reverse Patterns (Text/Background)), `BackgroundOnly` (Background Only), `TextOnly` (Text Only) | | +| `RIRepeat` | Repeat | PickOne | `Off` | `Off`, `Repeat` (On), `RepeatandRotateCarriageReturn` (On (Rotate 180 Degrees at Carriage Return)) | | +| `RILineSpace` | Line Space | PickOne | `70` | `70` | `LineSpace` int 50–300 | +| `RIPosition` | Position | PickOne | `Center` | `Center`, `TopLeft` (Top Left), `TopCenter` (Top Center), `TopRight` (Top Right), `BottomLeft` (Bottom Left), `BottomCenter` (Bottom Center), `BottomRight` (Bottom Right) | | +| `RIColor` | Color | PickOne | `Black` | `Black`, `Cyan`, `Magenta` | | +| `RIDensity` | Density | PickOne | `Medium` | `VeryLight` (Very Light), `Light`, `Medium`, `Dark`, `VeryDark` (Very Dark) | | +| `RIMaskType` | Mask Type | PickOne | `None` | `None`, `WaveCrest` (Type 1), `Mesh` (Type 2), `Lattice1` (Type 3), `Lattice2` (Type 4), `InterlockingCircles` (Type 5), `Shokkoh` (Type 6), `Matsukawabishi` (Type 7), `Scale` (Type 8), `Higaki` (Type 9), `Hexagonal` (Type 10) | | + +### Color Balance Details + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIBrightness` | Brightness ( -50 to 50 ) | PickOne | `0` | `0` | `Brightness` real -50–50 | +| `RIContrast` | Contrast ( -50 to 50 ) | PickOne | `0` | `0` | `Contrast` real -50–50 | +| `RIBlack` | Black ( -50 to 50 ) | PickOne | `0` | `0` | `Black` real -50–50 | +| `RICyan` | Cyan ( -50 to 50 ) | PickOne | `0` | `0` | `Cyan` real -50–50 | +| `RIMagenta` | Magenta ( -50 to 50 ) | PickOne | `0` | `0` | `Magenta` real -50–50 | +| `RIYellow` | Yellow ( -50 to 50 ) | PickOne | `0` | `0` | `Yellow` real -50–50 | + +### User Authentication + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIEnableUserAuth` | User Authentication | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIAuthLoginUserNameType` | Login User Name | PickOne | `DefinedUserID` | `DefinedUserID` (Defined User ID), `LoginUserName` (Mac Login Name) | | +| `RIAuthLoginUserNameText` | Enter Login User Name | PickOne | `None` | `None` | `AuthLoginUserNameText` string 0–128 | +| `RIAuthLoginPassword` | Login Password | PickOne | `None` | `None` | `AuthLoginPassword` password 0–128 | +| `RIAuthEnableEncryption` | Driver Encryption Key | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIAuthEncryptionKey` | Driver Encryption Key | PickOne | `None` | `None` | `AuthEncryptionKey` password 0–32 | + +## RICOH MP C307 PS + +### Installable Options + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `OptionTray` | Option Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `1Cassette` (Tray 2), `2Cassette` (Tray 2 and 3) | | +| `InnerTray2` | Upper Internal Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `RIPostScript` | PostScript | PickOne | `IRIPS` | `IRIPS` (PostScript Emulation), `Adobe` (Adobe PostScript) | | + +### General + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `PageSize` | PageSize | PickOne | `A4` | `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGerman` (8.5 x 12), `FanFoldGermanLegal` (8.5 x 13), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4), `A4.FullBleed` (A4 (210 x 297 mm) (Full Bleed)), `A5.FullBleed` (A5 (148 x 210 mm) (Full Bleed)), `A6.FullBleed` (A6 (105 x 148 mm) (Full Bleed)), `B5.FullBleed` (B5 (JIS) (182 x 257 mm) (Full Bleed)), `B6.FullBleed` (B6 (JIS) (128 x 182 mm) (Full Bleed)), `Legal.FullBleed` (Legal (8.5 x 14) (Full Bleed)), `GovernmentLG.FullBleed` (8.25 x 14 (Full Bleed)), `EngQuatro.FullBleed` (8 x 10 (Full Bleed)), `Letter.FullBleed` (Letter (8.5 x 11) (Full Bleed)), `HalfLetter.FullBleed` (5.5 x 8.5 (Full Bleed)), `F.FullBleed` (8 x 13 (Full Bleed)), `Folio.FullBleed` (8.25 x 13 (Full Bleed)), `FanFoldGerman.FullBleed` (8.5 x 12 (Full Bleed)), `FanFoldGermanLegal.FullBleed` (8.5 x 13 (Full Bleed)), `Executive.FullBleed` (Executive (7.25 x 10.5) (Full Bleed)), `Env10.FullBleed` (Com10 Env. (4.125 x 9.5) (Full Bleed)), `EnvMonarch.FullBleed` (Monarch Env. (3.875 x 7.5) (Full Bleed)), `EnvC5.FullBleed` (C5 Env. (162 x 229 mm) (Full Bleed)), `EnvC6.FullBleed` (C6 Env. (114 x 162 mm) (Full Bleed)), `DLEnv.FullBleed` (DL Env. (110 x 220 mm) (Full Bleed)), `16Kai.FullBleed` (16K (195 x 267 mm) (Full Bleed)), `Oficio.FullBleed` (8.5 x 13.4 (Full Bleed)) | `Width` points 216–613; `Height` points 394–1701; `WidthOffset` points 0–0; `HeightOffset` points 0–0; `Orientation` int 1–1 | +| `PageRegion` | PageRegion | PickOne | `A4` | `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGerman` (8.5 x 12), `FanFoldGermanLegal` (8.5 x 13), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4), `A4.FullBleed` (A4 (210 x 297 mm) (Full Bleed)), `A5.FullBleed` (A5 (148 x 210 mm) (Full Bleed)), `A6.FullBleed` (A6 (105 x 148 mm) (Full Bleed)), `B5.FullBleed` (B5 (JIS) (182 x 257 mm) (Full Bleed)), `B6.FullBleed` (B6 (JIS) (128 x 182 mm) (Full Bleed)), `Legal.FullBleed` (Legal (8.5 x 14) (Full Bleed)), `GovernmentLG.FullBleed` (8.25 x 14 (Full Bleed)), `EngQuatro.FullBleed` (8 x 10 (Full Bleed)), `Letter.FullBleed` (Letter (8.5 x 11) (Full Bleed)), `HalfLetter.FullBleed` (5.5 x 8.5 (Full Bleed)), `F.FullBleed` (8 x 13 (Full Bleed)), `Folio.FullBleed` (8.25 x 13 (Full Bleed)), `FanFoldGerman.FullBleed` (8.5 x 12 (Full Bleed)), `FanFoldGermanLegal.FullBleed` (8.5 x 13 (Full Bleed)), `Executive.FullBleed` (Executive (7.25 x 10.5) (Full Bleed)), `Env10.FullBleed` (Com10 Env. (4.125 x 9.5) (Full Bleed)), `EnvMonarch.FullBleed` (Monarch Env. (3.875 x 7.5) (Full Bleed)), `EnvC5.FullBleed` (C5 Env. (162 x 229 mm) (Full Bleed)), `EnvC6.FullBleed` (C6 Env. (114 x 162 mm) (Full Bleed)), `DLEnv.FullBleed` (DL Env. (110 x 220 mm) (Full Bleed)), `16Kai.FullBleed` (16K (195 x 267 mm) (Full Bleed)), `Oficio.FullBleed` (8.5 x 13.4 (Full Bleed)) | | +| `InputSlot` | InputSlot | PickOne | `1Tray` | `MultiTray` (Bypass Tray), `1Tray` (Tray 1), `2Tray` (Tray 2), `3Tray` (Tray 3) | | +| `Duplex` | Duplex | PickOne | `DuplexNoTumble` | `None` (Off), `DuplexNoTumble` (Long Edge), `DuplexTumble` (Short Edge) | | +| `Collate` | Collate | PickOne | `False` | `False` (Off), `True` (On) | | + +### Basic + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIPaperPolicy` | Fit to Paper | PickOne | `PromptUser` | `PromptUser` (Prompt User), `NearestSizeAdjust` (Nearest Size and Scale), `NearestSizeNoAdjust` (Nearest Size and Crop) | | +| `ColorModel` | Color Mode | PickOne | `CMYK` | `CMYK` (Color), `Gray` (Black and White) | | +| `RIRotateBy180` | Rotate by 180 degrees | PickOne | `Off` | `Off`, `On` | | + +### Paper + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `MediaType` | Paper Type | PickOne | `Auto` | `Auto` (Plain/Recycled), `Plain1` (Plain 1 (60 - 74 g/m2)), `Plain2` (Plain 2 (75 - 81 g/m2)), `Recycled`, `Special1` (Special 1), `Special2` (Special 2), `Special3` (Special 3), `Colored` (Color), `Letterhead`, `Preprinted`, `Labels`, `Coated`, `Bond`, `Cardstock`, `OHP` (Transparency), `Thick1` (Thick 1 (106 - 130 g/m2)), `Thick2` (Thick 2 (131 - 163 g/m2)), `Thick3` (Thick 3 (164 - 220 g/m2)), `Middlethick` (Middle Thick (82 - 105 g/m2)), `Envelope`, `WaterProof` (Waterproof), `None` | | +| `OutputBin` | Destination | PickOne | `Default` | `Default` (Printer Default), `Standard` (Internal Tray 1), `Bin1` (Internal Tray 2) | | +| `RIBannerPagePrint` | Banner Page | PickOne | `False` | `False` (Off), `True` (On) | | +| `RIBannerPageInputSlot` | Banner Page Input Tray | PickOne | `Auto` | `Auto` (Auto Tray Select), `MultiTray` (Bypass Tray), `1Tray` (Tray 1), `2Tray` (Tray 2), `3Tray` (Tray 3) | | +| `RIBannerPageMediaType` | Banner Page Paper Type | PickOne | `Auto` | `Auto` (Plain/Recycled), `Plain1` (Plain 1 (60 - 74 g/m2)), `Plain2` (Plain 2 (75 - 81 g/m2)), `Recycled`, `Special1` (Special 1), `Special2` (Special 2), `Special3` (Special 3), `Colored` (Color), `Letterhead`, `Preprinted`, `Labels`, `Coated`, `Bond`, `Cardstock`, `OHP` (Transparency), `Thick1` (Thick 1 (106 - 130 g/m2)), `Thick2` (Thick 2 (131 - 163 g/m2)), `Thick3` (Thick 3 (164 - 220 g/m2)), `Middlethick` (Middle Thick (82 - 105 g/m2)), `Envelope`, `WaterProof` (Waterproof) | | + +### Finishing + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIOrientOvr` | Orientation Override | PickOne | `Off` | `Off`, `Landscape`, `Portrait` | | +| `Booklet` | Booklet | PickOne | `None` | `None` (Off), `OpenToLeft` (Open to Left/Top), `OpenToRight` (Open to Right/Bottom) | | + +### Print Quality + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `Resolution` | Resolution | PickOne | `600dpi` | `600dpi` (600 dpi), `1200dpi` (1200 dpi) | | +| `RPSBitsPerPixel` | Gradation | PickOne | `2BitsPerPixel` | `2BitsPerPixel` (Standard), `1BitsPerPixel` (Fast), `4BitsPerPixel` (Fine) | | +| `RIPrintMode` | Print Mode | PickOne | `0rhit` | `0rhit` (Off), `3rhit` (Toner Saving) | | +| `Rimagesm` | Image Smoothing | PickOne | `Off` | `Off`, `On`, `Auto`, `90ppi` (Less than 90 ppi), `150ppi` (Less than 150 ppi), `200ppi` (Less than 200 ppi), `300ppi` (Less than 300 ppi) | | +| `RPSDitherType` | Dithering | PickOne | `Auto` | `Auto`, `Photo` (Photographic), `Letter` (Text), `User` (User Setting), `Dispersion` (Reduce Missing Colors and Blurring) | | +| `RPSRGBcorrect` | Color Setting | PickOne | `DetailBright` | `None` (Off), `DetailNormal` (Fine), `DetailBright` (Super Fine) | | +| `RPSColorRendDict` | Color Profile | PickOne | `Auto` | `Auto`, `Photograph` (Photographic), `Business` (Presentation), `Colorimetric` (Solid Color), `POP` (POP Display), `User` (User Setting), `Clpsimulation1` (Soft), `Clpsimulation2` (Sharp), `Clpsimulation4` (Vivid), `Clpsimulation7` (Deep), `Clpsimulation` (CLP Simulation) | | +| `Rcmyksimulation` | CMYK Simulation Profile | PickOne | `Off` | `Off`, `USOffsetPrint` (US OffsetPrint), `Euroscale`, `JapanColor` (JapanColor2001), `PANTONE` (PaletteColor), `Trans01` (Make Bluer) | | +| `RPSBlackMode` | Gray Reproduction | PickOne | `gray` | `gray` (Black/Gray by K (Text/Line Art)), `1Color` (Black by K), `4Color` (CMY+K), `grayText` (Black/Gray by K (Text only)), `1ColorText` (Black by K (Text only)), `grayAll` (Black/Gray by K (Strong UCR)) | | +| `RPSBlackOverPrint` | Black Over Print | PickOne | `False` | `False` (Off), `True` (On) | | +| `RPSColorSep` | Separate into CMYK | PickOne | `None` | `None` (Do not Separate), `Cyan`, `Magenta`, `Yellow`, `Black`, `Red` (Magenta and Yellow), `Green` (Cyan and Yellow), `Blue` (Cyan and Magenta), `KCyan` (Black and Cyan), `KMagenta` (Black and Magenta), `KYellow` (Black and Yellow) | | + +### Effects + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIWatermark` | Watermark | PickOne | `Off` | `Off`, `On` | | +| `RIWMText` | Watermark Text | PickOne | `Confidential` | `Confidential` (CONFIDENTIAL), `Copy` (COPY), `Copyright` (DRAFT), `Final` (FINAL), `FileCopy` (FILE COPY), `Proof` (PROOF), `TopSecret` (TOP SECRET) | | +| `RIwmFont` | Watermark Font | PickOne | `Default` | `Default` (Printer Default), `HelveticaB` (Helvetica Bold), `CourierB` (Courier Bold), `TimesB` (Times Bold), `NimbusSansB` (NimbusSans Bold), `NimbusMonoPSB` (NimbusMonoPS Bold), `NimbusRomanB` (NimbusRoman Bold) | | +| `RIwmAngle` | Watermark Angle | PickOne | `45Deg` | `180Deg` (180 Degrees), `135Deg` (135 Degrees), `90Deg` (90 Degrees), `45Deg` (45 Degrees), `0Deg` (0 Degrees), `M45Deg` (-45 Degrees), `M90Deg` (-90 Degrees), `M135Deg` (-135 Degrees), `M180Deg` (-180 Degrees) | | +| `RIwmSize` | Watermark Size | PickOne | `36` | `24` (24 Point), `36` (36 Point), `48` (48 Point), `60` (60 Point), `72` (72 Point) | | +| `RIwmTextStyle` | Watermark Style | PickOne | `Gray` | `Gray`, `Outline` (Outlined) | | + +### Job Log + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIUserId` | User ID | PickOne | `None` | `None` | `UserId` string 0–8 | +| `RIJobType` | Job Type | PickOne | `Normal` | `Normal` (Normal Print), `SamplePrint` (Sample Print), `LockedPrint` (Locked Print), `HoldPrint` (Hold Print), `StoredPrint` (Stored Print), `StoreandPrint` (Store and Print), `DocServer` (Document Server) | | +| `RIFileName` | File Name | PickOne | `None` | `None` | `FileName` string 0–16 | +| `RIPassword` | Password | PickOne | `None` | `None` | `Password` passcode 4–8 | +| `RIEnableUserCode` | Enable User Code | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIUserCode` | User Code | PickOne | `None` | `None` | `UserCode` string 0–8 | +| `RIEnableSpecifyTime` | Set Print Time | Boolean | `False` | `False` (Off), `True` (On) | | +| `RITimeHour` | Hour (0 to 23) | PickOne | `0` | `0` | `TimeHour` int 0–23 | +| `RITimeMin` | Minute (0 to 59) | PickOne | `0` | `0` | `TimeMin` int 0–59 | +| `RIFolderNumber` | Folder Number | PickOne | `0` | `0` | `FolderNumber` int 0–200 | +| `RIFolderPassword` | Folder Password | PickOne | `None` | `None` | `FolderPassword` passcode 4–8 | + +### Unauthorized Copy Prevention + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIPreventionType` | Prevention Type | PickOne | `Off` | `Off`, `PreventionCopyPattern` (Unauthorized Copy Prevention for Pattern), `CopyGuard` (Data Security for Copying) | | +| `RITypeText` | Text Type | PickOne | `UserText` | `UserText` (User Text), `LoginUserName` (Login User Name), `JobName` (Job Name), `JobTime` (Date & Time), `LoginUserNameJobName` (Login User Name + Job Name), `LoginUserNameJobTime` (Login User Name + Date & Time), `JobNameTime` (Job Name + Date & Time), `LoginUserNameJobNameTime` (Login User Name + Job Name + Date & Time) | | +| `RIText` | Enter User Text | PickOne | `Copy` | `Copy` (COPY) | `Text` string 0–64 | +| `RIFont` | Font | PickOne | `Default` | `Default` (Printer Default), `NimbusSansBold` (NimbusSans-Bold), `NimbusMonoPSBold` (NimbusMonoPS-Bold), `NimbusRomanBold` (NimbusRoman-Bold), `AlbertusMT`, `AlbertusMTItalic` (AlbertusMT-Italic), `AlbertusMTLight` (AlbertusMT-Light), `AntiqueOliveBold` (AntiqueOlive-Bold), `AntiqueOliveCompact` (AntiqueOlive-Compact), `AntiqueOliveItalic` (AntiqueOlive-Italic), `AntiqueOliveRoman` (AntiqueOlive-Roman), `AppleChancery` (Apple-Chancery), `ArialMT`, `ArialBoldMT` (Arial-BoldMT), `ArialBoldItalicMT` (Arial-BoldItalicMT), `ArialItalicMT` (Arial-ItalicMT), `AvantGardeBook` (AvantGarde-Book), `AvantGardeBookOblique` (AvantGarde-BookOblique), `AvantGardeDemi` (AvantGarde-Demi), `AvantGardeDemiOblique` (AvantGarde-DemiOblique), `Bodoni`, `BodoniBold` (Bodoni-Bold), `BodoniBoldItalic` (Bodoni-BoldItalic), `BodoniItalic` (Bodoni-Italic), `BodoniPoster` (Bodoni-Poster), `BodoniPosterCompressed` (Bodoni-PosterCompressed), `BookmanDemi` (Bookman-Demi), `BookmanDemiItalic` (Bookman-DemiItalic), `BookmanLight` (Bookman-Light), `BookmanLightItalic` (Bookman-LightItalic), `Carta`, `Chicago`, `ClarendonBold` (Clarendon-Bold), `ClarendonLight` (Clarendon-Light), `Clarendon`, `CooperBlackItalic` (CooperBlack-Italic), `CooperBlack`, `CopperplateThirtyThreeBC` (Copperplate-ThirtyThreeBC), `CopperplateThirtyTwoBC` (Copperplate-ThirtyTwoBC), `CoronetRegular` (Coronet-Regular), `CourierBold` (Courier-Bold), `CourierBoldOblique` (Courier-BoldOblique), `CourierOblique` (Courier-Oblique), `Courier`, `EurostileBold` (Eurostile-Bold), `EurostileBoldExtendedTwo` (Eurostile-BoldExtendedTwo), `EurostileExtendedTwo` (Eurostile-ExtendedTwo), `Eurostile`, `Geneva`, `GillSans`, `GillSansBold` (GillSans-Bold), `GillSansBoldCondensed` (GillSans-BoldCondensed), `GillSansBoldItalic` (GillSans-BoldItalic), `GillSansCondensed` (GillSans-Condensed), `GillSansExtraBold` (GillSans-ExtraBold), `GillSansItalic` (GillSans-Italic), `GillSansLight` (GillSans-Light), `GillSansLightItalic` (GillSans-LightItalic), `Goudy`, `GoudyBold` (Goudy-Bold), `GoudyBoldItalic` (Goudy-BoldItalic), `GoudyExtraBold` (Goudy-ExtraBold), `GoudyItalic` (Goudy-Italic), `Helvetica`, `HelveticaBold` (Helvetica-Bold), `HelveticaBoldOblique` (Helvetica-BoldOblique), `HelveticaCondensedBold` (Helvetica-Condensed-Bold), `HelveticaCondensedBoldObl` (Helvetica-Condensed-BoldObl), `HelveticaCondensedOblique` (Helvetica-Condensed-Oblique), `HelveticaCondensed` (Helvetica-Condensed), `HelveticaNarrowBold` (Helvetica-Narrow-Bold), `HelveticaNarrowBoldOblique` (Helvetica-Narrow-BoldOblique), `HelveticaNarrowOblique` (Helvetica-Narrow-Oblique), `HelveticaNarrow` (Helvetica-Narrow), `HelveticaOblique` (Helvetica-Oblique), `HoeflerTextBlack` (HoeflerText-Black), `HoeflerTextBlackItalic` (HoeflerText-BlackItalic), `HoeflerTextItalic` (HoeflerText-Italic), `HoeflerTextOrnaments` (HoeflerText-Ornaments), `HoeflerTextRegular` (HoeflerText-Regular), `JoannaMT`, `JoannaMTBold` (JoannaMT-Bold), `JoannaMTBoldItalic` (JoannaMT-BoldItalic), `JoannaMTItalic` (JoannaMT-Italic), `LetterGothic`, `LetterGothicBold` (LetterGothic-Bold), `LetterGothicBoldSlanted` (LetterGothic-BoldSlanted), `LetterGothicSlanted` (LetterGothic-Slanted), `LubalinGraphBook` (LubalinGraph-Book), `LubalinGraphBookOblique` (LubalinGraph-BookOblique), `LubalinGraphDemi` (LubalinGraph-Demi), `LubalinGraphDemiOblique` (LubalinGraph-DemiOblique), `Marigold`, `MonaLisaRecut` (MonaLisa-Recut), `Monaco`, `NewCenturySchlbkBold` (NewCenturySchlbk-Bold), `NewCenturySchlbkBoldItalic` (NewCenturySchlbk-BoldItalic), `NewCenturySchlbkItalic` (NewCenturySchlbk-Italic), `NewCenturySchlbkRoman` (NewCenturySchlbk-Roman), `NewYork`, `OptimaBold` (Optima-Bold), `OptimaBoldItalic` (Optima-BoldItalic), `OptimaItalic` (Optima-Italic), `Optima`, `Oxford`, `PalatinoBold` (Palatino-Bold), `PalatinoBoldItalic` (Palatino-BoldItalic), `PalatinoItalic` (Palatino-Italic), `PalatinoRoman` (Palatino-Roman), `StempelGaramondBold` (StempelGaramond-Bold), `StempelGaramondBoldItalic` (StempelGaramond-BoldItalic), `StempelGaramondItalic` (StempelGaramond-Italic), `StempelGaramondRoman` (StempelGaramond-Roman), `Symbol`, `Tekton`, `TimesBold` (Times-Bold), `TimesBoldItalic` (Times-BoldItalic), `TimesItalic` (Times-Italic), `TimesRoman` (Times-Roman), `TimesNewRomanPSBoldItalicMT` (TimesNewRomanPS-BoldItalicMT), `TimesNewRomanPSBoldMT` (TimesNewRomanPS-BoldMT), `TimesNewRomanPSItalicMT` (TimesNewRomanPS-ItalicMT), `TimesNewRomanPSMT`, `Univers`, `UniversBold` (Univers-Bold), `UniversBoldExt` (Univers-BoldExt), `UniversBoldExtObl` (Univers-BoldExtObl), `UniversBoldOblique` (Univers-BoldOblique), `UniversCondensed` (Univers-Condensed), `UniversCondensedBold` (Univers-CondensedBold), `UniversCondensedBoldOblique` (Univers-CondensedBoldOblique), `UniversCondensedOblique` (Univers-CondensedOblique), `UniversExtended` (Univers-Extended), `UniversExtendedObl` (Univers-ExtendedObl), `UniversLight` (Univers-Light), `UniversLightOblique` (Univers-LightOblique), `UniversOblique` (Univers-Oblique), `WingdingsRegular` (Wingdings-Regular), `ZapfChanceryMediumItalic` (ZapfChancery-MediumItalic), `ZapfDingbats` | | +| `RISize` | Size | PickOne | `70` | `70` | `Size` int 50–300 | +| `RIAngel` | Angle | PickOne | `30` | `30` | `Angel` int 0–359 | +| `RIEffects` | Text/Pattern Effects | PickOne | `Normal` | `Normal` (Text and Background), `ReversePatterns` (Reverse Patterns (Text/Background)), `BackgroundOnly` (Background Only), `TextOnly` (Text Only) | | +| `RIRepeat` | Repeat | PickOne | `Off` | `Off`, `Repeat` (On), `RepeatandRotateCarriageReturn` (On (Rotate 180 Degrees at Carriage Return)) | | +| `RILineSpace` | Line Space | PickOne | `70` | `70` | `LineSpace` int 50–300 | +| `RIPosition` | Position | PickOne | `Center` | `Center`, `TopLeft` (Top Left), `TopCenter` (Top Center), `TopRight` (Top Right), `BottomLeft` (Bottom Left), `BottomCenter` (Bottom Center), `BottomRight` (Bottom Right) | | +| `RIColor` | Color | PickOne | `Black` | `Black`, `Cyan`, `Magenta` | | +| `RIDensity` | Density | PickOne | `Medium` | `VeryLight` (Very Light), `Light`, `Medium`, `Dark`, `VeryDark` (Very Dark) | | +| `RIMaskType` | Mask Type | PickOne | `None` | `None`, `WaveCrest` (Type 1), `Mesh` (Type 2), `Lattice1` (Type 3), `Lattice2` (Type 4), `InterlockingCircles` (Type 5), `Shokkoh` (Type 6), `Matsukawabishi` (Type 7), `Scale` (Type 8), `Higaki` (Type 9), `Hexagonal` (Type 10) | | + +### Color Balance Details + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIBrightness` | Brightness ( -50 to 50 ) | PickOne | `0` | `0` | `Brightness` real -50–50 | +| `RIContrast` | Contrast ( -50 to 50 ) | PickOne | `0` | `0` | `Contrast` real -50–50 | +| `RIBlack` | Black ( -50 to 50 ) | PickOne | `0` | `0` | `Black` real -50–50 | +| `RICyan` | Cyan ( -50 to 50 ) | PickOne | `0` | `0` | `Cyan` real -50–50 | +| `RIMagenta` | Magenta ( -50 to 50 ) | PickOne | `0` | `0` | `Magenta` real -50–50 | +| `RIYellow` | Yellow ( -50 to 50 ) | PickOne | `0` | `0` | `Yellow` real -50–50 | + +### Background Numbering + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIBackgroundNumbering` | Background Numbering | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIBNSize` | Size | PickOne | `Normal` | `Small`, `Normal`, `Large` | | +| `RIBNDensity` | Density | PickOne | `Normal` | `Light`, `Normal`, `Dark`, `VeryDark` (Very Dark) | | +| `RIBNColor` | Color | PickOne | `Black` | `Yellow`, `Red`, `Cyan`, `Magenta`, `Green`, `Blue`, `Black` | | +| `RIStartNumber` | Start Number (1 to 9999) | PickOne | `1` | `1` | `StartNumber` int 1–9999 | + +### User Authentication + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIEnableUserAuth` | User Authentication | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIAuthLoginUserNameType` | Login User Name | PickOne | `DefinedUserID` | `DefinedUserID` (Defined User ID), `LoginUserName` (Mac Login Name) | | +| `RIAuthLoginUserNameText` | Enter Login User Name | PickOne | `None` | `None` | `AuthLoginUserNameText` string 0–128 | +| `RIAuthLoginPassword` | Login Password | PickOne | `None` | `None` | `AuthLoginPassword` password 0–128 | +| `RIAuthEnableEncryption` | Driver Encryption Key | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIAuthEncryptionKey` | Driver Encryption Key | PickOne | `None` | `None` | `AuthEncryptionKey` password 0–32 | + +## RICOH MP C3504 PS + +### Installable Options + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `OptionTray` | Option Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `1Cassette` (Lower Paper Tray), `LCT` (Tray 3 (LCT)), `2Cassette` (Lower Paper Trays) | | +| `LargeCapacityTray` | Large Capacity Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `InnerTray2` | Internal Tray 2 | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `ShiftTray` | Internal Shift Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `ExternalTray` | External Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `Finisher` | Finisher | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `FinRUBICONB` (Finisher SR3130), `FinAMURBBK` (Finisher SR3220), `FinAMURHY` (Finisher SR3210), `FinUYUNI` (Finisher SR3180) | | + +### General + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `PageSize` | PageSize | PickOne | `A4` | `A3` (A3 (297 x 420 mm)), `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B4` (B4 JIS (257 x 364 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGermanLegal` (8.5 x 13), `11x17` (11 x 17), `12x18` (12 x 18), `11x15` (11 x 15), `10x14` (10 x 14), `SRA3` (SRA3 (320 x 450 mm)), `SRA4` (SRA4 (225 x 320 mm)), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `8Kai` (8K (267 x 390 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4), `A3.FullBleed` (A3 (297 x 420 mm) (Full bleed)), `A4.FullBleed` (A4 (210 x 297 mm) (Full Bleed)), `A5.FullBleed` (A5 (148 x 210 mm) (Full Bleed)), `A6.FullBleed` (A6 (105 x 148 mm) (Full Bleed)), `B4.FullBleed` (B4 (JIS) (257 x 364 mm) (Full Bleed)), `B5.FullBleed` (B5 (JIS) (182 x 257 mm) (Full Bleed)), `B6.FullBleed` (B6 (JIS) (128 x 182 mm) (Full Bleed)), `Legal.FullBleed` (Legal (8.5 x 14) (Full Bleed)), `GovernmentLG.FullBleed` (8.25 x 14 (Full Bleed)), `EngQuatro.FullBleed` (8 x 10 (Full Bleed)), `Letter.FullBleed` (Letter (8.5 x 11) (Full Bleed)), `HalfLetter.FullBleed` (5.5 x 8.5 (Full Bleed)), `F.FullBleed` (8 x 13 (Full Bleed)), `Folio.FullBleed` (8.25 x 13 (Full Bleed)), `FanFoldGermanLegal.FullBleed` (8.5 x 13 (Full Bleed)), `11x17.FullBleed` (11 x 17 (Full Bleed)), `12x18.FullBleed` (12 x 18 (Full Bleed)), `11x15.FullBleed` (11 x 15 (Full Bleed)), `10x14.FullBleed` (10 x 14 (Full Bleed)), `SRA3.FullBleed` (SRA3 (320 x 450 mm) (Full Bleed)), `SRA4.FullBleed` (SRA4 (225 x 320 mm) (Full Bleed)), `Executive.FullBleed` (Executive (7.25 x 10.5) (Full Bleed)), `Env10.FullBleed` (Com10 Env. (4.125 x 9.5) (Full Bleed)), `EnvMonarch.FullBleed` (Monarch Env. (3.875 x 7.5) (Full Bleed)), `EnvC5.FullBleed` (C5 Env. (162 x 229 mm) (Full Bleed)), `EnvC6.FullBleed` (C6 Env. (114 x 162 mm) (Full Bleed)), `DLEnv.FullBleed` (DL Env. (110 x 220 mm) (Full Bleed)), `8Kai.FullBleed` (8K (267 x 390 mm) (Full Bleed)), `16Kai.FullBleed` (16K (195 x 267 mm) (Full Bleed)), `Oficio.FullBleed` (8.5 x 13.4 (Full Bleed)) | `Width` points 255–908; `Height` points 419–3572; `WidthOffset` points 0–0; `HeightOffset` points 0–0; `Orientation` int 1–1 | +| `PageRegion` | PageRegion | PickOne | `A4` | `A3` (A3 (297 x 420 mm)), `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B4` (B4 JIS (257 x 364 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGermanLegal` (8.5 x 13), `11x17` (11 x 17), `12x18` (12 x 18), `11x15` (11 x 15), `10x14` (10 x 14), `SRA3` (SRA3 (320 x 450 mm)), `SRA4` (SRA4 (225 x 320 mm)), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `8Kai` (8K (267 x 390 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4), `A3.FullBleed` (A3 (297 x 420 mm) (Full bleed)), `A4.FullBleed` (A4 (210 x 297 mm) (Full Bleed)), `A5.FullBleed` (A5 (148 x 210 mm) (Full Bleed)), `A6.FullBleed` (A6 (105 x 148 mm) (Full Bleed)), `B4.FullBleed` (B4 (JIS) (257 x 364 mm) (Full Bleed)), `B5.FullBleed` (B5 (JIS) (182 x 257 mm) (Full Bleed)), `B6.FullBleed` (B6 (JIS) (128 x 182 mm) (Full Bleed)), `Legal.FullBleed` (Legal (8.5 x 14) (Full Bleed)), `GovernmentLG.FullBleed` (8.25 x 14 (Full Bleed)), `EngQuatro.FullBleed` (8 x 10 (Full Bleed)), `Letter.FullBleed` (Letter (8.5 x 11) (Full Bleed)), `HalfLetter.FullBleed` (5.5 x 8.5 (Full Bleed)), `F.FullBleed` (8 x 13 (Full Bleed)), `Folio.FullBleed` (8.25 x 13 (Full Bleed)), `FanFoldGermanLegal.FullBleed` (8.5 x 13 (Full Bleed)), `11x17.FullBleed` (11 x 17 (Full Bleed)), `12x18.FullBleed` (12 x 18 (Full Bleed)), `11x15.FullBleed` (11 x 15 (Full Bleed)), `10x14.FullBleed` (10 x 14 (Full Bleed)), `SRA3.FullBleed` (SRA3 (320 x 450 mm) (Full Bleed)), `SRA4.FullBleed` (SRA4 (225 x 320 mm) (Full Bleed)), `Executive.FullBleed` (Executive (7.25 x 10.5) (Full Bleed)), `Env10.FullBleed` (Com10 Env. (4.125 x 9.5) (Full Bleed)), `EnvMonarch.FullBleed` (Monarch Env. (3.875 x 7.5) (Full Bleed)), `EnvC5.FullBleed` (C5 Env. (162 x 229 mm) (Full Bleed)), `EnvC6.FullBleed` (C6 Env. (114 x 162 mm) (Full Bleed)), `DLEnv.FullBleed` (DL Env. (110 x 220 mm) (Full Bleed)), `8Kai.FullBleed` (8K (267 x 390 mm) (Full Bleed)), `16Kai.FullBleed` (16K (195 x 267 mm) (Full Bleed)), `Oficio.FullBleed` (8.5 x 13.4 (Full Bleed)) | | +| `InputSlot` | InputSlot | PickOne | `1Tray` | `MultiTray` (Bypass Tray), `1Tray` (Tray 1), `2Tray` (Tray 2), `3Tray` (Tray 3), `4Tray` (Tray 4), `5Tray` (Large Capacity Tray) | | +| `Duplex` | Duplex | PickOne | `DuplexNoTumble` | `None` (Off), `DuplexNoTumble` (Long Edge), `DuplexTumble` (Short Edge) | | +| `Collate` | Collate | PickOne | `False` | `False` (Off), `True` (On) | | + +### Basic + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIPaperPolicy` | Fit to Paper | PickOne | `PromptUser` | `PromptUser` (Prompt User), `NearestSizeAdjust` (Nearest Size and Scale), `NearestSizeNoAdjust` (Nearest Size and Crop) | | +| `ColorModel` | Color Mode | PickOne | `CMYK` | `CMYK` (Color), `Gray` (Black and White) | | +| `RIRotateBy180` | Rotate by 180 degrees | PickOne | `Off` | `Off`, `On` | | + +### Paper + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `MediaType` | Paper Type | PickOne | `Auto` | `Auto` (Plain/Recycled), `Plain1` (Plain 1 (60 - 74 g/m2)), `Plain2` (Plain 2 (75 - 81 g/m2)), `Recycled`, `Special1` (Special 1), `Special2` (Special 2), `Special3` (Special 3), `Colored` (Color), `Letterhead`, `Preprinted`, `Labels`, `Bond`, `Cardstock`, `OHP` (Transparency), `Thick1` (Thick 1 (106 - 169 g/m2)), `Thick2` (Thick 2 (170 - 220 g/m2)), `Thick3` (Thick 3 (221 - 256 g/m2)), `Thick4` (Thick 4 (257 - 300 g/m2)), `Thin` (Thin (52 - 59 g/m2)), `Middlethick` (Middle Thick (82 - 105 g/m2)), `GlossCoated` (Coated (Glossy)), `MatCoated` (Coated (Matte)), `Envelope`, `None` | | +| `OutputBin` | Destination | PickOne | `Default` | `Default` (Printer Default), `Standard` (Internal Tray 1), `Bin1` (Internal Tray 2), `Shift` (Internal Shift Tray), `External` (External Tray), `FinRUBICONBShift` (Finisher SR3130 Shift Tray), `FinAMURBBKUpper` (Finisher SR3220 Upper Tray), `FinAMURBBKShift` (Finisher SR3220 Shift Tray), `FinAMURBBKLower` (Finisher SR3220 Booklet Tray), `FinAMURHYUpper` (Finisher SR3210 Upper Tray), `FinAMURHYShift` (Finisher SR3210 Shift Tray), `FinUYUNIShift` (Finisher SR3180 Shift Tray) | | +| `RIBannerPagePrint` | Banner Page | PickOne | `False` | `False` (Off), `True` (On) | | +| `RIBannerPageInputSlot` | Banner Page Input Tray | PickOne | `Auto` | `Auto` (Auto Tray Select), `MultiTray` (Bypass Tray), `1Tray` (Tray 1), `2Tray` (Tray 2), `3Tray` (Tray 3), `4Tray` (Tray 4), `5Tray` (Large Capacity Tray) | | +| `RIBannerPageMediaType` | Banner Page Paper Type | PickOne | `Auto` | `Auto` (Plain/Recycled), `Plain1` (Plain 1 (60 - 74 g/m2)), `Plain2` (Plain 2 (75 - 81 g/m2)), `Recycled`, `Special1` (Special 1), `Special2` (Special 2), `Special3` (Special 3), `Colored` (Color), `Letterhead`, `Preprinted`, `Labels`, `Bond`, `Cardstock`, `OHP` (Transparency), `Thick1` (Thick 1 (106 - 169 g/m2)), `Thick2` (Thick 2 (170 - 220 g/m2)), `Thick3` (Thick 3 (221 - 256 g/m2)), `Thick4` (Thick 4 (257 - 300 g/m2)), `Thin` (Thin (52 - 59 g/m2)), `Middlethick` (Middle Thick (82 - 105 g/m2)), `GlossCoated` (Coated (Glossy)), `MatCoated` (Coated (Matte)), `Envelope` | | + +### Finishing + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIOrientOvr` | Orientation Override | PickOne | `Off` | `Off`, `Landscape`, `Portrait` | | +| `RICollateKind` | Collate Type | PickOne | `Normal` | `Normal` (Collate), `RotateCollate` (Rotating Collate), `ShiftCollate` (Shift Collate) | | +| `StapleLocation` | Staple | PickOne | `None` | `None` (Off), `StaplessUpperLeft` (Top left (stapleless)), `StaplessUpperRight` (Top right (stapleless)), `UpperLeft` (Top left), `UpperRight` (Top right), `LeftW` (2 at left), `RightW` (2 at right), `UpperW` (2 at top), `CenterW` (2 at center) | | +| `RIPunch` | Punch | PickOne | `None` | `None` (Off), `Left2` (2 at left), `Left3` (3 at left), `Left4` (4 at left), `Right2` (2 at right), `Right3` (3 at right), `Right4` (4 at right), `Upper2` (2 at top), `Upper3` (3 at top), `Upper4` (4 at top) | | +| `RIFoldType` | Fold Type | PickOne | `None` | `None` (Off), `OutsideTwofold` (Half Fold - Print Outside (Finisher Booklet Tray)) | | +| `Booklet` | Booklet | PickOne | `None` | `None` (Off), `OpenToLeft` (Open to Left/Top), `OpenToRight` (Open to Right/Bottom) | | + +### Print Quality + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `Resolution` | Resolution | PickOne | `600dpi` | `600dpi` (600 dpi), `1200dpi` (1200 dpi) | | +| `RPSBitsPerPixel` | Gradation | PickOne | `2BitsPerPixel` | `2BitsPerPixel` (Standard), `1BitsPerPixel` (Fast), `4BitsPerPixel` (Fine) | | +| `RIPrintMode` | Print Mode | PickOne | `0rhit` | `0rhit` (Off), `3rhit` (Toner Saving) | | +| `Rimagesm` | Image Smoothing | PickOne | `Off` | `Off`, `On`, `Auto`, `90ppi` (Less than 90 ppi), `150ppi` (Less than 150 ppi), `200ppi` (Less than 200 ppi), `300ppi` (Less than 300 ppi) | | +| `RPSDitherType` | Dithering | PickOne | `Auto` | `Auto`, `Photo` (Photographic), `Letter` (Text), `User` (User Setting), `Dispersion` (Reduce Missing Colors and Blurring) | | +| `RPSRGBcorrect` | Color Setting | PickOne | `DetailBright` | `None` (Off), `DetailNormal` (Fine), `DetailBright` (Super Fine) | | +| `RPSColorRendDict` | Color Profile | PickOne | `Auto` | `Auto`, `Photograph` (Photographic), `Business` (Presentation), `Colorimetric` (Solid Color), `POP` (POP Display), `User` (User Setting), `Clpsimulation1` (Soft), `Clpsimulation2` (Sharp), `Clpsimulation4` (Vivid), `Clpsimulation` (CLP Simulation) | | +| `Rcmyksimulation` | CMYK Simulation Profile | PickOne | `Off` | `Off`, `USOffsetPrint` (US OffsetPrint), `Euroscale`, `JapanColor` (JapanColor2001), `PANTONE` (PaletteColor), `Trans01` (Make Bluer) | | +| `RPSBlackMode` | Gray Reproduction | PickOne | `gray` | `gray` (Black/Gray by K (Text/Line Art)), `1Color` (Black by K), `4Color` (CMY+K), `grayText` (Black/Gray by K (Text only)), `1ColorText` (Black by K (Text only)), `grayAll` (Black/Gray by K (Strong UCR)) | | +| `RPSBlackOverPrint` | Black Over Print | PickOne | `False` | `False` (Off), `True` (On) | | +| `RPSColorSep` | Separate into CMYK | PickOne | `None` | `None` (Do not Separate), `Cyan`, `Magenta`, `Yellow`, `Black`, `Red` (Magenta and Yellow), `Green` (Cyan and Yellow), `Blue` (Cyan and Magenta), `KCyan` (Black and Cyan), `KMagenta` (Black and Magenta), `KYellow` (Black and Yellow) | | + +### Effects + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIWatermark` | Watermark | PickOne | `Off` | `Off`, `On` | | +| `RIWMText` | Watermark Text | PickOne | `Confidential` | `Confidential` (CONFIDENTIAL), `Copy` (COPY), `Copyright` (DRAFT), `Final` (FINAL), `FileCopy` (FILE COPY), `Proof` (PROOF), `TopSecret` (TOP SECRET) | | +| `RIwmFont` | Watermark Font | PickOne | `HelveticaB` | `CourierB` (Courier Bold), `TimesB` (Times Bold), `HelveticaB` (Helvetica Bold) | | +| `RIwmAngle` | Watermark Angle | PickOne | `45Deg` | `180Deg` (180 Degrees), `135Deg` (135 Degrees), `90Deg` (90 Degrees), `45Deg` (45 Degrees), `0Deg` (0 Degrees), `M45Deg` (-45 Degrees), `M90Deg` (-90 Degrees), `M135Deg` (-135 Degrees), `M180Deg` (-180 Degrees) | | +| `RIwmSize` | Watermark Size | PickOne | `36` | `24` (24 Point), `36` (36 Point), `48` (48 Point), `60` (60 Point), `72` (72 Point) | | +| `RIwmTextStyle` | Watermark Style | PickOne | `Gray` | `Gray`, `Outline` (Outlined) | | + +### Job Log + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIUserId` | User ID | PickOne | `None` | `None` | `UserId` string 0–8 | +| `RIJobType` | Job Type | PickOne | `Normal` | `Normal` (Normal Print), `SamplePrint` (Sample Print), `LockedPrint` (Locked Print), `HoldPrint` (Hold Print), `StoredPrint` (Stored Print), `StoreandPrint` (Store and Print), `DocServer` (Document Server) | | +| `RIFileName` | File Name | PickOne | `None` | `None` | `FileName` string 0–16 | +| `RIPassword` | Password | PickOne | `None` | `None` | `Password` passcode 4–8 | +| `RIEnableUserCode` | Enable User Code | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIUserCode` | User Code | PickOne | `None` | `None` | `UserCode` string 0–8 | +| `RIEnableSpecifyTime` | Set Print Time | Boolean | `False` | `False` (Off), `True` (On) | | +| `RITimeHour` | Hour (0 to 23) | PickOne | `0` | `0` | `TimeHour` int 0–23 | +| `RITimeMin` | Minute (0 to 59) | PickOne | `0` | `0` | `TimeMin` int 0–59 | +| `RIFolderNumber` | Folder Number | PickOne | `0` | `0` | `FolderNumber` int 0–200 | +| `RIFolderPassword` | Folder Password | PickOne | `None` | `None` | `FolderPassword` passcode 4–8 | + +### Unauthorized Copy Prevention + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIPreventionType` | Prevention Type | PickOne | `Off` | `Off`, `PreventionCopyPattern` (Unauthorized Copy Prevention for Pattern), `CopyGuard` (Data Security for Copying) | | +| `RITypeText` | Text Type | PickOne | `UserText` | `UserText` (User Text), `LoginUserName` (Login User Name), `JobName` (Job Name), `JobTime` (Date & Time), `LoginUserNameJobName` (Login User Name + Job Name), `LoginUserNameJobTime` (Login User Name + Date & Time), `JobNameTime` (Job Name + Date & Time), `LoginUserNameJobNameTime` (Login User Name + Job Name + Date & Time) | | +| `RIText` | Enter User Text | PickOne | `Copy` | `Copy` (COPY) | `Text` string 0–64 | +| `RIFont` | Font | PickOne | `ArialMT` | `AlbertusMT`, `AlbertusMTItalic` (AlbertusMT-Italic), `AlbertusMTLight` (AlbertusMT-Light), `AntiqueOliveBold` (AntiqueOlive-Bold), `AntiqueOliveCompact` (AntiqueOlive-Compact), `AntiqueOliveItalic` (AntiqueOlive-Italic), `AntiqueOliveRoman` (AntiqueOlive-Roman), `AppleChancery` (Apple-Chancery), `ArialMT`, `ArialBoldMT` (Arial-BoldMT), `ArialBoldItalicMT` (Arial-BoldItalicMT), `ArialItalicMT` (Arial-ItalicMT), `AvantGardeBook` (AvantGarde-Book), `AvantGardeBookOblique` (AvantGarde-BookOblique), `AvantGardeDemi` (AvantGarde-Demi), `AvantGardeDemiOblique` (AvantGarde-DemiOblique), `Bodoni`, `BodoniBold` (Bodoni-Bold), `BodoniBoldItalic` (Bodoni-BoldItalic), `BodoniItalic` (Bodoni-Italic), `BodoniPoster` (Bodoni-Poster), `BodoniPosterCompressed` (Bodoni-PosterCompressed), `BookmanDemi` (Bookman-Demi), `BookmanDemiItalic` (Bookman-DemiItalic), `BookmanLight` (Bookman-Light), `BookmanLightItalic` (Bookman-LightItalic), `Carta`, `Chicago`, `ClarendonBold` (Clarendon-Bold), `ClarendonLight` (Clarendon-Light), `Clarendon`, `CooperBlackItalic` (CooperBlack-Italic), `CooperBlack`, `CopperplateThirtyThreeBC` (Copperplate-ThirtyThreeBC), `CopperplateThirtyTwoBC` (Copperplate-ThirtyTwoBC), `CoronetRegular` (Coronet-Regular), `CourierBold` (Courier-Bold), `CourierBoldOblique` (Courier-BoldOblique), `CourierOblique` (Courier-Oblique), `Courier`, `EurostileBold` (Eurostile-Bold), `EurostileBoldExtendedTwo` (Eurostile-BoldExtendedTwo), `EurostileExtendedTwo` (Eurostile-ExtendedTwo), `Eurostile`, `Geneva`, `GillSans`, `GillSansBold` (GillSans-Bold), `GillSansBoldCondensed` (GillSans-BoldCondensed), `GillSansBoldItalic` (GillSans-BoldItalic), `GillSansCondensed` (GillSans-Condensed), `GillSansExtraBold` (GillSans-ExtraBold), `GillSansItalic` (GillSans-Italic), `GillSansLight` (GillSans-Light), `GillSansLightItalic` (GillSans-LightItalic), `Goudy`, `GoudyBold` (Goudy-Bold), `GoudyBoldItalic` (Goudy-BoldItalic), `GoudyExtraBold` (Goudy-ExtraBold), `GoudyItalic` (Goudy-Italic), `Helvetica`, `HelveticaBold` (Helvetica-Bold), `HelveticaBoldOblique` (Helvetica-BoldOblique), `HelveticaCondensedBold` (Helvetica-Condensed-Bold), `HelveticaCondensedBoldObl` (Helvetica-Condensed-BoldObl), `HelveticaCondensedOblique` (Helvetica-Condensed-Oblique), `HelveticaCondensed` (Helvetica-Condensed), `HelveticaNarrowBold` (Helvetica-Narrow-Bold), `HelveticaNarrowBoldOblique` (Helvetica-Narrow-BoldOblique), `HelveticaNarrowOblique` (Helvetica-Narrow-Oblique), `HelveticaNarrow` (Helvetica-Narrow), `HelveticaOblique` (Helvetica-Oblique), `HoeflerTextBlack` (HoeflerText-Black), `HoeflerTextBlackItalic` (HoeflerText-BlackItalic), `HoeflerTextItalic` (HoeflerText-Italic), `HoeflerTextOrnaments` (HoeflerText-Ornaments), `HoeflerTextRegular` (HoeflerText-Regular), `JoannaMT`, `JoannaMTBold` (JoannaMT-Bold), `JoannaMTBoldItalic` (JoannaMT-BoldItalic), `JoannaMTItalic` (JoannaMT-Italic), `LetterGothic`, `LetterGothicBold` (LetterGothic-Bold), `LetterGothicBoldSlanted` (LetterGothic-BoldSlanted), `LetterGothicSlanted` (LetterGothic-Slanted), `LubalinGraphBook` (LubalinGraph-Book), `LubalinGraphBookOblique` (LubalinGraph-BookOblique), `LubalinGraphDemi` (LubalinGraph-Demi), `LubalinGraphDemiOblique` (LubalinGraph-DemiOblique), `Marigold`, `MonaLisaRecut` (MonaLisa-Recut), `Monaco`, `NewCenturySchlbkBold` (NewCenturySchlbk-Bold), `NewCenturySchlbkBoldItalic` (NewCenturySchlbk-BoldItalic), `NewCenturySchlbkItalic` (NewCenturySchlbk-Italic), `NewCenturySchlbkRoman` (NewCenturySchlbk-Roman), `NewYork`, `OptimaBold` (Optima-Bold), `OptimaBoldItalic` (Optima-BoldItalic), `OptimaItalic` (Optima-Italic), `Optima`, `Oxford`, `PalatinoBold` (Palatino-Bold), `PalatinoBoldItalic` (Palatino-BoldItalic), `PalatinoItalic` (Palatino-Italic), `PalatinoRoman` (Palatino-Roman), `StempelGaramondBold` (StempelGaramond-Bold), `StempelGaramondBoldItalic` (StempelGaramond-BoldItalic), `StempelGaramondItalic` (StempelGaramond-Italic), `StempelGaramondRoman` (StempelGaramond-Roman), `Symbol`, `Tekton`, `TimesBold` (Times-Bold), `TimesBoldItalic` (Times-BoldItalic), `TimesItalic` (Times-Italic), `TimesRoman` (Times-Roman), `TimesNewRomanPSBoldItalicMT` (TimesNewRomanPS-BoldItalicMT), `TimesNewRomanPSBoldMT` (TimesNewRomanPS-BoldMT), `TimesNewRomanPSItalicMT` (TimesNewRomanPS-ItalicMT), `TimesNewRomanPSMT`, `Univers`, `UniversBold` (Univers-Bold), `UniversBoldExt` (Univers-BoldExt), `UniversBoldExtObl` (Univers-BoldExtObl), `UniversBoldOblique` (Univers-BoldOblique), `UniversCondensed` (Univers-Condensed), `UniversCondensedBold` (Univers-CondensedBold), `UniversCondensedBoldOblique` (Univers-CondensedBoldOblique), `UniversCondensedOblique` (Univers-CondensedOblique), `UniversExtended` (Univers-Extended), `UniversExtendedObl` (Univers-ExtendedObl), `UniversLight` (Univers-Light), `UniversLightOblique` (Univers-LightOblique), `UniversOblique` (Univers-Oblique), `WingdingsRegular` (Wingdings-Regular), `ZapfChanceryMediumItalic` (ZapfChancery-MediumItalic), `ZapfDingbats` | | +| `RISize` | Size | PickOne | `70` | `70` | `Size` int 50–300 | +| `RIAngel` | Angle | PickOne | `30` | `30` | `Angel` int 0–359 | +| `RIEffects` | Text/Pattern Effects | PickOne | `Normal` | `Normal` (Text and Background), `ReversePatterns` (Reverse Patterns (Text/Background)), `BackgroundOnly` (Background Only), `TextOnly` (Text Only) | | +| `RIRepeat` | Repeat | PickOne | `Off` | `Off`, `Repeat` (On), `RepeatandRotateCarriageReturn` (On (Rotate 180 Degrees at Carriage Return)) | | +| `RILineSpace` | Line Space | PickOne | `70` | `70` | `LineSpace` int 50–300 | +| `RIPosition` | Position | PickOne | `Center` | `Center`, `TopLeft` (Top Left), `TopCenter` (Top Center), `TopRight` (Top Right), `BottomLeft` (Bottom Left), `BottomCenter` (Bottom Center), `BottomRight` (Bottom Right) | | +| `RIColor` | Color | PickOne | `Black` | `Black`, `Cyan`, `Magenta` | | +| `RIDensity` | Density | PickOne | `Medium` | `VeryLight` (Very Light), `Light`, `Medium`, `Dark`, `VeryDark` (Very Dark) | | +| `RIMaskType` | Mask Type | PickOne | `None` | `None`, `WaveCrest` (Type 1), `Mesh` (Type 2), `Lattice1` (Type 3), `Lattice2` (Type 4), `InterlockingCircles` (Type 5), `Shokkoh` (Type 6), `Matsukawabishi` (Type 7), `Scale` (Type 8), `Higaki` (Type 9), `Hexagonal` (Type 10) | | + +### Color Balance Details + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIBrightness` | Brightness ( -50 to 50 ) | PickOne | `0` | `0` | `Brightness` real -50–50 | +| `RIContrast` | Contrast ( -50 to 50 ) | PickOne | `0` | `0` | `Contrast` real -50–50 | +| `RIBlack` | Black ( -50 to 50 ) | PickOne | `0` | `0` | `Black` real -50–50 | +| `RICyan` | Cyan ( -50 to 50 ) | PickOne | `0` | `0` | `Cyan` real -50–50 | +| `RIMagenta` | Magenta ( -50 to 50 ) | PickOne | `0` | `0` | `Magenta` real -50–50 | +| `RIYellow` | Yellow ( -50 to 50 ) | PickOne | `0` | `0` | `Yellow` real -50–50 | + +### User Authentication + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIEnableUserAuth` | User Authentication | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIAuthLoginUserNameType` | Login User Name | PickOne | `DefinedUserID` | `DefinedUserID` (Defined User ID), `LoginUserName` (Mac Login Name) | | +| `RIAuthLoginUserNameText` | Enter Login User Name | PickOne | `None` | `None` | `AuthLoginUserNameText` string 0–128 | +| `RIAuthLoginPassword` | Login Password | PickOne | `None` | `None` | `AuthLoginPassword` password 0–128 | +| `RIAuthEnableEncryption` | Driver Encryption Key | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIAuthEncryptionKey` | Driver Encryption Key | PickOne | `None` | `None` | `AuthEncryptionKey` password 0–32 | + +## RICOH MP 5055 PS + +### Installable Options + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `OptionTray` | Option Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `1Cassette` (Single Tray), `LCT` (Tray 3 (LCT)), `2Cassette` (Lower Paper Trays) | | +| `LargeCapacityTray` | Large Capacity Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `InnerTray2` | Internal Tray 2 | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `ShiftTray` | Internal Shift Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `ExternalTray` | External Tray | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `Finisher` | Finisher | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `FinRUBICONB` (Finisher SR3130), `FinVOLGADBK` (Finisher SR3240), `FinVOLGAD` (Finisher SR3230), `FinAMURBBK` (Finisher SR3220), `FinAMURHY` (Finisher SR3210) | | +| `MultiFold` | Folding Unit | PickOne | `NotInstalled` | `NotInstalled` (Not Installed), `Installed` | | +| `RIPostScript` | PostScript | PickOne | `IRIPS` | `IRIPS` (PostScript Emulation), `Adobe` (Adobe PostScript) | | + +### General + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `PageSize` | PageSize | PickOne | `A4` | `A3` (A3 (297 x 420 mm)), `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B4` (B4 JIS (257 x 364 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGermanLegal` (8.5 x 13), `11x17` (11 x 17), `12x18` (12 x 18), `11x15` (11 x 15), `10x14` (10 x 14), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `8Kai` (8K (267 x 390 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4), `A3.FullBleed` (A3 (297 x 420 mm) (Full bleed)), `A4.FullBleed` (A4 (210 x 297 mm) (Full Bleed)), `A5.FullBleed` (A5 (148 x 210 mm) (Full Bleed)), `A6.FullBleed` (A6 (105 x 148 mm) (Full Bleed)), `B4.FullBleed` (B4 (JIS) (257 x 364 mm) (Full Bleed)), `B5.FullBleed` (B5 (JIS) (182 x 257 mm) (Full Bleed)), `B6.FullBleed` (B6 (JIS) (128 x 182 mm) (Full Bleed)), `Legal.FullBleed` (Legal (8.5 x 14) (Full Bleed)), `GovernmentLG.FullBleed` (8.25 x 14 (Full Bleed)), `EngQuatro.FullBleed` (8 x 10 (Full Bleed)), `Letter.FullBleed` (Letter (8.5 x 11) (Full Bleed)), `HalfLetter.FullBleed` (5.5 x 8.5 (Full Bleed)), `F.FullBleed` (8 x 13 (Full Bleed)), `Folio.FullBleed` (8.25 x 13 (Full Bleed)), `FanFoldGermanLegal.FullBleed` (8.5 x 13 (Full Bleed)), `11x17.FullBleed` (11 x 17 (Full Bleed)), `12x18.FullBleed` (12 x 18 (Full Bleed)), `11x15.FullBleed` (11 x 15 (Full Bleed)), `10x14.FullBleed` (10 x 14 (Full Bleed)), `Executive.FullBleed` (Executive (7.25 x 10.5) (Full Bleed)), `Env10.FullBleed` (Com10 Env. (4.125 x 9.5) (Full Bleed)), `EnvMonarch.FullBleed` (Monarch Env. (3.875 x 7.5) (Full Bleed)), `EnvC5.FullBleed` (C5 Env. (162 x 229 mm) (Full Bleed)), `EnvC6.FullBleed` (C6 Env. (114 x 162 mm) (Full Bleed)), `DLEnv.FullBleed` (DL Env. (110 x 220 mm) (Full Bleed)), `8Kai.FullBleed` (8K (267 x 390 mm) (Full Bleed)), `16Kai.FullBleed` (16K (195 x 267 mm) (Full Bleed)), `Oficio.FullBleed` (8.5 x 13.4 (Full Bleed)) | `Width` points 255–864; `Height` points 419–1701; `WidthOffset` points 0–0; `HeightOffset` points 0–0; `Orientation` int 1–1 | +| `PageRegion` | PageRegion | PickOne | `A4` | `A3` (A3 (297 x 420 mm)), `A4` (A4 (210 x 297 mm)), `A5` (A5 (148 x 210 mm)), `A6` (A6 (105 x 148 mm)), `B4` (B4 JIS (257 x 364 mm)), `B5` (B5 JIS (182 x 257 mm)), `B6` (B6 JIS (128 x 182 mm)), `Legal` (Legal (8.5 x 14)), `GovernmentLG` (8.25 x 14), `EngQuatro` (8 x 10), `Letter` (Letter (8.5 x 11)), `HalfLetter` (5.5 x 8.5), `F` (8 x 13), `Folio` (8.25 x 13), `FanFoldGermanLegal` (8.5 x 13), `11x17` (11 x 17), `12x18` (12 x 18), `11x15` (11 x 15), `10x14` (10 x 14), `Executive` (Executive (7.25 x 10.5)), `Env10` (Com10 Env. (4.125 x 9.5)), `EnvMonarch` (Monarch Env. (3.875 x 7.5)), `EnvC5` (C5 Env. (162 x 229 mm)), `EnvC6` (C6 Env. (114 x 162 mm)), `DLEnv` (DL Env. (110 x 220 mm)), `8Kai` (8K (267 x 390 mm)), `16Kai` (16K (195 x 267 mm)), `Oficio` (8.5 x 13.4), `A3.FullBleed` (A3 (297 x 420 mm) (Full bleed)), `A4.FullBleed` (A4 (210 x 297 mm) (Full Bleed)), `A5.FullBleed` (A5 (148 x 210 mm) (Full Bleed)), `A6.FullBleed` (A6 (105 x 148 mm) (Full Bleed)), `B4.FullBleed` (B4 (JIS) (257 x 364 mm) (Full Bleed)), `B5.FullBleed` (B5 (JIS) (182 x 257 mm) (Full Bleed)), `B6.FullBleed` (B6 (JIS) (128 x 182 mm) (Full Bleed)), `Legal.FullBleed` (Legal (8.5 x 14) (Full Bleed)), `GovernmentLG.FullBleed` (8.25 x 14 (Full Bleed)), `EngQuatro.FullBleed` (8 x 10 (Full Bleed)), `Letter.FullBleed` (Letter (8.5 x 11) (Full Bleed)), `HalfLetter.FullBleed` (5.5 x 8.5 (Full Bleed)), `F.FullBleed` (8 x 13 (Full Bleed)), `Folio.FullBleed` (8.25 x 13 (Full Bleed)), `FanFoldGermanLegal.FullBleed` (8.5 x 13 (Full Bleed)), `11x17.FullBleed` (11 x 17 (Full Bleed)), `12x18.FullBleed` (12 x 18 (Full Bleed)), `11x15.FullBleed` (11 x 15 (Full Bleed)), `10x14.FullBleed` (10 x 14 (Full Bleed)), `Executive.FullBleed` (Executive (7.25 x 10.5) (Full Bleed)), `Env10.FullBleed` (Com10 Env. (4.125 x 9.5) (Full Bleed)), `EnvMonarch.FullBleed` (Monarch Env. (3.875 x 7.5) (Full Bleed)), `EnvC5.FullBleed` (C5 Env. (162 x 229 mm) (Full Bleed)), `EnvC6.FullBleed` (C6 Env. (114 x 162 mm) (Full Bleed)), `DLEnv.FullBleed` (DL Env. (110 x 220 mm) (Full Bleed)), `8Kai.FullBleed` (8K (267 x 390 mm) (Full Bleed)), `16Kai.FullBleed` (16K (195 x 267 mm) (Full Bleed)), `Oficio.FullBleed` (8.5 x 13.4 (Full Bleed)) | | +| `InputSlot` | InputSlot | PickOne | `1Tray` | `MultiTray` (Bypass Tray), `1Tray` (Tray 1), `2Tray` (Tray 2), `3Tray` (Tray 3), `4Tray` (Tray 4), `5Tray` (Large Capacity Tray) | | +| `Duplex` | Duplex | PickOne | `DuplexNoTumble` | `None` (Off), `DuplexNoTumble` (Long Edge), `DuplexTumble` (Short Edge) | | +| `Collate` | Collate | PickOne | `False` | `False` (Off), `True` (On) | | + +### Basic + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIPaperPolicy` | Fit to Paper | PickOne | `PromptUser` | `PromptUser` (Prompt User), `NearestSizeAdjust` (Nearest Size and Scale), `NearestSizeNoAdjust` (Nearest Size and Crop) | | +| `RIRotateBy180` | Rotate by 180 degrees | PickOne | `Off` | `Off`, `On` | | + +### Paper + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `MediaType` | Paper Type | PickOne | `Auto` | `Auto` (Plain/Recycled), `Plain1` (Plain 1 (60 - 74 g/m2)), `Plain2` (Plain 2 (75 - 81 g/m2)), `Recycled`, `Special1` (Special 1), `Special2` (Special 2), `Special3` (Special 3), `Colored` (Color), `Letterhead`, `Preprinted`, `Labels`, `Bond`, `Cardstock`, `OHP` (Transparency), `Thick1` (Thick 1 (106 - 169 g/m2)), `Thick2` (Thick 2 (170 - 220 g/m2)), `Thick3` (Thick 3 (221 - 256 g/m2)), `Thick4` (Thick 4 (257 - 300 g/m2)), `Thin` (Thin (52 - 59 g/m2)), `Middlethick` (Middle Thick (82 - 105 g/m2)), `Envelope`, `None` | | +| `OutputBin` | Destination | PickOne | `Default` | `Default` (Printer Default), `Standard` (Internal Tray 1), `Bin1` (Internal Tray 2), `Shift` (Internal Shift Tray), `External` (External Tray), `FinRUBICONBShift` (Finisher SR3130 Shift Tray), `FinVOLGADBKUpper` (Finisher SR3240 Upper Tray), `FinVOLGADBKShift` (Finisher SR3240 Shift Tray), `FinVOLGADBKLower` (Finisher SR3240 Booklet Tray), `FinVOLGADUpper` (Finisher SR3230 Upper Tray), `FinVOLGADShift` (Finisher SR3230 Shift Tray), `FinAMURBBKUpper` (Finisher SR3220 Upper Tray), `FinAMURBBKShift` (Finisher SR3220 Shift Tray), `FinAMURBBKLower` (Finisher SR3220 Booklet Tray), `FinAMURHYUpper` (Finisher SR3210 Upper Tray), `FinAMURHYShift` (Finisher SR3210 Shift Tray), `FinDONAUProof` (Folding Unit Tray) | | +| `RIBannerPagePrint` | Banner Page | PickOne | `False` | `False` (Off), `True` (On) | | +| `RIBannerPageInputSlot` | Banner Page Input Tray | PickOne | `Auto` | `Auto` (Auto Tray Select), `MultiTray` (Bypass Tray), `1Tray` (Tray 1), `2Tray` (Tray 2), `3Tray` (Tray 3), `4Tray` (Tray 4), `5Tray` (Large Capacity Tray) | | +| `RIBannerPageMediaType` | Banner Page Paper Type | PickOne | `Auto` | `Auto` (Plain/Recycled), `Plain1` (Plain 1 (60 - 74 g/m2)), `Plain2` (Plain 2 (75 - 81 g/m2)), `Recycled`, `Special1` (Special 1), `Special2` (Special 2), `Special3` (Special 3), `Colored` (Color), `Letterhead`, `Preprinted`, `Labels`, `Bond`, `Cardstock`, `OHP` (Transparency), `Thick1` (Thick 1 (106 - 169 g/m2)), `Thick2` (Thick 2 (170 - 220 g/m2)), `Thick3` (Thick 3 (221 - 256 g/m2)), `Thick4` (Thick 4 (257 - 300 g/m2)), `Thin` (Thin (52 - 59 g/m2)), `Middlethick` (Middle Thick (82 - 105 g/m2)), `Envelope` | | + +### Finishing + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIOrientOvr` | Orientation Override | PickOne | `Off` | `Off`, `Landscape`, `Portrait` | | +| `RICollateKind` | Collate Type | PickOne | `Normal` | `Normal` (Collate), `RotateCollate` (Rotating Collate), `ShiftCollate` (Shift Collate) | | +| `StapleLocation` | Staple | PickOne | `None` | `None` (Off), `StaplessUpperLeft` (Top left (stapleless)), `StaplessUpperRight` (Top right (stapleless)), `UpperLeft` (Top left), `UpperRight` (Top right), `LeftW` (2 at left), `RightW` (2 at right), `UpperW` (2 at top), `CenterW` (2 at center) | | +| `RIPunch` | Punch | PickOne | `None` | `None` (Off), `Left2` (2 at left), `Left3` (3 at left), `Left4` (4 at left), `Right2` (2 at right), `Right3` (3 at right), `Right4` (4 at right), `Upper2` (2 at top), `Upper3` (3 at top), `Upper4` (4 at top) | | +| `RIZfold` | Z-fold | PickOne | `None` | `None` (Off), `Bottom` (Bottom Fold), `Right` (Right Fold), `Left` (Left Fold) | | +| `RIFoldType` | Fold Type | PickOne | `None` | `None` (Off), `Twofold` (Half Fold), `Threefold` (Letter Fold-in), `ThreefoldOut` (Letter Fold-out), `OutsideTwofold` (Half Fold - Print Outside (Finisher Booklet Tray)) | | +| `OverlapFold` | Multi-sheet Fold | PickOne | `Off` | `Off`, `On` | | +| `Booklet` | Booklet | PickOne | `None` | `None` (Off), `OpenToLeft` (Open to Left/Top), `OpenToRight` (Open to Right/Bottom) | | + +### Print Quality + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `Resolution` | Resolution | PickOne | `600dpi` | `600dpi` (600 dpi), `1200dpi` (1200 dpi) | | +| `RIPrintMode` | Print Mode | PickOne | `0rhit` | `0rhit` (Through), `1rhit` (Edge Smoothing), `3rhit` (Toner Saving 1), `4rhit` (Toner Saving 2) | | +| `Rimagesm` | Image Smoothing | PickOne | `Off` | `Off`, `On`, `Auto`, `90ppi` (Less than 90 ppi), `150ppi` (Less than 150 ppi), `200ppi` (Less than 200 ppi), `300ppi` (Less than 300 ppi) | | +| `RPSDitherType` | Dithering | PickOne | `Auto` | `Auto`, `Photo` (Photographic), `Letter` (Text), `User` (User Setting), `Dispersion` (Reduce Missing Colors and Blurring) | | + +### Effects + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIWatermark` | Watermark | PickOne | `Off` | `Off`, `On` | | +| `RIWMText` | Watermark Text | PickOne | `Confidential` | `Confidential` (CONFIDENTIAL), `Copy` (COPY), `Copyright` (DRAFT), `Final` (FINAL), `FileCopy` (FILE COPY), `Proof` (PROOF), `TopSecret` (TOP SECRET) | | +| `RIwmFont` | Watermark Font | PickOne | `Default` | `Default` (Printer Default), `HelveticaB` (Helvetica Bold), `CourierB` (Courier Bold), `TimesB` (Times Bold), `NimbusSansB` (NimbusSans Bold), `NimbusMonoPSB` (NimbusMonoPS Bold), `NimbusRomanB` (NimbusRoman Bold) | | +| `RIwmAngle` | Watermark Angle | PickOne | `45Deg` | `180Deg` (180 Degrees), `135Deg` (135 Degrees), `90Deg` (90 Degrees), `45Deg` (45 Degrees), `0Deg` (0 Degrees), `M45Deg` (-45 Degrees), `M90Deg` (-90 Degrees), `M135Deg` (-135 Degrees), `M180Deg` (-180 Degrees) | | +| `RIwmSize` | Watermark Size | PickOne | `36` | `24` (24 Point), `36` (36 Point), `48` (48 Point), `60` (60 Point), `72` (72 Point) | | +| `RIwmTextStyle` | Watermark Style | PickOne | `Gray` | `Gray`, `Outline` (Outlined) | | + +### Job Log + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIUserId` | User ID | PickOne | `None` | `None` | `UserId` string 0–8 | +| `RIJobType` | Job Type | PickOne | `Normal` | `Normal` (Normal Print), `SamplePrint` (Sample Print), `LockedPrint` (Locked Print), `HoldPrint` (Hold Print), `StoredPrint` (Stored Print), `StoreandPrint` (Store and Print), `DocServer` (Document Server) | | +| `RIFileName` | File Name | PickOne | `None` | `None` | `FileName` string 0–16 | +| `RIPassword` | Password | PickOne | `None` | `None` | `Password` passcode 4–8 | +| `RIEnableUserCode` | Enable User Code | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIUserCode` | User Code | PickOne | `None` | `None` | `UserCode` string 0–8 | +| `RIEnableSpecifyTime` | Set Print Time | Boolean | `False` | `False` (Off), `True` (On) | | +| `RITimeHour` | Hour (0 to 23) | PickOne | `0` | `0` | `TimeHour` int 0–23 | +| `RITimeMin` | Minute (0 to 59) | PickOne | `0` | `0` | `TimeMin` int 0–59 | +| `RIFolderNumber` | Folder Number | PickOne | `0` | `0` | `FolderNumber` int 0–200 | +| `RIFolderPassword` | Folder Password | PickOne | `None` | `None` | `FolderPassword` passcode 4–8 | + +### Unauthorized Copy Prevention + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIPreventionType` | Prevention Type | PickOne | `Off` | `Off`, `PreventionCopyPattern` (Unauthorized Copy Prevention for Pattern), `CopyGuard` (Data Security for Copying) | | +| `RITypeText` | Text Type | PickOne | `UserText` | `UserText` (User Text), `LoginUserName` (Login User Name), `JobName` (Job Name), `JobTime` (Date & Time), `LoginUserNameJobName` (Login User Name + Job Name), `LoginUserNameJobTime` (Login User Name + Date & Time), `JobNameTime` (Job Name + Date & Time), `LoginUserNameJobNameTime` (Login User Name + Job Name + Date & Time) | | +| `RIText` | Enter User Text | PickOne | `Copy` | `Copy` (COPY) | `Text` string 0–64 | +| `RIFont` | Font | PickOne | `Default` | `Default` (Printer Default), `NimbusSansBold` (NimbusSans-Bold), `NimbusMonoPSBold` (NimbusMonoPS-Bold), `NimbusRomanBold` (NimbusRoman-Bold), `AlbertusMT`, `AlbertusMTItalic` (AlbertusMT-Italic), `AlbertusMTLight` (AlbertusMT-Light), `AntiqueOliveBold` (AntiqueOlive-Bold), `AntiqueOliveCompact` (AntiqueOlive-Compact), `AntiqueOliveItalic` (AntiqueOlive-Italic), `AntiqueOliveRoman` (AntiqueOlive-Roman), `AppleChancery` (Apple-Chancery), `ArialMT`, `ArialBoldMT` (Arial-BoldMT), `ArialBoldItalicMT` (Arial-BoldItalicMT), `ArialItalicMT` (Arial-ItalicMT), `AvantGardeBook` (AvantGarde-Book), `AvantGardeBookOblique` (AvantGarde-BookOblique), `AvantGardeDemi` (AvantGarde-Demi), `AvantGardeDemiOblique` (AvantGarde-DemiOblique), `Bodoni`, `BodoniBold` (Bodoni-Bold), `BodoniBoldItalic` (Bodoni-BoldItalic), `BodoniItalic` (Bodoni-Italic), `BodoniPoster` (Bodoni-Poster), `BodoniPosterCompressed` (Bodoni-PosterCompressed), `BookmanDemi` (Bookman-Demi), `BookmanDemiItalic` (Bookman-DemiItalic), `BookmanLight` (Bookman-Light), `BookmanLightItalic` (Bookman-LightItalic), `Carta`, `Chicago`, `ClarendonBold` (Clarendon-Bold), `ClarendonLight` (Clarendon-Light), `Clarendon`, `CooperBlackItalic` (CooperBlack-Italic), `CooperBlack`, `CopperplateThirtyThreeBC` (Copperplate-ThirtyThreeBC), `CopperplateThirtyTwoBC` (Copperplate-ThirtyTwoBC), `CoronetRegular` (Coronet-Regular), `CourierBold` (Courier-Bold), `CourierBoldOblique` (Courier-BoldOblique), `CourierOblique` (Courier-Oblique), `Courier`, `EurostileBold` (Eurostile-Bold), `EurostileBoldExtendedTwo` (Eurostile-BoldExtendedTwo), `EurostileExtendedTwo` (Eurostile-ExtendedTwo), `Eurostile`, `Geneva`, `GillSans`, `GillSansBold` (GillSans-Bold), `GillSansBoldCondensed` (GillSans-BoldCondensed), `GillSansBoldItalic` (GillSans-BoldItalic), `GillSansCondensed` (GillSans-Condensed), `GillSansExtraBold` (GillSans-ExtraBold), `GillSansItalic` (GillSans-Italic), `GillSansLight` (GillSans-Light), `GillSansLightItalic` (GillSans-LightItalic), `Goudy`, `GoudyBold` (Goudy-Bold), `GoudyBoldItalic` (Goudy-BoldItalic), `GoudyExtraBold` (Goudy-ExtraBold), `GoudyItalic` (Goudy-Italic), `Helvetica`, `HelveticaBold` (Helvetica-Bold), `HelveticaBoldOblique` (Helvetica-BoldOblique), `HelveticaCondensedBold` (Helvetica-Condensed-Bold), `HelveticaCondensedBoldObl` (Helvetica-Condensed-BoldObl), `HelveticaCondensedOblique` (Helvetica-Condensed-Oblique), `HelveticaCondensed` (Helvetica-Condensed), `HelveticaNarrowBold` (Helvetica-Narrow-Bold), `HelveticaNarrowBoldOblique` (Helvetica-Narrow-BoldOblique), `HelveticaNarrowOblique` (Helvetica-Narrow-Oblique), `HelveticaNarrow` (Helvetica-Narrow), `HelveticaOblique` (Helvetica-Oblique), `HoeflerTextBlack` (HoeflerText-Black), `HoeflerTextBlackItalic` (HoeflerText-BlackItalic), `HoeflerTextItalic` (HoeflerText-Italic), `HoeflerTextOrnaments` (HoeflerText-Ornaments), `HoeflerTextRegular` (HoeflerText-Regular), `JoannaMT`, `JoannaMTBold` (JoannaMT-Bold), `JoannaMTBoldItalic` (JoannaMT-BoldItalic), `JoannaMTItalic` (JoannaMT-Italic), `LetterGothic`, `LetterGothicBold` (LetterGothic-Bold), `LetterGothicBoldSlanted` (LetterGothic-BoldSlanted), `LetterGothicSlanted` (LetterGothic-Slanted), `LubalinGraphBook` (LubalinGraph-Book), `LubalinGraphBookOblique` (LubalinGraph-BookOblique), `LubalinGraphDemi` (LubalinGraph-Demi), `LubalinGraphDemiOblique` (LubalinGraph-DemiOblique), `Marigold`, `MonaLisaRecut` (MonaLisa-Recut), `Monaco`, `NewCenturySchlbkBold` (NewCenturySchlbk-Bold), `NewCenturySchlbkBoldItalic` (NewCenturySchlbk-BoldItalic), `NewCenturySchlbkItalic` (NewCenturySchlbk-Italic), `NewCenturySchlbkRoman` (NewCenturySchlbk-Roman), `NewYork`, `OptimaBold` (Optima-Bold), `OptimaBoldItalic` (Optima-BoldItalic), `OptimaItalic` (Optima-Italic), `Optima`, `Oxford`, `PalatinoBold` (Palatino-Bold), `PalatinoBoldItalic` (Palatino-BoldItalic), `PalatinoItalic` (Palatino-Italic), `PalatinoRoman` (Palatino-Roman), `StempelGaramondBold` (StempelGaramond-Bold), `StempelGaramondBoldItalic` (StempelGaramond-BoldItalic), `StempelGaramondItalic` (StempelGaramond-Italic), `StempelGaramondRoman` (StempelGaramond-Roman), `Symbol`, `Tekton`, `TimesBold` (Times-Bold), `TimesBoldItalic` (Times-BoldItalic), `TimesItalic` (Times-Italic), `TimesRoman` (Times-Roman), `TimesNewRomanPSBoldItalicMT` (TimesNewRomanPS-BoldItalicMT), `TimesNewRomanPSBoldMT` (TimesNewRomanPS-BoldMT), `TimesNewRomanPSItalicMT` (TimesNewRomanPS-ItalicMT), `TimesNewRomanPSMT`, `Univers`, `UniversBold` (Univers-Bold), `UniversBoldExt` (Univers-BoldExt), `UniversBoldExtObl` (Univers-BoldExtObl), `UniversBoldOblique` (Univers-BoldOblique), `UniversCondensed` (Univers-Condensed), `UniversCondensedBold` (Univers-CondensedBold), `UniversCondensedBoldOblique` (Univers-CondensedBoldOblique), `UniversCondensedOblique` (Univers-CondensedOblique), `UniversExtended` (Univers-Extended), `UniversExtendedObl` (Univers-ExtendedObl), `UniversLight` (Univers-Light), `UniversLightOblique` (Univers-LightOblique), `UniversOblique` (Univers-Oblique), `WingdingsRegular` (Wingdings-Regular), `ZapfChanceryMediumItalic` (ZapfChancery-MediumItalic), `ZapfDingbats` | | +| `RISize` | Size | PickOne | `70` | `70` | `Size` int 50–300 | +| `RIAngel` | Angle | PickOne | `30` | `30` | `Angel` int 0–359 | +| `RIEffects` | Text/Pattern Effects | PickOne | `Normal` | `Normal` (Text and Background), `ReversePatterns` (Reverse Patterns (Text/Background)), `BackgroundOnly` (Background Only), `TextOnly` (Text Only) | | +| `RIRepeat` | Repeat | PickOne | `Off` | `Off`, `Repeat` (On), `RepeatandRotateCarriageReturn` (On (Rotate 180 Degrees at Carriage Return)) | | +| `RILineSpace` | Line Space | PickOne | `70` | `70` | `LineSpace` int 50–300 | +| `RIPosition` | Position | PickOne | `Center` | `Center`, `TopLeft` (Top Left), `TopCenter` (Top Center), `TopRight` (Top Right), `BottomLeft` (Bottom Left), `BottomCenter` (Bottom Center), `BottomRight` (Bottom Right) | | +| `RIColor` | Color | PickOne | `Black` | `Black` | | +| `RIDensity` | Density | PickOne | `Medium` | `VeryLight` (Very Light), `Light`, `Medium`, `Dark`, `VeryDark` (Very Dark) | | +| `RIMaskType` | Mask Type | PickOne | `None` | `None`, `WaveCrest` (Type 1), `Mesh` (Type 2), `Lattice1` (Type 3), `Lattice2` (Type 4), `InterlockingCircles` (Type 5), `Shokkoh` (Type 6), `Matsukawabishi` (Type 7), `Scale` (Type 8), `Higaki` (Type 9), `Hexagonal` (Type 10) | | + +### Color Balance Details + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIBrightness` | Brightness ( -50 to 50 ) | PickOne | `0` | `0` | `Brightness` real -50–50 | +| `RIContrast` | Contrast ( -50 to 50 ) | PickOne | `0` | `0` | `Contrast` real -50–50 | +| `RIBlack` | Black ( -50 to 50 ) | PickOne | `0` | `0` | `Black` real -50–50 | + +### Background Numbering + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIBackgroundNumbering` | Background Numbering | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIBNSize` | Size | PickOne | `Normal` | `Small`, `Normal`, `Large` | | +| `RIBNDensity` | Density | PickOne | `Normal` | `Light`, `Normal`, `Dark`, `VeryDark` (Very Dark) | | +| `RIBNColor` | Color | PickOne | `Black` | `Black` | | +| `RIStartNumber` | Start Number (1 to 9999) | PickOne | `1` | `1` | `StartNumber` int 1–9999 | + +### User Authentication + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `RIEnableUserAuth` | User Authentication | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIAuthLoginUserNameType` | Login User Name | PickOne | `DefinedUserID` | `DefinedUserID` (Defined User ID), `LoginUserName` (Mac Login Name) | | +| `RIAuthLoginUserNameText` | Enter Login User Name | PickOne | `None` | `None` | `AuthLoginUserNameText` string 0–128 | +| `RIAuthLoginPassword` | Login Password | PickOne | `None` | `None` | `AuthLoginPassword` password 0–128 | +| `RIAuthEnableEncryption` | Driver Encryption Key | Boolean | `False` | `False` (Off), `True` (On) | | +| `RIAuthEncryptionKey` | Driver Encryption Key | PickOne | `None` | `None` | `AuthEncryptionKey` password 0–32 | + +## RICOH M C251FW PS + +### General + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `PageSize` | Media Size | PickOne | `A4` | `A4`, `JISB5` (B5 (JIS)), `A5`, `JISB6` (B6 (JIS)), `A6`, `Legal`, `Letter`, `HLT` (5.5" x 8.5"), `Executive`, `8x13` (8" x 13"), `8_25x13` (8.25" x 13"), `8_5x13` (8.5" x 13"), `4_125x9_5` (Com10 Env.), `3_875x7_5` (Monarch Env.), `DLEnv` (DL Env.), `C6Env` (C6 Env.), `C5Env` (C5 Env.), `Kai16` (16K), `A5L` (A5 (210 x 148mm)), `8_5x13_4` (8.5" x 13.4"), `8_5x13_6` (8.5" x 13.6") | `Width` points 255.119995117188–612.280029296875; `Height` points 419.529998779297–1009.130004882812; `WidthOffset` points 0–0; `HeightOffset` points 0–0; `Orientation` int 0–0 | +| `PageRegion` | Media Size | PickOne | `A4` | `A4`, `JISB5` (B5 (JIS)), `A5`, `JISB6` (B6 (JIS)), `A6`, `Legal`, `Letter`, `HLT` (5.5" x 8.5"), `Executive`, `8x13` (8" x 13"), `8_25x13` (8.25" x 13"), `8_5x13` (8.5" x 13"), `4_125x9_5` (Com10 Env.), `3_875x7_5` (Monarch Env.), `DLEnv` (DL Env.), `C6Env` (C6 Env.), `C5Env` (C5 Env.), `Kai16` (16K), `A5L` (A5 (210 x 148mm)), `8_5x13_4` (8.5" x 13.4"), `8_5x13_6` (8.5" x 13.6") | | +| `InputSlot` | Input Tray | PickOne | `Tray1` | `Manual` (Bypass Tray), `Tray1` (Tray 1), `Tray2` (Tray 2) | | +| `Duplex` | Duplex | PickOne | `DuplexNoTumble` | `None` (One sided), `DuplexNoTumble` (Left Side Binding), `DuplexTumble` (Top Side Binding) | | + +### Installable Options + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `Option1` | Total Memory | PickOne | `Mem256` | `Mem256` (256MB) | | +| `Option2` | Tray 2 | PickOne | `False` | `False` (Not Installed), `True` (Installed) | | + +### Paper Type + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `MediaType` | Media Type | PickOne | `PlainRecycled` | `Thinner` (Thin (60 to 65 g/m2)), `Thin` (Plain 1 (66 to 74 g/m2)), `Thin2` (Plain 2 (75 to 81 g/m2)), `Plain` (Middle Thick (82 to 90 g/m2)), `PlainP` (Thick 1 (91 to 105 g/m2)), `Recycle` (Recycled), `PlainRecycled` (Plain 1/Plain 2/Middle Thick/Recycled), `Color`, `Letterhead`, `PrePrint` (Preprinted), `PrePunch` (Prepunched), `Labels`, `Bond`, `Cardstock`, `Thick` (Thick 2 (106 to 163 g/m2)), `Envelope` | | + +### Imaging + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `HQPrintMode` | Print Quality | Boolean | `661` | `661` (Standard), `662` (High Quality), `664` (Best Quality) | | +| `HQColorRendering` | Color Profile | PickOne | `TEXT` | `OFF` (Off), `PRESENTATION` (Presentation), `TEXT` (Solid Color), `PHOTOGRAPHIC` (Photographic), `POPDISPLAY` (POP Display) | | +| `HQColorSimul` | CMYK Simulation Profile | PickOne | `OFF` | `OFF` (Off), `USSWOP` (US OffsetPrint), `EUROSCALE` (Euroscale), `PALETTE` (PaletteColor) | | +| `HQBlackTextOverprint` | Black Over Print | PickOne | `Text` | `Off`, `Text` (On) | | + +### Dithering + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `HQScreenMode` | Dithering | PickOne | `Auto` | `Auto` (Automatic), `Photographic`, `Text` | | + +### Toner + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `HQColorMode` | Color/ Black and White | PickOne | `COLOR` | `COLOR` (Color), `BW` (Black and White) | | +| `HQGrayMode` | Gray Reproduction | PickOne | `Black` | `Black` (Black by K), `Color` (CMY+K) | | +| `HQTonerSave` | Economy Color | PickOne | `OFF` | `OFF` (Off), `ON` (On) | | +| `HQBlankPage` | Print Blank Pages | PickOne | `On` | `Off`, `On` | | +| `HQCyan` | Cyan | PickOne | `On` | `Off`, `On` | | +| `HQMagenta` | Magenta | PickOne | `On` | `Off`, `On` | | +| `HQYellow` | Yellow | PickOne | `On` | `Off`, `On` | | +| `HQBlack` | Black | PickOne | `On` | `Off`, `On` | | + +### Cover + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `HQFrontCoverPrint` | Front Cover | PickOne | `OFF` | `OFF` (Off), `BLANK` (Leave Blank), `SIMPLEXCOPY` (One Side), `DUPLEXCOPY` (Both Sides) | | +| `HQFrontCoverPaperSource` | Front Cover Input | PickOne | `TRAY1` | `TRAY4Man` (Bypass Tray), `TRAY1` (Tray 1), `TRAY2` (Tray 2) | | + +### Watermark Text + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `HQwtrmrkType` | Watermark Type | PickOne | `Outline` | `Outline` (Outlined), `Solid`, `Transparent` (Transparent Text) | | +| `HQwtrmrkString` | Watermark Text | PickOne | `Confidential` | `Confidential` (CONFIDENTIAL), `TopSecret` (TOP SECRET), `Copy` (COPY), `Draft` (DRAFT), `Final` (FINAL), `FileCopy` (FILE COPY), `Proof` (PROOF) | | +| `HQwtrmrkFontSize` | Watermark Font Size (points) | PickOne | `pt60` | `pt6` (6), `pt18` (18), `pt30` (30), `pt60` (60), `pt90` (90), `pt120` (120), `pt150` (150), `pt180` (180), `pt200` (200), `pt300` (300) | | +| `HQwtrmrkFontName` | Watermark Font Typeface | PickOne | `Helvetica` | `Arial-BoldItalic`, `Arial-Bold`, `Arial-Italic`, `Arial`, `Courier`, `Courier-Bold`, `Courier-BoldOblique`, `Courier-Oblique`, `Helvetica`, `Helvetica-Bold`, `Helvetica-BoldOblique`, `Helvetica-Oblique`, `Times-Bold`, `Times-BoldItalic`, `Times-Italic`, `Times-Roman` | | +| `HQwtrmrkShading` | Watermark Font Shading | PickOne | `Shading20` | `Shading5` (5%), `Shading10` (10%), `Shading20` (20%), `Shading30` (30%), `Shading40` (40%), `Shading50` (50%), `Shading60` (60%), `Shading70` (70%), `Shading80` (80%), `Shading90` (90%), `Shading100` (100%) | | + +### Watermark Location + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `HQwtrmrkLocationX` | Watermark Position (Horizontal) | PickOne | `CenterX0` | `Left50` (Left (-50)), `Left40` (Left (-40)), `Left30` (Left (-30)), `Left20` (Left (-20)), `Left10` (Left (-10)), `CenterX0` (Center (0)), `Right10` (Right (+10)), `Right20` (Right (+20)), `Right30` (Right (+30)), `Right40` (Right (+40)), `Right50` (Right (+50)) | | +| `HQwtrmrkLocationY` | Watermark Position (Vertical) | PickOne | `CenterY0` | `Bottom50` (Bottom (-50)), `Bottom40` (Bottom (-40)), `Bottom30` (Bottom (-30)), `Bottom20` (Bottom (-20)), `Bottom10` (Bottom (-10)), `CenterY0` (Center (0)), `Top10` (Top (+10)), `Top20` (Top (+20)), `Top30` (Top (+30)), `Top40` (Top (+40)), `Top50` (Top (+50)) | | +| `HQwtrmrkAngle` | Watermark Angle | PickOne | `Deg45` | `Degm90` (Vertical (-90 degrees)), `Degm75` (Diagonal (-75 degrees)), `Degm60` (Diagonal (-60 degrees)), `Degm45` (Diagonal (-45 degrees)), `Degm30` (Diagonal (-30 degrees)), `Degm15` (Diagonal (-15 degrees)), `Deg0` (Horizontal (0 degrees)), `Deg15` (Diagonal (15 degrees)), `Deg30` (Diagonal (30 degrees)), `Deg45` (Diagonal (45 degrees)), `Deg60` (Diagonal (60 degrees)), `Deg75` (Diagonal (75 degrees)), `Deg90` (Vertical (90 degrees)) | | +| `HQwtrmrkMode` | Watermark | PickOne | `None` | `None` (Off), `FirstPage` (First Page Only), `AllPage` (All Pages) | | + +## RICOH SP 3710DN + +### General + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `PageSize` | Media Size | PickOne | `Letter` | `A4` (A4 (210 x 297 mm)), `Letter` (Letter (8.5" x 11")), `A5` (A5 (148 x 210 mm)), `Legal` (Legal (8.5" x 14")), `Env10` (Com10 (104.8 x 241.3 mm)), `EnvMonarch` (Monarch (98.4 x 190.5 mm)), `EnvDL` (DL Env (110 x 220 mm)), `EnvC5` (C5 Env (162 x 229 mm)) | | +| `PageRegion` | Media Size | PickOne | `Letter` | `A4` (A4 (210 x 297 mm)), `Letter` (Letter (8.5" x 11")), `A5` (A5 (148 x 210 mm)), `Legal` (Legal (8.5" x 14")), `Env10` (Com10 (104.8 x 241.3 mm)), `EnvMonarch` (Monarch (98.4 x 190.5 mm)), `EnvDL` (DL Env (110 x 220 mm)), `EnvC5` (C5 Env (162 x 229 mm)) | | +| `Resolution` | Resolution | PickOne | `600dpi` | `600dpi` | | +| `InputSlot` | Input Tray | PickOne | `AutoSelect` | `AutoSelect` (Auto Tray Select), `Manual` (Bypass Tray), `Tray1` (Tray 1), `Tray2` (Tray 2) | | +| `Duplex` | Duplex | PickOne | `DuplexNoTumble` | `None` (Off), `DuplexNoTumble` (Long-Edge Binding), `DuplexTumble` (Short-Edge Binding) | | + +### Installable Options + +| Keyword | Label | UI type | Default | Choices | Custom value | +|---|---|---|---|---|---| +| `Option1` | Tray 2 | PickOne | `False` | `False` (Not Set), `True` (Set) | | + diff --git a/pkg/postinstall b/pkg/postinstall new file mode 100755 index 0000000..8b29eb1 --- /dev/null +++ b/pkg/postinstall @@ -0,0 +1,17 @@ +#!/bin/bash +# cupsadmin postinstall (payload-free pkg): the binary ships in the Scripts archive next to this file. +# Never changes ownership/permissions of an existing /usr/local/bin (Homebrew on Intel owns it). +set -euo pipefail + +TARGET="${3:-/}" +DEST_DIR="${TARGET%/}/usr/local/bin" +SOURCE="$(cd "$(dirname "$0")" && pwd)/cupsadmin" + +if [ ! -d "$DEST_DIR" ]; then + mkdir -p -m 755 "$DEST_DIR" + echo "cupsadmin: created $DEST_DIR" +fi + +# -S: copy to a temp file and rename, so a running cupsadmin is never half-written. +install -S -m 755 -o root -g wheel "$SOURCE" "$DEST_DIR/cupsadmin" +echo "cupsadmin: installed $DEST_DIR/cupsadmin" diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..ef4b640 --- /dev/null +++ b/test.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Runs CupsKit tests with Swift Testing from the Command Line Tools (no Xcode). +# The CLT ship Testing.framework outside the default search path and an empty _Testing_Foundation +# overlay, so point at the framework, disable cross-import overlays, and load the @Test macro plugin. +# Live apply tests: CUPSKIT_LIVE_QUEUE= ./test.sh +START_EPOCH=$(date +%s) +echo "started $(date +%H:%M:%S)" +finish() { + local rc=$? + [ $rc -eq 0 ] && echo "OK: tests passed" || echo "ERROR: swift test exited $rc" + echo "finished $(date +%H:%M:%S) (total $(awk -v s="$(( $(date +%s) - START_EPOCH ))" 'BEGIN{printf "%.1f", s/60}') min)" +} +trap finish EXIT +cd "$(dirname "$0")" +F=/Library/Developer/CommandLineTools/Library/Developer/Frameworks +P=/Library/Developer/CommandLineTools/usr/lib/swift/host/plugins/testing +swift test \ + -Xswiftc -F -Xswiftc "$F" -Xswiftc -Xfrontend -Xswiftc -disable-cross-import-overlays \ + -Xswiftc -plugin-path -Xswiftc "$P" \ + -Xlinker -F -Xlinker "$F" -Xlinker -rpath -Xlinker "$F" "$@"