Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecbf46723b | ||
|
|
12061a03ac | ||
|
|
f7f6a8bd87 | ||
|
|
ed91ebf807 | ||
|
|
77c065f433 | ||
|
|
f7099a9808 |
@@ -93,6 +93,57 @@ codesign -dvv <sidecar>
|
|||||||
```
|
```
|
||||||
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
|
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
|
||||||
|
|
||||||
|
## Remote test builds (runner host)
|
||||||
|
**Policy: all builds and test runs that _can_ execute on the remote host
|
||||||
|
_should_ execute there.** Use `localadmin@192.168.0.172` whenever the
|
||||||
|
Gitea `macos_12` runner is not actively executing a workflow job.
|
||||||
|
|
||||||
|
Test builds done as part of change implementations run on the CI runner
|
||||||
|
host, not the dev machine — the runner is a macOS 12.7.6 **x86_64** VM
|
||||||
|
with Xcode 14.2, and CI failures there are often environmental and do not
|
||||||
|
reproduce locally.
|
||||||
|
|
||||||
|
- Host: `localadmin@192.168.0.172` (SSH, LAN). Runner daemon:
|
||||||
|
`gitea-runner daemon -c /opt/gitea-runner/config.yaml`.
|
||||||
|
- Credentials: `~/Projects/build-host.env` defines `BUILD_HOST_USER` /
|
||||||
|
`BUILD_HOST_PASSWORD`. **Never read or print this file** — load it with
|
||||||
|
`set -a; source ~/Projects/build-host.env; set +a` so values stay in the
|
||||||
|
environment.
|
||||||
|
- `sshpass` is installed (`/usr/local/bin/sshpass`). Feed the password via
|
||||||
|
the `SSHPASS` env var — never on the command line:
|
||||||
|
```sh
|
||||||
|
SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e ssh localadmin@192.168.0.172 '<cmd>'
|
||||||
|
SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e scp <local> localadmin@192.168.0.172:<remote>
|
||||||
|
```
|
||||||
|
First connection needs `-o StrictHostKeyChecking=accept-new`.
|
||||||
|
- Remote checkout: `~/Projects/iccery-v2-mac` (anonymous HTTPS clone works;
|
||||||
|
full history needed by `scripts/version.sh`). Before testing, sync it to
|
||||||
|
the same base commit as the local work:
|
||||||
|
`git fetch origin && git checkout <local base sha>`, then `scp` each
|
||||||
|
locally-changed file over its remote counterpart (uncommitted work
|
||||||
|
transfers this way — there is no push).
|
||||||
|
- `xcodegen` is **not** on PATH: prepend
|
||||||
|
`$HOME/.local/xcodegen/2.38.0/xcodegen/bin` (prebuilt 2.38.0 installed
|
||||||
|
by `scripts/ensure-host-tools.sh`; brew's formula needs Xcode 15.3,
|
||||||
|
impossible on macOS 12 — #109).
|
||||||
|
- Mirror `.gitea/workflows/macos.yml` exactly, from the repo root with
|
||||||
|
`DERIVED=build/DerivedData-test`:
|
||||||
|
`scripts/ensure-host-tools.sh` → `xcodegen generate --spec project.yml`
|
||||||
|
→ `eval "$(scripts/version.sh)"` → `xcodebuild build-for-testing
|
||||||
|
-scheme ICCery -destination 'platform=macOS' -derivedDataPath "$DERIVED"
|
||||||
|
-configuration Debug ARCHS="$(uname -m)" ONLY_ACTIVE_ARCH=NO
|
||||||
|
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-' MARKETING_VERSION=…
|
||||||
|
CURRENT_PROJECT_VERSION=… ICCERY_RELEASE_TAG=…` → codesign every
|
||||||
|
`*_PackageProduct.framework` under `$DERIVED/Build/Products/Debug`
|
||||||
|
(#119) → `xcodebuild test-without-building -xctestrun <ICCery*.xctestrun>
|
||||||
|
-only-testing:ICCeryCoreTests -destination 'platform=macOS'
|
||||||
|
-derivedDataPath "$DERIVED"`.
|
||||||
|
- Run **unit tests only** (`ICCeryCoreTests`). UI tests need a logged-in
|
||||||
|
GUI session and fail environmentally on the VM (runs 29700, 29804,
|
||||||
|
42183) — do not use them for verification unless asked.
|
||||||
|
- xcodebuild takes minutes — run the ssh command in a background shell
|
||||||
|
and poll rather than blocking.
|
||||||
|
|
||||||
## Private ColorSync SPI
|
## Private ColorSync SPI
|
||||||
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||||
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
|
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ public enum CupsOptionsFilter {
|
|||||||
// Media
|
// Media
|
||||||
"MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType",
|
"MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType",
|
||||||
// Tray
|
// Tray
|
||||||
"InputSlot", "AP_D_InputSlot",
|
"InputSlot", "MediaSource", "AP_D_InputSlot",
|
||||||
|
"EPIJ_FdSo", "EPIJ_PSrc", "CNIJMediaSupply",
|
||||||
// Size
|
// Size
|
||||||
"PageSize",
|
"PageSize",
|
||||||
// Colour bypass
|
// Colour bypass
|
||||||
|
|||||||
@@ -353,6 +353,55 @@ public enum CupsParsers {
|
|||||||
.map { $0 == "4" ? "landscape" : "portrait" }
|
.map { $0 == "4" ? "landscape" : "portrait" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Tray detection (#218)
|
||||||
|
|
||||||
|
/// Paper-source option key in preference order. `EPIJ_FdSo` is
|
||||||
|
/// preferred over `EPIJ_PSrc` because Epson InkjetPrinter2 PPDs
|
||||||
|
/// label `EPIJ_FdSo` as "Paper Source" and `EPIJ_PSrc` as
|
||||||
|
/// "Page Setup" — the feed source is what Stage 2 should control.
|
||||||
|
/// `AP_D_InputSlot` is a capture-side alias only.
|
||||||
|
public static let trayKeys = [
|
||||||
|
"InputSlot", "MediaSource", "EPIJ_FdSo", "EPIJ_PSrc",
|
||||||
|
"CNIJMediaSupply"
|
||||||
|
]
|
||||||
|
|
||||||
|
public static func detectTrayKey(optionKeys: Set<String>) -> String? {
|
||||||
|
trayKeys.first { optionKeys.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a paper-source token from a captured `key=value` string.
|
||||||
|
/// If `preferredKey` is supplied and present, it wins; otherwise
|
||||||
|
/// every roster key is tried. `AP_D_InputSlot` is accepted as an
|
||||||
|
/// alias when it carries a non-empty value. Empty values are
|
||||||
|
/// ignored so an `AP_D_InputSlot=` placeholder never maps to a
|
||||||
|
/// selection (#218 capture-back).
|
||||||
|
public static func extractTray(
|
||||||
|
fromOptionsString options: String,
|
||||||
|
preferredKey: String? = nil
|
||||||
|
) -> String? {
|
||||||
|
let pairs = lpoptions(options)
|
||||||
|
if let preferredKey,
|
||||||
|
let value = pairs.first(where: {
|
||||||
|
$0.key.caseInsensitiveCompare(preferredKey) == .orderedSame
|
||||||
|
})?.value, !value.isEmpty {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
for key in trayKeys {
|
||||||
|
if let value = pairs.first(where: {
|
||||||
|
$0.key.caseInsensitiveCompare(key) == .orderedSame
|
||||||
|
})?.value, !value.isEmpty {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Capture-side alias used by some macOS Core Printing paths.
|
||||||
|
if let value = pairs.first(where: {
|
||||||
|
$0.key.caseInsensitiveCompare("AP_D_InputSlot") == .orderedSame
|
||||||
|
})?.value, !value.isEmpty {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
|
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
|
||||||
/// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`;
|
/// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`;
|
||||||
/// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`;
|
/// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`;
|
||||||
|
|||||||
@@ -115,9 +115,36 @@ public struct CupsService: Sendable {
|
|||||||
|
|
||||||
/// Trays / paper sizes / media types for a queue, with PPD
|
/// Trays / paper sizes / media types for a queue, with PPD
|
||||||
/// `*Key id/Human:` enrichment when the queue's PPD is readable.
|
/// `*Key id/Human:` enrichment when the queue's PPD is readable.
|
||||||
|
/// Also resolves the per-media quality map from vendor driver
|
||||||
|
/// data (`MediaQualityConstraints`, #214) — empty when the driver
|
||||||
|
/// exposes none.
|
||||||
public func capabilities(for queue: String) async throws -> PrinterCapabilities {
|
public func capabilities(for queue: String) async throws -> PrinterCapabilities {
|
||||||
let listings = try await optionListings(for: queue)
|
let listings = try await optionListings(for: queue)
|
||||||
return capabilities(from: listings, ppd: loadPPD(for: queue))
|
let ppd = loadPPD(for: queue)
|
||||||
|
var caps = capabilities(from: listings, ppd: ppd)
|
||||||
|
// Default queue-level map, then per-tray overrides (#218).
|
||||||
|
// Share one file-read cache across all contexts so multiple
|
||||||
|
// tray-token resolutions read Epson PDEData.dat only once.
|
||||||
|
var fileCache: [URL: Data] = [:]
|
||||||
|
let readFile: (URL) -> Data? = {
|
||||||
|
if let cached = fileCache[$0] { return cached }
|
||||||
|
guard let data = try? Data(contentsOf: $0) else { return nil }
|
||||||
|
fileCache[$0] = data
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
caps.qualityIDsByMediaType = MediaQualityConstraints.resolve(
|
||||||
|
listings: listings, ppd: ppd,
|
||||||
|
optionOverrides: [:], readFile: readFile)
|
||||||
|
if let trayKey = caps.trayKey {
|
||||||
|
for tray in caps.trays {
|
||||||
|
let map = MediaQualityConstraints.resolve(
|
||||||
|
listings: listings, ppd: ppd,
|
||||||
|
optionOverrides: [trayKey: tray.token],
|
||||||
|
readFile: readFile)
|
||||||
|
caps.qualityIDsByMediaTypeByTray[tray.token] = map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return caps
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure mapping — extracted so fixture tests need no process.
|
/// Pure mapping — extracted so fixture tests need no process.
|
||||||
@@ -125,16 +152,12 @@ public struct CupsService: Sendable {
|
|||||||
from listings: [CupsOptionListing],
|
from listings: [CupsOptionListing],
|
||||||
ppd: String?
|
ppd: String?
|
||||||
) -> PrinterCapabilities {
|
) -> PrinterCapabilities {
|
||||||
var trays: [PrinterTray] = []
|
let optionKeys = Set(listings.map(\.key))
|
||||||
var sizes: [PrinterPaperSize] = []
|
var sizes: [PrinterPaperSize] = []
|
||||||
var media: [PrinterMediaType] = []
|
var media: [PrinterMediaType] = []
|
||||||
|
|
||||||
for listing in listings {
|
for listing in listings {
|
||||||
switch listing.key {
|
switch listing.key {
|
||||||
case "InputSlot", "MediaSource":
|
|
||||||
trays = listing.choices.enumerated().map {
|
|
||||||
PrinterTray(id: $0.offset + 1, name: $0.element)
|
|
||||||
}
|
|
||||||
case "PageSize", "MediaSize":
|
case "PageSize", "MediaSize":
|
||||||
sizes = listing.choices.enumerated().map {
|
sizes = listing.choices.enumerated().map {
|
||||||
PrinterPaperSize(id: $0.offset + 1, name: $0.element)
|
PrinterPaperSize(id: $0.offset + 1, name: $0.element)
|
||||||
@@ -152,12 +175,32 @@ public struct CupsService: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paper source — one deterministic vendor roster key (#218).
|
||||||
|
let trayKey = CupsParsers.detectTrayKey(optionKeys: optionKeys)
|
||||||
|
let trays: [PrinterTray]
|
||||||
|
let trayDefault: String?
|
||||||
|
if let trayKey,
|
||||||
|
let listing = listings.first(where: { $0.key == trayKey }) {
|
||||||
|
let labels = ppd.map {
|
||||||
|
CupsParsers.ppdChoiceLabels($0, key: trayKey)
|
||||||
|
} ?? [:]
|
||||||
|
trays = listing.choices.enumerated().map {
|
||||||
|
PrinterTray(
|
||||||
|
id: $0.offset + 1,
|
||||||
|
token: $0.element,
|
||||||
|
name: labels[$0.element] ?? $0.element)
|
||||||
|
}
|
||||||
|
trayDefault = listing.defaultChoice
|
||||||
|
} else {
|
||||||
|
trays = []
|
||||||
|
trayDefault = nil
|
||||||
|
}
|
||||||
|
|
||||||
// Print quality — the detected roster key's listing maps to
|
// Print quality — the detected roster key's listing maps to
|
||||||
// `PrinterQuality` with PPD labels and the `*` default (#183).
|
// `PrinterQuality` with PPD labels and the `*` default (#183).
|
||||||
var qualities: [PrinterQuality] = []
|
var qualities: [PrinterQuality] = []
|
||||||
var qualityDefault: String?
|
var qualityDefault: String?
|
||||||
let qualityKey = CupsParsers.detectQualityKey(
|
let qualityKey = CupsParsers.detectQualityKey(optionKeys: optionKeys)
|
||||||
optionKeys: Set(listings.map(\.key)))
|
|
||||||
if let qualityKey,
|
if let qualityKey,
|
||||||
let listing = listings.first(where: { $0.key == qualityKey }) {
|
let listing = listings.first(where: { $0.key == qualityKey }) {
|
||||||
let labels = ppd.map {
|
let labels = ppd.map {
|
||||||
@@ -171,7 +214,8 @@ public struct CupsService: Sendable {
|
|||||||
|
|
||||||
return PrinterCapabilities(
|
return PrinterCapabilities(
|
||||||
trays: trays, paperSizes: sizes, mediaTypes: media,
|
trays: trays, paperSizes: sizes, mediaTypes: media,
|
||||||
qualityKey: qualityKey, qualities: qualities,
|
trayKey: trayKey, qualityKey: qualityKey,
|
||||||
|
qualities: qualities, trayDefault: trayDefault,
|
||||||
qualityDefault: qualityDefault)
|
qualityDefault: qualityDefault)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Per-media quality validity (#214): which of the queue's quality
|
||||||
|
/// tokens the driver actually accepts for a given media type. The
|
||||||
|
/// answer is not in `lpoptions -l`, IPP, or standard PPD
|
||||||
|
/// `*UIConstraints` for the two driver families handled here —
|
||||||
|
///
|
||||||
|
/// - Epson InkjetPrinter2: `*EPIJUIConstraint: <cond>|<forbidden>`
|
||||||
|
/// lines in the machine bundle's
|
||||||
|
/// `…/Contents/Resources/PDEData.dat`. A rule forbids its RHS
|
||||||
|
/// choice while every LHS `*<key> <value>` term holds. LHS keys
|
||||||
|
/// other than the media key (`EPIJ_PSrc`, `EPIJ_FdSo`,
|
||||||
|
/// `EPIJ_Ink_`, …) are evaluated against the queue's `lpoptions`
|
||||||
|
/// defaults — reproducing the driver's PDE state when it opens.
|
||||||
|
/// - Canon BJPrinter: `*CNIJNameTblPath` + `*CNIJTableID` PPD keys
|
||||||
|
/// locate `cnb_<TableID>0.tbl`, a binary record DB whose 20-byte
|
||||||
|
/// `{u16 0x30, u16 1, u32 0, u16 family, u16 flag, u32 mediaID,
|
||||||
|
/// u16 0, u16 quality}` records enumerate the allowed
|
||||||
|
/// `CNIJPrintQuality` values per `CNIJMediaType` (`mediaID`
|
||||||
|
/// `| 0x10000` marks the borderless variant of the same media).
|
||||||
|
/// - Generic: standard PPD `*UIConstraints:`/`*Constraints:` pairs
|
||||||
|
/// (Gutenprint etc.) — first non-empty source wins.
|
||||||
|
///
|
||||||
|
/// Every parser is pure; file access is injected so tests need no
|
||||||
|
/// installed driver. Failure or absence yields an empty map — callers
|
||||||
|
/// treat that as "unconstrained" and keep the full quality list.
|
||||||
|
public enum MediaQualityConstraints {
|
||||||
|
|
||||||
|
/// media-type id → allowed quality ids. Sources tried in order
|
||||||
|
/// (PPD constraints → Epson `PDEData.dat` → Canon `cnb` table);
|
||||||
|
/// the first source producing any entries wins.
|
||||||
|
public static func resolve(
|
||||||
|
listings: [CupsOptionListing],
|
||||||
|
ppd: String?,
|
||||||
|
optionOverrides: [String: String] = [:],
|
||||||
|
readFile: (URL) -> Data? = { try? Data(contentsOf: $0) }
|
||||||
|
) -> [String: Set<String>] {
|
||||||
|
let optionKeys = Set(listings.map(\.key))
|
||||||
|
guard let mediaKey = CupsParsers.detectMediaTypeKey(
|
||||||
|
optionKeys: optionKeys),
|
||||||
|
let qualityKey = CupsParsers.detectQualityKey(
|
||||||
|
optionKeys: optionKeys),
|
||||||
|
let mediaListing = listings.first(where: {
|
||||||
|
$0.key == mediaKey }),
|
||||||
|
let qualityListing = listings.first(where: {
|
||||||
|
$0.key == qualityKey })
|
||||||
|
else { return [:] }
|
||||||
|
let mediaIDs = Set(mediaListing.choices)
|
||||||
|
let qualityIDs = Set(qualityListing.choices)
|
||||||
|
var defaults = Dictionary(
|
||||||
|
listings.compactMap { l in l.defaultChoice.map { (l.key, $0) } },
|
||||||
|
uniquingKeysWith: { first, _ in first })
|
||||||
|
for (key, value) in optionOverrides {
|
||||||
|
defaults[key] = value
|
||||||
|
}
|
||||||
|
guard let ppd else { return [:] }
|
||||||
|
|
||||||
|
let ppdMap = allowedMap(
|
||||||
|
forbidden: ppdUIConstraints(
|
||||||
|
ppd, mediaKey: mediaKey, qualityKey: qualityKey,
|
||||||
|
mediaIDs: mediaIDs, qualityIDs: qualityIDs),
|
||||||
|
mediaIDs: mediaIDs, qualityIDs: qualityIDs)
|
||||||
|
if !ppdMap.isEmpty { return ppdMap }
|
||||||
|
|
||||||
|
if let url = epijPDEDataPath(ppd: ppd),
|
||||||
|
let data = readFile(url),
|
||||||
|
let dat = String(data: data, encoding: .utf8)
|
||||||
|
?? String(data: data, encoding: .isoLatin1) {
|
||||||
|
let map = allowedMap(
|
||||||
|
forbidden: epijUIConstraints(
|
||||||
|
dat, mediaKey: mediaKey, qualityKey: qualityKey,
|
||||||
|
mediaIDs: mediaIDs, qualityIDs: qualityIDs,
|
||||||
|
defaults: defaults),
|
||||||
|
mediaIDs: mediaIDs, qualityIDs: qualityIDs)
|
||||||
|
if !map.isEmpty { return map }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let url = cnijTablePath(ppd: ppd),
|
||||||
|
let data = readFile(url) {
|
||||||
|
let map = cnijMediaQualityTable(
|
||||||
|
data, mediaIDs: mediaIDs, qualityIDs: qualityIDs)
|
||||||
|
if !map.isEmpty { return map }
|
||||||
|
}
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PPD keyword lookup
|
||||||
|
|
||||||
|
/// `*<key>: <value>` or `*<key>: "<value>"` → the value.
|
||||||
|
/// Exact key match — `*CNIJTableIDFoo:` must not satisfy a lookup
|
||||||
|
/// for `CNIJTableID`.
|
||||||
|
public static func ppdKeyword(_ ppd: String, _ key: String) -> String? {
|
||||||
|
for raw in ppd.split(separator: "\n") {
|
||||||
|
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard line.hasPrefix("*\(key)") else { continue }
|
||||||
|
let rest = line.dropFirst(key.count + 1)
|
||||||
|
guard rest.first == ":" else { continue }
|
||||||
|
var value = rest.dropFirst()
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
if value.count >= 2,
|
||||||
|
value.hasPrefix("\""), value.hasSuffix("\"") {
|
||||||
|
value = String(value.dropFirst().dropLast())
|
||||||
|
}
|
||||||
|
if !value.isEmpty { return value }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generic PPD constraints
|
||||||
|
|
||||||
|
/// `*UIConstraints:`/`*Constraints:` lines pair two conflicting
|
||||||
|
/// option choices; the ones naming both the media key and the
|
||||||
|
/// quality key produce forbidden (media, quality) pairs.
|
||||||
|
/// `*cupsUIConstraints` resolver triples do not match the
|
||||||
|
/// `*…Constraints:` prefixes, so they are skipped naturally.
|
||||||
|
public static func ppdUIConstraints(
|
||||||
|
_ ppd: String,
|
||||||
|
mediaKey: String,
|
||||||
|
qualityKey: String,
|
||||||
|
mediaIDs: Set<String>,
|
||||||
|
qualityIDs: Set<String>
|
||||||
|
) -> [String: Set<String>] {
|
||||||
|
var forbidden: [String: Set<String>] = [:]
|
||||||
|
for raw in ppd.split(separator: "\n") {
|
||||||
|
var line = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
if line.hasPrefix("*UIConstraints:") {
|
||||||
|
line = String(line.dropFirst("*UIConstraints:".count))
|
||||||
|
} else if line.hasPrefix("*Constraints:") {
|
||||||
|
line = String(line.dropFirst("*Constraints:".count))
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let terms = constraintTerms(line)
|
||||||
|
guard let media = terms[mediaKey], mediaIDs.contains(media),
|
||||||
|
let quality = terms[qualityKey],
|
||||||
|
qualityIDs.contains(quality)
|
||||||
|
else { continue }
|
||||||
|
forbidden[media, default: []].insert(quality)
|
||||||
|
}
|
||||||
|
return forbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Epson PDEData.dat
|
||||||
|
|
||||||
|
/// `EPIJDriverBasePath` + `EPIJMachineBundleName` → the machine
|
||||||
|
/// bundle's `Contents/Resources/PDEData.dat` (uniform across the
|
||||||
|
/// InkjetPrinter2 family).
|
||||||
|
public static func epijPDEDataPath(ppd: String) -> URL? {
|
||||||
|
guard let base = ppdKeyword(ppd, "EPIJDriverBasePath"),
|
||||||
|
let bundle = ppdKeyword(ppd, "EPIJMachineBundleName")
|
||||||
|
else { return nil }
|
||||||
|
return URL(fileURLWithPath: base)
|
||||||
|
.appendingPathComponent("Machine")
|
||||||
|
.appendingPathComponent(bundle)
|
||||||
|
.appendingPathComponent("Contents/Resources/PDEData.dat")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `*EPIJUIConstraint:` forbidden-pair rules → (media, quality)
|
||||||
|
/// pairs the driver greys out. A rule *fires* when its LHS
|
||||||
|
/// `*<mediaKey>` term equals the media (absent media term →
|
||||||
|
/// applies to every media) and every other LHS term's value
|
||||||
|
/// equals that key's `lpoptions` default. A term whose key the
|
||||||
|
/// queue does not advertise counts as satisfied — hiding a usable
|
||||||
|
/// quality is a soft restriction, while showing an invalid one
|
||||||
|
/// re-creates the reported print failure.
|
||||||
|
public static func epijUIConstraints(
|
||||||
|
_ dat: String,
|
||||||
|
mediaKey: String,
|
||||||
|
qualityKey: String,
|
||||||
|
mediaIDs: Set<String>,
|
||||||
|
qualityIDs: Set<String>,
|
||||||
|
defaults: [String: String]
|
||||||
|
) -> [String: Set<String>] {
|
||||||
|
var forbidden: [String: Set<String>] = [:]
|
||||||
|
for raw in dat.split(separator: "\n") {
|
||||||
|
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard line.hasPrefix("*EPIJUIConstraint:"),
|
||||||
|
let bar = line.firstIndex(of: "|")
|
||||||
|
else { continue }
|
||||||
|
let rhs = constraintTerms(
|
||||||
|
String(line[line.index(after: bar)...]))
|
||||||
|
guard let quality = rhs[qualityKey],
|
||||||
|
qualityIDs.contains(quality)
|
||||||
|
else { continue }
|
||||||
|
let lhs = constraintTerms(
|
||||||
|
String(line[
|
||||||
|
line.index(
|
||||||
|
line.startIndex,
|
||||||
|
offsetBy: "*EPIJUIConstraint:".count)..<bar]))
|
||||||
|
// Non-media terms must hold at the queue's defaults;
|
||||||
|
// a key absent from `defaults` is satisfied (see above).
|
||||||
|
let fires = lhs.allSatisfy { key, value in
|
||||||
|
key == mediaKey || value.isEmpty
|
||||||
|
|| (defaults[key].map { $0 == value } ?? true)
|
||||||
|
}
|
||||||
|
guard fires else { continue }
|
||||||
|
if let media = lhs[mediaKey] {
|
||||||
|
guard mediaIDs.contains(media) else { continue }
|
||||||
|
forbidden[media, default: []].insert(quality)
|
||||||
|
} else {
|
||||||
|
for media in mediaIDs {
|
||||||
|
forbidden[media, default: []].insert(quality)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return forbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Canon cnb table
|
||||||
|
|
||||||
|
/// `CNIJNameTblPath` + `CNIJTableID` → `cnb_<TableID>0.tbl`.
|
||||||
|
public static func cnijTablePath(ppd: String) -> URL? {
|
||||||
|
guard let dir = ppdKeyword(ppd, "CNIJNameTblPath"),
|
||||||
|
let tableID = ppdKeyword(ppd, "CNIJTableID")
|
||||||
|
else { return nil }
|
||||||
|
return URL(fileURLWithPath: dir)
|
||||||
|
.appendingPathComponent("cnb_\(tableID)0.tbl")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scans the Canon table for 20-byte LE records anchored on
|
||||||
|
/// `30 00 01 00 00 00 00 00`: `{u16 family, u16 flag,
|
||||||
|
/// u32 mediaID, u16 pad, u16 quality}` follows the anchor.
|
||||||
|
/// A row counts only when `pad == 0`, `mediaID & 0xFFFF` is a
|
||||||
|
/// listed media id, and `quality` is a listed quality id — so
|
||||||
|
/// unrelated tables cannot inject false entries. Records carry a
|
||||||
|
/// `family` field (0x03 on the Pro9500 II); the modal family is
|
||||||
|
/// used so sibling record layouts in the same file are ignored.
|
||||||
|
/// Returns allowed (not forbidden) sets directly.
|
||||||
|
public static func cnijMediaQualityTable(
|
||||||
|
_ data: Data,
|
||||||
|
mediaIDs: Set<String>,
|
||||||
|
qualityIDs: Set<String>
|
||||||
|
) -> [String: Set<String>] {
|
||||||
|
let mediaNums = Set(mediaIDs.compactMap { UInt32($0) })
|
||||||
|
let qualityNums = Set(qualityIDs.compactMap { UInt16($0) })
|
||||||
|
guard !mediaNums.isEmpty, !qualityNums.isEmpty else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
let anchor: [UInt8] = [0x30, 0x00, 0x01, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00]
|
||||||
|
var rows: [UInt16: [(media: UInt32, quality: UInt16)]] = [:]
|
||||||
|
data.withUnsafeBytes { buffer in
|
||||||
|
guard let base = buffer.baseAddress else { return }
|
||||||
|
var i = 0
|
||||||
|
while i + 20 <= buffer.count {
|
||||||
|
if memcmp(base + i, anchor, anchor.count) == 0 {
|
||||||
|
let family = readU16(base, i + 8)
|
||||||
|
let media = readU32(base, i + 12)
|
||||||
|
let pad = readU16(base, i + 16)
|
||||||
|
let quality = readU16(base, i + 18)
|
||||||
|
let baseMedia = media & 0xFFFF
|
||||||
|
if pad == 0,
|
||||||
|
mediaNums.contains(baseMedia),
|
||||||
|
qualityNums.contains(quality) {
|
||||||
|
rows[family, default: []].append(
|
||||||
|
(baseMedia, quality))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let dominant = rows.max(by: { $0.value.count < $1.value.count })
|
||||||
|
else { return [:] }
|
||||||
|
var map: [String: Set<String>] = [:]
|
||||||
|
for row in dominant.value {
|
||||||
|
map[String(row.media), default: []].insert(String(row.quality))
|
||||||
|
}
|
||||||
|
// Keep strict subsets only — a media with the full set (or an
|
||||||
|
// empty one) is unconstrained as far as the UI is concerned.
|
||||||
|
map = map.filter { _, ids in
|
||||||
|
!ids.isEmpty && ids != qualityIDs
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared
|
||||||
|
|
||||||
|
/// `*<key> <value>` term extraction for both PPD and Epson
|
||||||
|
/// constraint syntax. A `*<key>` not followed by a value is a
|
||||||
|
/// wildcard term (empty-string value → always satisfied).
|
||||||
|
private static func constraintTerms(_ text: String) -> [String: String] {
|
||||||
|
var terms: [String: String] = [:]
|
||||||
|
let tokens = text.split(separator: " ").map(String.init)
|
||||||
|
var index = 0
|
||||||
|
while index < tokens.count {
|
||||||
|
guard tokens[index].hasPrefix("*") else {
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let key = String(tokens[index].dropFirst())
|
||||||
|
if index + 1 < tokens.count,
|
||||||
|
!tokens[index + 1].hasPrefix("*") {
|
||||||
|
terms[key] = tokens[index + 1]
|
||||||
|
index += 2
|
||||||
|
} else {
|
||||||
|
terms[key] = ""
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return terms
|
||||||
|
}
|
||||||
|
|
||||||
|
/// forbidden (media → quality ids) → allowed map entries,
|
||||||
|
/// emitting only strict non-empty subsets: a media whose allowed
|
||||||
|
/// set is empty or equals the full roster is unconstrained as far
|
||||||
|
/// as the picker is concerned.
|
||||||
|
private static func allowedMap(
|
||||||
|
forbidden: [String: Set<String>],
|
||||||
|
mediaIDs: Set<String>,
|
||||||
|
qualityIDs: Set<String>
|
||||||
|
) -> [String: Set<String>] {
|
||||||
|
var map: [String: Set<String>] = [:]
|
||||||
|
for media in mediaIDs {
|
||||||
|
let allowed = qualityIDs.subtracting(forbidden[media] ?? [])
|
||||||
|
if !allowed.isEmpty, allowed != qualityIDs {
|
||||||
|
map[media] = allowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readU16(_ base: UnsafeRawPointer, _ offset: Int) -> UInt16 {
|
||||||
|
UInt16(base.load(fromByteOffset: offset, as: UInt8.self))
|
||||||
|
| UInt16(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readU32(_ base: UnsafeRawPointer, _ offset: Int) -> UInt32 {
|
||||||
|
UInt32(base.load(fromByteOffset: offset, as: UInt8.self))
|
||||||
|
| UInt32(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8
|
||||||
|
| UInt32(base.load(fromByteOffset: offset + 2, as: UInt8.self)) << 16
|
||||||
|
| UInt32(base.load(fromByteOffset: offset + 3, as: UInt8.self)) << 24
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,13 +48,17 @@ public struct Printer: Codable, Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Paper source. `id` is the 1-based index of the `InputSlot` /
|
/// Paper source. `id` is the 1-based index of the `InputSlot` /
|
||||||
/// `MediaSource` choice (not a PPD code) — docs/10.
|
/// `MediaSource` / vendor choice (not a PPD code); `token` is the
|
||||||
|
/// exact CUPS/PPD machine value written to the ticket; `name` is the
|
||||||
|
/// PPD-enriched display label (#218).
|
||||||
public struct PrinterTray: Codable, Equatable, Sendable {
|
public struct PrinterTray: Codable, Equatable, Sendable {
|
||||||
public var id: Int
|
public var id: Int
|
||||||
|
public var token: String
|
||||||
public var name: String
|
public var name: String
|
||||||
|
|
||||||
public init(id: Int, name: String) {
|
public init(id: Int, token: String, name: String) {
|
||||||
self.id = id
|
self.id = id
|
||||||
|
self.token = token
|
||||||
self.name = name
|
self.name = name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,29 +105,101 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
|||||||
/// Always `true` on macOS (spec parity — CUPS honours
|
/// Always `true` on macOS (spec parity — CUPS honours
|
||||||
/// `orientation-requested`).
|
/// `orientation-requested`).
|
||||||
public var supportsOrientation: Bool
|
public var supportsOrientation: Bool
|
||||||
|
/// The queue's detected tray enumeration key
|
||||||
|
/// (`CupsParsers.detectTrayKey`), e.g. `EPIJ_FdSo` (#218).
|
||||||
|
public var trayKey: String?
|
||||||
/// The queue's detected quality enumeration key
|
/// The queue's detected quality enumeration key
|
||||||
/// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183).
|
/// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183).
|
||||||
public var qualityKey: String?
|
public var qualityKey: String?
|
||||||
public var qualities: [PrinterQuality]
|
public var qualities: [PrinterQuality]
|
||||||
|
/// The `*`-marked default tray token from `lpoptions -l`, if any
|
||||||
|
/// (#218).
|
||||||
|
public var trayDefault: String?
|
||||||
/// The `*`-marked default choice from `lpoptions -l`, if any.
|
/// The `*`-marked default choice from `lpoptions -l`, if any.
|
||||||
public var qualityDefault: String?
|
public var qualityDefault: String?
|
||||||
|
/// Media-type id → the quality ids the driver accepts for it
|
||||||
|
/// (#214 — `MediaQualityConstraints`). Empty when the driver
|
||||||
|
/// exposes no per-media quality matrix; a missing entry means
|
||||||
|
/// "unconstrained" — show everything.
|
||||||
|
public var qualityIDsByMediaType: [String: Set<String>]
|
||||||
|
/// Tray token → media-type id → allowed quality ids. Computed
|
||||||
|
/// once at capability load; a missing tray falls back to
|
||||||
|
/// `qualityIDsByMediaType` (#218).
|
||||||
|
public var qualityIDsByMediaTypeByTray: [String: [String: Set<String>]]
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
trays: [PrinterTray] = [],
|
trays: [PrinterTray] = [],
|
||||||
paperSizes: [PrinterPaperSize] = [],
|
paperSizes: [PrinterPaperSize] = [],
|
||||||
mediaTypes: [PrinterMediaType] = [],
|
mediaTypes: [PrinterMediaType] = [],
|
||||||
supportsOrientation: Bool = true,
|
supportsOrientation: Bool = true,
|
||||||
|
trayKey: String? = nil,
|
||||||
qualityKey: String? = nil,
|
qualityKey: String? = nil,
|
||||||
qualities: [PrinterQuality] = [],
|
qualities: [PrinterQuality] = [],
|
||||||
qualityDefault: String? = nil
|
trayDefault: String? = nil,
|
||||||
|
qualityDefault: String? = nil,
|
||||||
|
qualityIDsByMediaType: [String: Set<String>] = [:],
|
||||||
|
qualityIDsByMediaTypeByTray: [String: [String: Set<String>]] = [:]
|
||||||
) {
|
) {
|
||||||
self.trays = trays
|
self.trays = trays
|
||||||
self.paperSizes = paperSizes
|
self.paperSizes = paperSizes
|
||||||
self.mediaTypes = mediaTypes
|
self.mediaTypes = mediaTypes
|
||||||
self.supportsOrientation = supportsOrientation
|
self.supportsOrientation = supportsOrientation
|
||||||
|
self.trayKey = trayKey
|
||||||
self.qualityKey = qualityKey
|
self.qualityKey = qualityKey
|
||||||
self.qualities = qualities
|
self.qualities = qualities
|
||||||
|
self.trayDefault = trayDefault
|
||||||
self.qualityDefault = qualityDefault
|
self.qualityDefault = qualityDefault
|
||||||
|
self.qualityIDsByMediaType = qualityIDsByMediaType
|
||||||
|
self.qualityIDsByMediaTypeByTray = qualityIDsByMediaTypeByTray
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The quality map for the requested tray token, if any.
|
||||||
|
/// Prefers the tray-specific map; otherwise falls back to the
|
||||||
|
/// queue-default map. An explicitly empty tray map means the tray
|
||||||
|
/// is unconstrained, so `nil` is returned only when neither map
|
||||||
|
/// exists.
|
||||||
|
private func qualityMap(
|
||||||
|
forTrayToken trayToken: String?
|
||||||
|
) -> [String: Set<String>]? {
|
||||||
|
if let trayToken,
|
||||||
|
let map = qualityIDsByMediaTypeByTray[trayToken],
|
||||||
|
!map.isEmpty {
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
return qualityIDsByMediaType.isEmpty ? nil : qualityIDsByMediaType
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Qualities valid for `mediaID` and an optional tray token,
|
||||||
|
/// preserving the driver's listing order (#180/#218). Falls back
|
||||||
|
/// to the full list when the driver has no per-media data, the
|
||||||
|
/// media is absent from the map, or the filter would empty the
|
||||||
|
/// picker (#214).
|
||||||
|
public func qualities(
|
||||||
|
forMediaType mediaID: String?,
|
||||||
|
trayToken: String? = nil
|
||||||
|
) -> [PrinterQuality] {
|
||||||
|
guard let mediaID,
|
||||||
|
let allowed = qualityMap(forTrayToken: trayToken)?[mediaID],
|
||||||
|
!allowed.isEmpty
|
||||||
|
else { return qualities }
|
||||||
|
let filtered = qualities.filter { allowed.contains($0.id) }
|
||||||
|
return filtered.isEmpty ? qualities : filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `qualityID` is usable with `mediaID` and an optional
|
||||||
|
/// tray token (#218). A quality absent from `qualities` entirely
|
||||||
|
/// is still allowed: a driver-captured token can be legitimate
|
||||||
|
/// even when `lpoptions` never listed it.
|
||||||
|
public func allowsQuality(
|
||||||
|
_ qualityID: String,
|
||||||
|
forMediaType mediaID: String?,
|
||||||
|
trayToken: String? = nil
|
||||||
|
) -> Bool {
|
||||||
|
guard let mediaID,
|
||||||
|
let allowed = qualityMap(forTrayToken: trayToken)?[mediaID],
|
||||||
|
!allowed.isEmpty
|
||||||
|
else { return true }
|
||||||
|
return allowed.contains(qualityID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Product | ICCery v2 for macOS |
|
| Product | ICCery v2 for macOS |
|
||||||
| Bundle | `com.gronod.iccery2` |
|
| Bundle | `com.gronod.iccery2` |
|
||||||
| Version | 2.0.0 |
|
| Version | 2.0.3 |
|
||||||
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
|
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
|
||||||
| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) |
|
| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) |
|
||||||
| CI | Gitea Actions `macos-12` runner |
|
| CI | Gitea Actions `macos-12` runner |
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ struct AppEnvironment: Sendable {
|
|||||||
.map { URL(fileURLWithPath: $0) }
|
.map { URL(fileURLWithPath: $0) }
|
||||||
var bundledRoot = AppPaths.bundledArgyllDir
|
var bundledRoot = AppPaths.bundledArgyllDir
|
||||||
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
||||||
|
var ppdDir = URL(fileURLWithPath: "/etc/cups/ppd")
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
||||||
overrideDir = URL(fileURLWithPath: dir)
|
overrideDir = URL(fileURLWithPath: dir)
|
||||||
@@ -34,6 +35,11 @@ struct AppEnvironment: Sendable {
|
|||||||
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
|
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
|
||||||
cupsDir = URL(fileURLWithPath: dir)
|
cupsDir = URL(fileURLWithPath: dir)
|
||||||
}
|
}
|
||||||
|
// #214 — fixture PPD dir lets UI tests exercise the media→
|
||||||
|
// quality constraint resolver without installed drivers.
|
||||||
|
if let dir = environment["ICCERY_CUPS_PPD_DIR"], !dir.isEmpty {
|
||||||
|
ppdDir = URL(fileURLWithPath: dir)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
return AppEnvironment(
|
return AppEnvironment(
|
||||||
stateStore: WizardStateStore(),
|
stateStore: WizardStateStore(),
|
||||||
@@ -46,7 +52,7 @@ struct AppEnvironment: Sendable {
|
|||||||
),
|
),
|
||||||
cupsService: CupsService(
|
cupsService: CupsService(
|
||||||
processManager: .shared,
|
processManager: .shared,
|
||||||
binaryDir: cupsDir),
|
binaryDir: cupsDir, ppdDir: ppdDir),
|
||||||
historyStore: VerificationHistoryStore(),
|
historyStore: VerificationHistoryStore(),
|
||||||
mediaStore: MediaLibraryStore(),
|
mediaStore: MediaLibraryStore(),
|
||||||
recentProjectsStore: RecentProjectsStore()
|
recentProjectsStore: RecentProjectsStore()
|
||||||
@@ -127,17 +133,29 @@ enum UITestHooks {
|
|||||||
/// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and
|
/// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and
|
||||||
/// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the
|
/// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the
|
||||||
/// queue the panel was opened for).
|
/// queue the panel was opened for).
|
||||||
static func printPanelResult(forQueue queue: String) -> PrintPropertiesResult? {
|
static func printPanelResult(
|
||||||
|
forQueue queue: String,
|
||||||
|
trays: [PrinterTray] = [],
|
||||||
|
preferredTrayKey: String? = nil
|
||||||
|
) -> PrintPropertiesResult? {
|
||||||
switch env["ICCERY_TEST_PRINT_PANEL"] {
|
switch env["ICCERY_TEST_PRINT_PANEL"] {
|
||||||
case "ok":
|
case "ok":
|
||||||
let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap {
|
let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap {
|
||||||
$0.isEmpty ? nil : $0
|
$0.isEmpty ? nil : $0
|
||||||
}
|
}
|
||||||
|
let trayToken = options.flatMap {
|
||||||
|
CupsParsers.extractTray(
|
||||||
|
fromOptionsString: $0, preferredKey: preferredTrayKey)
|
||||||
|
}
|
||||||
|
let paperSource = trays.first(where: {
|
||||||
|
$0.token == trayToken
|
||||||
|
}).map(\.id)
|
||||||
return PrintPropertiesResult(
|
return PrintPropertiesResult(
|
||||||
selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap {
|
selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap {
|
||||||
$0.isEmpty ? nil : $0
|
$0.isEmpty ? nil : $0
|
||||||
} ?? queue,
|
} ?? queue,
|
||||||
options: PrintOptions(
|
options: PrintOptions(
|
||||||
|
paperSource: paperSource,
|
||||||
orientation: options.flatMap {
|
orientation: options.flatMap {
|
||||||
CupsParsers.extractOrientation(fromOptionsString: $0)
|
CupsParsers.extractOrientation(fromOptionsString: $0)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -181,29 +181,44 @@ struct ColorSyncSuppressor {
|
|||||||
|
|
||||||
// MARK: - Layer ⑥ capture
|
// MARK: - Layer ⑥ capture
|
||||||
|
|
||||||
/// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType)`.
|
/// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType,
|
||||||
/// The malloc'd C string is freed after copying.
|
/// tray)`. The malloc'd C string is freed after copying. `trayKey`
|
||||||
|
/// is the queue's detected tray enumeration key; if nil, the full
|
||||||
|
/// roster plus the capture-only `AP_D_InputSlot` alias is tried.
|
||||||
func captureOptions(
|
func captureOptions(
|
||||||
from settings: PMPrintSettings
|
from settings: PMPrintSettings,
|
||||||
) -> (cupsOptions: String?, mediaType: String?) {
|
trayKey: String?
|
||||||
guard let toOptions = optionsResolver() else {
|
) -> (cupsOptions: String?, mediaType: String?, tray: String?) {
|
||||||
log("ColorSync: PMPrintSettingsToOptions unavailable — "
|
guard let unfiltered = rawOptionsDump(from: settings) else {
|
||||||
+ "panel options not captured")
|
return (nil, nil, nil)
|
||||||
return (nil, nil)
|
|
||||||
}
|
}
|
||||||
var raw: UnsafeMutablePointer<CChar>?
|
|
||||||
guard toOptions(settings, &raw) == 0, let raw else {
|
|
||||||
return (nil, nil)
|
|
||||||
}
|
|
||||||
defer { free(raw) }
|
|
||||||
let unfiltered = String(cString: raw)
|
|
||||||
let filtered = CupsOptionsFilter.filter(unfiltered)
|
let filtered = CupsOptionsFilter.filter(unfiltered)
|
||||||
return (
|
return (
|
||||||
filtered.isEmpty ? nil : filtered,
|
filtered.isEmpty ? nil : filtered,
|
||||||
CupsParsers.extractMediaType(fromOptionsString: unfiltered)
|
CupsParsers.extractMediaType(fromOptionsString: unfiltered),
|
||||||
|
CupsParsers.extractTray(
|
||||||
|
fromOptionsString: unfiltered,
|
||||||
|
preferredKey: trayKey)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unfiltered `PMPrintSettingsToOptions` output — the diagnostics
|
||||||
|
/// half of the capture path (#217 `ICCERY_DEBUG_PANEL_DUMP`).
|
||||||
|
/// `nil` when the symbol or the call fails.
|
||||||
|
func rawOptionsDump(from settings: PMPrintSettings) -> String? {
|
||||||
|
guard let toOptions = optionsResolver() else {
|
||||||
|
log("ColorSync: PMPrintSettingsToOptions unavailable — "
|
||||||
|
+ "panel options not captured")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var raw: UnsafeMutablePointer<CChar>?
|
||||||
|
guard toOptions(settings, &raw) == 0, let raw else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer { free(raw) }
|
||||||
|
return String(cString: raw)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - dlsym
|
// MARK: - dlsym
|
||||||
|
|
||||||
private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? {
|
private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? {
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ struct PrintPanelInitialSelections {
|
|||||||
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written
|
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written
|
||||||
/// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1).
|
/// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1).
|
||||||
var paperSize: String?
|
var paperSize: String?
|
||||||
|
/// The queue's detected tray enumeration key, e.g. `EPIJ_FdSo`.
|
||||||
|
var trayKey: String?
|
||||||
|
/// The selected tray machine token.
|
||||||
|
var tray: String?
|
||||||
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
|
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
|
||||||
var qualityKey: String?
|
var qualityKey: String?
|
||||||
/// The selected quality token.
|
/// The selected quality token.
|
||||||
@@ -32,6 +36,23 @@ struct PrintPanelInitialSelections {
|
|||||||
var orientation: String?
|
var orientation: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One bound-panel invocation (#217): the queue, its `lpoptions -l`
|
||||||
|
/// key roster, the Stage 2 pre-selections, and the session's captured
|
||||||
|
/// ticket — everything `makePanelPrintInfo` needs before the modal.
|
||||||
|
struct PrintPanelRequest {
|
||||||
|
let queue: String
|
||||||
|
let displayName: String?
|
||||||
|
let optionKeys: Set<String>
|
||||||
|
let initialSelections: PrintPanelInitialSelections
|
||||||
|
/// The queue's previously captured ticket — rehydrated before the
|
||||||
|
/// Stage 2 writes so vendor PDE state survives a reopen (#217).
|
||||||
|
let ticket: PrintTicket?
|
||||||
|
/// Roster of paper-source choices used to map a captured option
|
||||||
|
/// token back to a 1-based ID. Empty for queues with no tray key
|
||||||
|
/// (#218).
|
||||||
|
let trays: [PrinterTray]
|
||||||
|
}
|
||||||
|
|
||||||
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
||||||
/// queue (issue 13, docs/11).
|
/// queue (issue 13, docs/11).
|
||||||
///
|
///
|
||||||
@@ -58,11 +79,16 @@ struct PrintPanelService {
|
|||||||
displayName: String?,
|
displayName: String?,
|
||||||
cupsService: CupsService,
|
cupsService: CupsService,
|
||||||
initialSelections: PrintPanelInitialSelections =
|
initialSelections: PrintPanelInitialSelections =
|
||||||
PrintPanelInitialSelections()
|
PrintPanelInitialSelections(),
|
||||||
|
ticket: PrintTicket? = nil,
|
||||||
|
trays: [PrinterTray] = []
|
||||||
) async throws -> PanelCaptureResult? {
|
) async throws -> PanelCaptureResult? {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if UITestHooks.printPanelStubbed {
|
if UITestHooks.printPanelStubbed {
|
||||||
return UITestHooks.printPanelResult(forQueue: queue).map {
|
return UITestHooks.printPanelResult(
|
||||||
|
forQueue: queue, trays: trays,
|
||||||
|
preferredTrayKey: initialSelections.trayKey
|
||||||
|
).map {
|
||||||
PanelCaptureResult(properties: $0, ticket: nil)
|
PanelCaptureResult(properties: $0, ticket: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,83 +101,140 @@ struct PrintPanelService {
|
|||||||
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
||||||
?? []
|
?? []
|
||||||
return try runNativePanel(
|
return try runNativePanel(
|
||||||
queue: queue, displayName: display, optionKeys: optionKeys,
|
PrintPanelRequest(
|
||||||
initialSelections: initialSelections)
|
queue: queue, displayName: display,
|
||||||
|
optionKeys: optionKeys,
|
||||||
|
initialSelections: initialSelections, ticket: ticket,
|
||||||
|
trays: trays))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Panel
|
// MARK: - Panel
|
||||||
|
|
||||||
private func runNativePanel(
|
/// The pre-modal half of `runNativePanel` — a fully configured
|
||||||
queue: String,
|
/// `NSPrintInfo` plus the `boundViaPM` flag. Extracted (same
|
||||||
displayName: String?,
|
/// pattern as `NativeTargetSpooler.makePrintInfo`) so unit tests
|
||||||
optionKeys: Set<String>,
|
/// assert ticket restore / Stage 2 precedence without driving the
|
||||||
initialSelections: PrintPanelInitialSelections
|
/// modal (#217). The caller owns `printer` — it must stay alive
|
||||||
) throws -> PanelCaptureResult? {
|
/// through the modal.
|
||||||
|
func makePanelPrintInfo(
|
||||||
|
for request: PrintPanelRequest,
|
||||||
|
boundTo printer: PMPrinter?
|
||||||
|
) throws -> (printInfo: NSPrintInfo, boundViaPM: Bool) {
|
||||||
let printInfo = NSPrintInfo()
|
let printInfo = NSPrintInfo()
|
||||||
var pmPrinter: PMPrinter?
|
|
||||||
var boundViaPM = false
|
|
||||||
|
|
||||||
// ① Bind the session to the selected CUPS queue (docs/11).
|
// ① Bind the session to the selected CUPS queue (docs/11).
|
||||||
if let printer = PMTicketBridge.makePrinter(queue: queue) {
|
if let printer {
|
||||||
pmPrinter = printer
|
try PMTicketBridge.bind(printer: printer, to: printInfo)
|
||||||
let session = PMTicketBridge.session(printInfo)
|
configureBoundPanel(
|
||||||
let settings = PMTicketBridge.settings(printInfo)
|
printInfo, request: request, printer: printer)
|
||||||
|
|
||||||
do {
|
|
||||||
try PMTicketBridge.bind(printer: printer, to: printInfo)
|
|
||||||
} catch {
|
|
||||||
PMTicketBridge.release(printer)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
// Initial selections — after `PMSessionDefault*`, before
|
|
||||||
// ColorSync suppression ②–⑤ (locked write order,
|
|
||||||
// #183/#186). Paper is TWO writes (E1): the `PageSize`
|
|
||||||
// print-settings value drivers/capture read AND the
|
|
||||||
// `PMPageFormat` paper the panel's dropdown reflects.
|
|
||||||
applyInitialSelections(
|
|
||||||
initialSelections, to: settings, optionKeys: optionKeys)
|
|
||||||
if let paperToken = initialSelections.paperSize {
|
|
||||||
PMTicketBridge.applyPaper(
|
|
||||||
token: paperToken, printer: printer,
|
|
||||||
session: session, printInfo: printInfo)
|
|
||||||
}
|
|
||||||
boundViaPM = true
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback: NSPrinter by display name (docs/11 §binding).
|
// Fallback: NSPrinter by display name (docs/11 §binding).
|
||||||
// Warn — the display name can resolve a *different* queue
|
// Warn — the display name can resolve a *different* queue
|
||||||
// (#186 E2: diagnosable, not a proven defect).
|
// (#186 E2: diagnosable, not a proven defect).
|
||||||
AppLogger.shared.warn(
|
AppLogger.shared.warn(
|
||||||
"Print panel: PM binding unavailable for '\(queue)' — "
|
"Print panel: PM binding unavailable for "
|
||||||
|
+ "'\(request.queue)' — "
|
||||||
+ "falling back to NSPrinter(displayName)")
|
+ "falling back to NSPrinter(displayName)")
|
||||||
guard let displayName,
|
guard let displayName = request.displayName,
|
||||||
let nsPrinter = NSPrinter(name: displayName)
|
let nsPrinter = NSPrinter(name: displayName)
|
||||||
else {
|
else {
|
||||||
throw PrintPanelError.noPrinterFound(
|
throw PrintPanelError.noPrinterFound(
|
||||||
displayName ?? queue)
|
request.displayName ?? request.queue)
|
||||||
}
|
}
|
||||||
printInfo.printer = nsPrinter
|
printInfo.printer = nsPrinter
|
||||||
printInfo.setUpPrintOperationDefaultValues()
|
printInfo.setUpPrintOperationDefaultValues()
|
||||||
|
return (printInfo, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (printInfo, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post-bind configuration in locked order (#217 — mirrors the
|
||||||
|
/// spooler's S4→S7): ticket restore → Stage 2 selections →
|
||||||
|
/// validate → paper → suppression ②–⑤. Internal so unit tests can
|
||||||
|
/// drive it with `printer: nil` — `applyPaper` and the SPI layer
|
||||||
|
/// need a bound printer and skip when it is absent, while ticket
|
||||||
|
/// restore / selections / validate still run on the session-less
|
||||||
|
/// `NSPrintInfo`.
|
||||||
|
func configureBoundPanel(
|
||||||
|
_ printInfo: NSPrintInfo,
|
||||||
|
request: PrintPanelRequest,
|
||||||
|
printer: PMPrinter?
|
||||||
|
) {
|
||||||
|
let session = PMTicketBridge.session(printInfo)
|
||||||
|
let settings = PMTicketBridge.settings(printInfo)
|
||||||
|
|
||||||
|
// Rehydrate the captured ticket *before* the Stage 2 writes
|
||||||
|
// (#217 — restore-then-override). Warn-only, unlike the
|
||||||
|
// spooler's fatal `try`: a stale/corrupt ticket must not keep
|
||||||
|
// the dialog from opening. `restore` itself refuses a
|
||||||
|
// cross-queue replay (R3).
|
||||||
|
if let ticket = request.ticket {
|
||||||
|
do {
|
||||||
|
try PMTicketBridge.restore(ticket, into: printInfo)
|
||||||
|
} catch {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: ticket restore failed — "
|
||||||
|
+ "opening on queue defaults "
|
||||||
|
+ "(\(error.localizedDescription))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial selections — Stage 2 wins over the restored ticket
|
||||||
|
// (D6 parity). Paper is TWO writes (E1): the `PageSize`
|
||||||
|
// print-settings value drivers/capture read AND the
|
||||||
|
// `PMPageFormat` paper the panel's dropdown reflects.
|
||||||
|
applyInitialSelections(
|
||||||
|
request.initialSelections, to: settings,
|
||||||
|
optionKeys: request.optionKeys)
|
||||||
|
|
||||||
|
// Reconcile the pre-writes into the ticket before the modal
|
||||||
|
// (#217 RC2): the vendor PDE derives pane state from a
|
||||||
|
// validated ticket. Warn-only — log when the driver adjusts
|
||||||
|
// so a companion-key desync is diagnosable.
|
||||||
|
var changed = DarwinBoolean(false)
|
||||||
|
_ = PMSessionValidatePrintSettings(session, settings, &changed)
|
||||||
|
if changed.boolValue {
|
||||||
|
AppLogger.shared.info(
|
||||||
|
"Print panel: driver adjusted the pre-selections")
|
||||||
|
}
|
||||||
|
printInfo.updateFromPMPrintSettings()
|
||||||
|
|
||||||
|
if let printer, let paperToken = request.initialSelections.paperSize {
|
||||||
|
PMTicketBridge.applyPaper(
|
||||||
|
token: paperToken, printer: printer,
|
||||||
|
session: session, printInfo: printInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ②–⑤ ColourSync suppression. The SPI needs a session with a
|
||||||
|
// current printer to attach to; the SetValue/mirror layers
|
||||||
|
// are warn-only and run regardless.
|
||||||
|
if printer != nil {
|
||||||
|
suppressor.applySPIMode(to: session) // ②
|
||||||
|
}
|
||||||
|
suppressor.applyLockedKeys(to: settings) // ③
|
||||||
|
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
|
||||||
|
let driverBypass = suppressor.applyDriverBypass( // ④
|
||||||
|
to: settings, optionKeys: request.optionKeys)
|
||||||
|
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runNativePanel(
|
||||||
|
_ request: PrintPanelRequest
|
||||||
|
) throws -> PanelCaptureResult? {
|
||||||
|
// The PM printer handle is held through the modal and
|
||||||
|
// released on every path.
|
||||||
|
let pmPrinter = PMTicketBridge.makePrinter(queue: request.queue)
|
||||||
defer {
|
defer {
|
||||||
if let printer = pmPrinter {
|
if let printer = pmPrinter {
|
||||||
PMTicketBridge.release(printer)
|
PMTicketBridge.release(printer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ②–⑤ ColourSync suppression — only on the PM path: the SPI
|
let (printInfo, boundViaPM) = try makePanelPrintInfo(
|
||||||
// and PMPrintSettingsSetValue need a session with a current
|
for: request, boundTo: pmPrinter)
|
||||||
// printer to attach to.
|
|
||||||
var settings = PMTicketBridge.settings(printInfo)
|
debugDump(printInfo, when: "pre-modal")
|
||||||
var driverBypass: (key: String, value: String)?
|
|
||||||
if boundViaPM {
|
|
||||||
let session = PMTicketBridge.session(printInfo)
|
|
||||||
suppressor.applySPIMode(to: session) // ②
|
|
||||||
suppressor.applyLockedKeys(to: settings) // ③
|
|
||||||
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
|
|
||||||
driverBypass = suppressor.applyDriverBypass( // ④
|
|
||||||
to: settings, optionKeys: optionKeys)
|
|
||||||
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
|
|
||||||
}
|
|
||||||
|
|
||||||
let panel = NSPrintPanel()
|
let panel = NSPrintPanel()
|
||||||
panel.options = [
|
panel.options = [
|
||||||
@@ -173,27 +256,35 @@ struct PrintPanelService {
|
|||||||
// (#183); the PDE may rewrite or drop them (R12).
|
// (#183); the PDE may rewrite or drop them (R12).
|
||||||
var cupsOptions: String?
|
var cupsOptions: String?
|
||||||
var mediaType: String?
|
var mediaType: String?
|
||||||
|
var trayToken: String?
|
||||||
if boundViaPM {
|
if boundViaPM {
|
||||||
settings = PMTicketBridge.settings(printInfo)
|
debugDump(printInfo, when: "post-OK")
|
||||||
let captured = suppressor.captureOptions(from: settings)
|
let captured = suppressor.captureOptions(
|
||||||
|
from: PMTicketBridge.settings(printInfo),
|
||||||
|
trayKey: request.initialSelections.trayKey)
|
||||||
cupsOptions = captured.cupsOptions
|
cupsOptions = captured.cupsOptions
|
||||||
mediaType = captured.mediaType
|
mediaType = captured.mediaType
|
||||||
|
trayToken = captured.tray
|
||||||
}
|
}
|
||||||
let capturedOptions = cupsOptions ?? ""
|
let capturedOptions = cupsOptions ?? ""
|
||||||
let resolvedQueue = boundViaPM
|
let resolvedQueue = boundViaPM
|
||||||
? PMTicketBridge.currentPrinterID(
|
? PMTicketBridge.currentPrinterID(
|
||||||
session: PMTicketBridge.session(printInfo),
|
session: PMTicketBridge.session(printInfo),
|
||||||
fallback: queue)
|
fallback: request.queue)
|
||||||
: queue
|
: request.queue
|
||||||
// ⑦ Serialise the native ticket — the payload `lp -o` could
|
// ⑦ Serialise the native ticket — the payload `lp -o` could
|
||||||
// never carry (#201). Warn-only via `try?`: a serialise
|
// never carry (#201). Warn-only via `try?`: a serialise
|
||||||
// failure must not lose the Stage 2 mirror above.
|
// failure must not lose the Stage 2 mirror above.
|
||||||
let ticket = try? PMTicketBridge.serialise(
|
let ticket = try? PMTicketBridge.serialise(
|
||||||
printInfo, queue: resolvedQueue)
|
printInfo, queue: resolvedQueue)
|
||||||
|
let paperSource = request.trays.first(where: {
|
||||||
|
$0.token == trayToken
|
||||||
|
}).map(\.id)
|
||||||
return PanelCaptureResult(
|
return PanelCaptureResult(
|
||||||
properties: PrintPropertiesResult(
|
properties: PrintPropertiesResult(
|
||||||
selectedPrinter: boundViaPM ? resolvedQueue : nil,
|
selectedPrinter: boundViaPM ? resolvedQueue : nil,
|
||||||
options: PrintOptions(
|
options: PrintOptions(
|
||||||
|
paperSource: paperSource,
|
||||||
orientation: CupsParsers.extractOrientation(
|
orientation: CupsParsers.extractOrientation(
|
||||||
fromOptionsString: capturedOptions),
|
fromOptionsString: capturedOptions),
|
||||||
paperSize: CupsParsers.extractOption(
|
paperSize: CupsParsers.extractOption(
|
||||||
@@ -207,10 +298,10 @@ struct PrintPanelService {
|
|||||||
ticket: ticket)
|
ticket: ticket)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initial-selection `PMPrintSettings` writes — paper, quality,
|
/// Initial-selection `PMPrintSettings` writes — paper, tray,
|
||||||
/// media type, orientation. All warn-only: a driver that ignores
|
/// quality, media type, orientation. All warn-only: a driver that
|
||||||
/// a key must not keep the panel from opening (R12 surfaces via
|
/// ignores a key must not keep the panel from opening (R12 surfaces
|
||||||
/// the capture echo instead).
|
/// via the capture echo instead).
|
||||||
private func applyInitialSelections(
|
private func applyInitialSelections(
|
||||||
_ selections: PrintPanelInitialSelections,
|
_ selections: PrintPanelInitialSelections,
|
||||||
to settings: PMPrintSettings,
|
to settings: PMPrintSettings,
|
||||||
@@ -221,6 +312,13 @@ struct PrintPanelService {
|
|||||||
paperSize, forKey: "PageSize", locked: false,
|
paperSize, forKey: "PageSize", locked: false,
|
||||||
in: settings, context: "Print panel")
|
in: settings, context: "Print panel")
|
||||||
}
|
}
|
||||||
|
// Paper source via the queue's detected tray roster key (#218).
|
||||||
|
if let tray = selections.tray, !tray.isEmpty,
|
||||||
|
let trayKey = selections.trayKey {
|
||||||
|
PMTicketBridge.setValue(
|
||||||
|
tray, forKey: trayKey, locked: false,
|
||||||
|
in: settings, context: "Print panel")
|
||||||
|
}
|
||||||
if let key = selections.qualityKey, let value = selections.quality {
|
if let key = selections.qualityKey, let value = selections.quality {
|
||||||
PMTicketBridge.setValue(
|
PMTicketBridge.setValue(
|
||||||
value, forKey: key, locked: false,
|
value, forKey: key, locked: false,
|
||||||
@@ -243,4 +341,35 @@ struct PrintPanelService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ICCERY_DEBUG_PANEL_DUMP=1` — log the unfiltered
|
||||||
|
/// `PMPrintSettingsToOptions` output and the serialised
|
||||||
|
/// settings/page-format byte counts (#217 diagnostics for the
|
||||||
|
/// Epson RC3 question: does the PDE source pane state from the
|
||||||
|
/// session ticket or lpoptions?). DEBUG-only, warn-only.
|
||||||
|
private func debugDump(_ printInfo: NSPrintInfo, when phase: String) {
|
||||||
|
#if DEBUG
|
||||||
|
guard ProcessInfo.processInfo.environment[
|
||||||
|
"ICCERY_DEBUG_PANEL_DUMP"] == "1" else { return }
|
||||||
|
let raw = suppressor.rawOptionsDump(
|
||||||
|
from: PMTicketBridge.settings(printInfo)) ?? "<unavailable>"
|
||||||
|
var settingsSize = -1
|
||||||
|
var ref: Unmanaged<CFData>?
|
||||||
|
if PMPrintSettingsCreateDataRepresentation(
|
||||||
|
PMTicketBridge.settings(printInfo), &ref,
|
||||||
|
kPMDataFormatXMLDefault) == noErr, let ref {
|
||||||
|
settingsSize = (ref.takeRetainedValue() as Data).count
|
||||||
|
}
|
||||||
|
var formatSize = -1
|
||||||
|
var formatRef: Unmanaged<CFData>?
|
||||||
|
if PMPageFormatCreateDataRepresentation(
|
||||||
|
PMTicketBridge.pageFormat(printInfo), &formatRef,
|
||||||
|
kPMDataFormatXMLDefault) == noErr, let formatRef {
|
||||||
|
formatSize = (formatRef.takeRetainedValue() as Data).count
|
||||||
|
}
|
||||||
|
AppLogger.shared.info(
|
||||||
|
"Print panel dump (\(phase)): settings=\(settingsSize)B "
|
||||||
|
+ "pageFormat=\(formatSize)B options: \(raw)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,24 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Published var printerCaps = PrinterCapabilities()
|
@Published var printerCaps = PrinterCapabilities()
|
||||||
@Published var selectedTray: Int?
|
@Published var selectedTray: Int? {
|
||||||
@Published var selectedMediaType: String?
|
didSet {
|
||||||
|
// A tray switch can invalidate the current quality pick
|
||||||
|
// using the tray-specific Epson constraint map (#218).
|
||||||
|
if selectedTray != oldValue {
|
||||||
|
clampQualityToMedia()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published var selectedMediaType: String? {
|
||||||
|
didSet {
|
||||||
|
// A media switch can invalidate the current quality pick —
|
||||||
|
// re-clamp into the driver's allowed set (#214).
|
||||||
|
if selectedMediaType != oldValue {
|
||||||
|
clampQualityToMedia()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
/// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183).
|
/// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183).
|
||||||
@Published var selectedPaperSize: Int?
|
@Published var selectedPaperSize: Int?
|
||||||
/// Print-quality option token, e.g. `"303"` (#183).
|
/// Print-quality option token, e.g. `"303"` (#183).
|
||||||
@@ -120,12 +136,22 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
if selectedMediaType == nil {
|
if selectedMediaType == nil {
|
||||||
selectedMediaType = printerCaps.mediaTypes.first?.id
|
selectedMediaType = printerCaps.mediaTypes.first?.id
|
||||||
}
|
}
|
||||||
if selectedTray == nil {
|
if selectedTray == nil || !printerCaps.trays
|
||||||
selectedTray = printerCaps.trays.first?.id
|
.contains(where: { $0.id == selectedTray }) {
|
||||||
|
selectedTray = printerCaps.trayDefault.flatMap { token in
|
||||||
|
printerCaps.trays.first { $0.token == token }?.id
|
||||||
|
} ?? printerCaps.trays.first?.id
|
||||||
}
|
}
|
||||||
|
// Quality seeds inside the selected media's allowed set
|
||||||
|
// (#214): driver default when valid there, else the first
|
||||||
|
// allowed choice.
|
||||||
if selectedQuality == nil {
|
if selectedQuality == nil {
|
||||||
selectedQuality = printerCaps.qualityDefault
|
let allowed = availableQualities
|
||||||
?? printerCaps.qualities.first?.id
|
selectedQuality = printerCaps.qualityDefault.flatMap { d in
|
||||||
|
allowed.contains(where: { $0.id == d }) ? d : nil
|
||||||
|
} ?? allowed.first?.id
|
||||||
|
} else {
|
||||||
|
clampQualityToMedia()
|
||||||
}
|
}
|
||||||
// Caps reload is a re-mirror trigger for the paper picker
|
// Caps reload is a re-mirror trigger for the paper picker
|
||||||
// (#183 E4) — pageSize + printer changes route here too.
|
// (#183 E4) — pageSize + printer changes route here too.
|
||||||
@@ -137,6 +163,50 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Paper / quality selection (#183)
|
// MARK: - Paper / quality selection (#183)
|
||||||
|
|
||||||
|
/// The raw tray token for the currently selected source (#218).
|
||||||
|
var selectedTrayToken: String? {
|
||||||
|
guard let id = selectedTray else { return nil }
|
||||||
|
return printerCaps.trays.first { $0.id == id }?.token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The quality picker's source (#214/#218): `printerCaps.qualities`
|
||||||
|
/// filtered to what the driver accepts for `selectedMediaType` and
|
||||||
|
/// the current tray token. Equals the full list whenever the queue
|
||||||
|
/// exposes no per-media/per-tray quality matrix — and never empty
|
||||||
|
/// while `qualities` is not.
|
||||||
|
var availableQualities: [PrinterQuality] {
|
||||||
|
printerCaps.qualities(
|
||||||
|
forMediaType: selectedMediaType,
|
||||||
|
trayToken: selectedTrayToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep `selectedQuality` inside the allowed set for the current
|
||||||
|
/// media and tray: keep the pick when still valid, else the driver
|
||||||
|
/// default when valid, else the first allowed choice (#214/#218).
|
||||||
|
private func clampQualityToMedia() {
|
||||||
|
let allowed = availableQualities
|
||||||
|
guard !allowed.isEmpty else { return }
|
||||||
|
if let quality = selectedQuality,
|
||||||
|
allowed.contains(where: { $0.id == quality }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let fallback = printerCaps.qualityDefault,
|
||||||
|
allowed.contains(where: { $0.id == fallback }) {
|
||||||
|
selectedQuality = fallback
|
||||||
|
} else {
|
||||||
|
selectedQuality = allowed.first?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tray token for a captured option string, used both for
|
||||||
|
/// applying panel results and for synthesising unknown selections.
|
||||||
|
private func capturedTrayToken(from options: String?) -> String? {
|
||||||
|
guard let options else { return nil }
|
||||||
|
return CupsParsers.extractTray(
|
||||||
|
fromOptionsString: options,
|
||||||
|
preferredKey: printerCaps.trayKey)
|
||||||
|
}
|
||||||
|
|
||||||
/// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
|
/// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
|
||||||
/// a capability whose name matches `pageSize.rawValue` → its id;
|
/// a capability whose name matches `pageSize.rawValue` → its id;
|
||||||
/// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
|
/// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
|
||||||
@@ -186,17 +256,22 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
let cups = environment.cupsService
|
let cups = environment.cupsService
|
||||||
let selections = PrintPanelInitialSelections(
|
let selections = PrintPanelInitialSelections(
|
||||||
paperSize: selectedPaperSizeToken,
|
paperSize: selectedPaperSizeToken,
|
||||||
|
trayKey: printerCaps.trayKey,
|
||||||
|
tray: selectedTrayToken,
|
||||||
qualityKey: printerCaps.qualityKey,
|
qualityKey: printerCaps.qualityKey,
|
||||||
quality: selectedQuality,
|
quality: selectedQuality,
|
||||||
mediaType: selectedMediaType,
|
mediaType: selectedMediaType,
|
||||||
orientation: printOrientation)
|
orientation: printOrientation)
|
||||||
|
let ticket = capturedTickets[queue]
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
guard let result = try await PrintPanelService()
|
guard let result = try await PrintPanelService()
|
||||||
.showProperties(
|
.showProperties(
|
||||||
queue: queue, displayName: displayName,
|
queue: queue, displayName: displayName,
|
||||||
cupsService: cups,
|
cupsService: cups,
|
||||||
initialSelections: selections)
|
initialSelections: selections,
|
||||||
|
ticket: ticket,
|
||||||
|
trays: printerCaps.trays)
|
||||||
else {
|
else {
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
kind: .info,
|
kind: .info,
|
||||||
@@ -221,6 +296,28 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
if let media = result.properties.options.mediaType {
|
if let media = result.properties.options.mediaType {
|
||||||
selectedMediaType = media
|
selectedMediaType = media
|
||||||
}
|
}
|
||||||
|
// Capture-return paper source (#218): a dialog tray
|
||||||
|
// change updates Stage 2; unknown tokens are appended
|
||||||
|
// so a driver-only source remains selectable.
|
||||||
|
if let options = result.properties.options.cupsOptions,
|
||||||
|
let trayToken = capturedTrayToken(from: options),
|
||||||
|
!trayToken.isEmpty {
|
||||||
|
if let known = printerCaps.trays
|
||||||
|
.first(where: { $0.token == trayToken }) {
|
||||||
|
selectedTray = known.id
|
||||||
|
} else {
|
||||||
|
let nextID = (printerCaps.trays.map(\.id).max() ?? 0) + 1
|
||||||
|
let synthetic = PrinterTray(
|
||||||
|
id: nextID, token: trayToken, name: trayToken)
|
||||||
|
printerCaps.trays.append(synthetic)
|
||||||
|
selectedTray = nextID
|
||||||
|
}
|
||||||
|
} else if let paperSource = result.properties.options.paperSource,
|
||||||
|
printerCaps.trays.contains(where: {
|
||||||
|
$0.id == paperSource
|
||||||
|
}) {
|
||||||
|
selectedTray = paperSource
|
||||||
|
}
|
||||||
// Capture-return (#183/#186): a dialog paper/quality/
|
// Capture-return (#183/#186): a dialog paper/quality/
|
||||||
// orientation change updates the Stage 2 selections —
|
// orientation change updates the Stage 2 selections —
|
||||||
// never `workflow.pageSize` (printtarg layout is
|
// never `workflow.pageSize` (printtarg layout is
|
||||||
@@ -230,7 +327,12 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
.first(where: { $0.name == paper }) {
|
.first(where: { $0.name == paper }) {
|
||||||
selectedPaperSize = match.id
|
selectedPaperSize = match.id
|
||||||
}
|
}
|
||||||
if let quality = result.properties.options.quality {
|
// A captured quality the driver rejects for the
|
||||||
|
// (possibly just-captured) media is dropped — keeping
|
||||||
|
// it would only re-create a failed print (#214).
|
||||||
|
if let quality = result.properties.options.quality,
|
||||||
|
printerCaps.allowsQuality(
|
||||||
|
quality, forMediaType: selectedMediaType) {
|
||||||
selectedQuality = quality
|
selectedQuality = quality
|
||||||
}
|
}
|
||||||
if let orientation = result.properties.options.orientation {
|
if let orientation = result.properties.options.orientation {
|
||||||
@@ -358,6 +460,17 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
let optionKeys = (try? await environment.cupsService
|
let optionKeys = (try? await environment.cupsService
|
||||||
.optionKeys(for: queue)) ?? []
|
.optionKeys(for: queue)) ?? []
|
||||||
attachDiagnostics()
|
attachDiagnostics()
|
||||||
|
// Defence-in-depth (#214): an invalid media+quality pair can
|
||||||
|
// never reach the ticket — substitute the first allowed
|
||||||
|
// quality when the pick is stale (UI already clamps; this is
|
||||||
|
// the last gate before the driver).
|
||||||
|
var quality = selectedQuality
|
||||||
|
if let q = quality,
|
||||||
|
!printerCaps.allowsQuality(
|
||||||
|
q, forMediaType: selectedMediaType,
|
||||||
|
trayToken: selectedTrayToken) {
|
||||||
|
quality = availableQualities.first?.id ?? q
|
||||||
|
}
|
||||||
return TargetPrintRequest(
|
return TargetPrintRequest(
|
||||||
queue: queue,
|
queue: queue,
|
||||||
displayName: printers.first { $0.name == queue }?.displayName,
|
displayName: printers.first { $0.name == queue }?.displayName,
|
||||||
@@ -373,7 +486,9 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
paperSize: selectedPaperSizeToken,
|
paperSize: selectedPaperSizeToken,
|
||||||
mediaType: selectedMediaType,
|
mediaType: selectedMediaType,
|
||||||
qualityKey: printerCaps.qualityKey,
|
qualityKey: printerCaps.qualityKey,
|
||||||
quality: selectedQuality,
|
quality: quality,
|
||||||
|
trayKey: printerCaps.trayKey,
|
||||||
|
tray: selectedTrayToken,
|
||||||
orientation: printOrientation),
|
orientation: printOrientation),
|
||||||
optionKeys: optionKeys)
|
optionKeys: optionKeys)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ struct TargetPrintOverrides: Equatable {
|
|||||||
var mediaType: String?
|
var mediaType: String?
|
||||||
var qualityKey: String? // PrinterCapabilities.qualityKey
|
var qualityKey: String? // PrinterCapabilities.qualityKey
|
||||||
var quality: String?
|
var quality: String?
|
||||||
|
var trayKey: String? // PrinterCapabilities.trayKey
|
||||||
|
var tray: String? // raw tray token (#218)
|
||||||
var orientation: String? // "portrait" | "landscape"
|
var orientation: String? // "portrait" | "landscape"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,10 +44,11 @@ enum TicketWriteResolver {
|
|||||||
/// 5 `com.apple.print.PrintSettings.PMColorMatchingMode`
|
/// 5 `com.apple.print.PrintSettings.PMColorMatchingMode`
|
||||||
/// = APCustomColorMatching (unlocked)
|
/// = APCustomColorMatching (unlocked)
|
||||||
/// 6 `PageSize` = overrides.paperSize
|
/// 6 `PageSize` = overrides.paperSize
|
||||||
/// 7 detectMediaTypeKey(optionKeys) = overrides.mediaType
|
/// 7 overrides.trayKey = overrides.tray
|
||||||
/// 8 overrides.qualityKey = overrides.quality
|
/// 8 detectMediaTypeKey(optionKeys) = overrides.mediaType
|
||||||
/// 9 detectDriverColorBypass(optionKeys) (unlocked)
|
/// 9 overrides.qualityKey = overrides.quality
|
||||||
/// 10 `orientation-requested` = 3 | 4
|
/// 10 detectDriverColorBypass(optionKeys) (unlocked)
|
||||||
|
/// 11 `orientation-requested` = 3 | 4
|
||||||
///
|
///
|
||||||
/// Invariants: `raw` can never appear (there is no `lp`); nothing
|
/// Invariants: `raw` can never appear (there is no `lp`); nothing
|
||||||
/// is suppressed because a captured key already exists (D6 — the
|
/// is suppressed because a captured key already exists (D6 — the
|
||||||
@@ -87,7 +90,14 @@ enum TicketWriteResolver {
|
|||||||
key: "PageSize", value: paperSize, locked: false))
|
key: "PageSize", value: paperSize, locked: false))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7 — media type via the queue's detected vendor key.
|
// 7 — paper source via the queue's detected tray roster key.
|
||||||
|
if let trayKey = overrides.trayKey, !trayKey.isEmpty,
|
||||||
|
let tray = overrides.tray, !tray.isEmpty {
|
||||||
|
writes.append(TicketWrite(
|
||||||
|
key: trayKey, value: tray, locked: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8 — media type via the queue's detected vendor key.
|
||||||
if let mediaType = overrides.mediaType,
|
if let mediaType = overrides.mediaType,
|
||||||
let mediaKey = CupsParsers.detectMediaTypeKey(
|
let mediaKey = CupsParsers.detectMediaTypeKey(
|
||||||
optionKeys: optionKeys) {
|
optionKeys: optionKeys) {
|
||||||
@@ -95,7 +105,7 @@ enum TicketWriteResolver {
|
|||||||
key: mediaKey, value: mediaType, locked: false))
|
key: mediaKey, value: mediaType, locked: false))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8 — print quality; the detected enumeration key travels in
|
// 9 — print quality; the detected enumeration key travels in
|
||||||
// the overrides (`PrinterCapabilities.qualityKey`, #183).
|
// the overrides (`PrinterCapabilities.qualityKey`, #183).
|
||||||
if let qualityKey = overrides.qualityKey,
|
if let qualityKey = overrides.qualityKey,
|
||||||
let quality = overrides.quality {
|
let quality = overrides.quality {
|
||||||
@@ -103,7 +113,7 @@ enum TicketWriteResolver {
|
|||||||
key: qualityKey, value: quality, locked: false))
|
key: qualityKey, value: quality, locked: false))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9 — driver "no colour adjustment" bypass. Never gated on
|
// 10 — driver "no colour adjustment" bypass. Never gated on
|
||||||
// `ppdUncorrectedPassthrough` — macOS always bypasses.
|
// `ppdUncorrectedPassthrough` — macOS always bypasses.
|
||||||
if let bypass = CupsParsers.detectDriverColorBypass(
|
if let bypass = CupsParsers.detectDriverColorBypass(
|
||||||
optionKeys: optionKeys) {
|
optionKeys: optionKeys) {
|
||||||
@@ -111,7 +121,7 @@ enum TicketWriteResolver {
|
|||||||
key: bypass.key, value: bypass.value, locked: false))
|
key: bypass.key, value: bypass.value, locked: false))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10 — orientation, CUPS IPP codes portrait=3 / landscape=4.
|
// 11 — orientation, CUPS IPP codes portrait=3 / landscape=4.
|
||||||
if let orientation = overrides.orientation {
|
if let orientation = overrides.orientation {
|
||||||
writes.append(TicketWrite(
|
writes.append(TicketWrite(
|
||||||
key: "orientation-requested",
|
key: "orientation-requested",
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ struct Stage2View: View {
|
|||||||
// ViewBuilder ≤10 children (R13).
|
// ViewBuilder ≤10 children (R13).
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
if !workflow.print.printerCaps.trays.isEmpty {
|
if !workflow.print.printerCaps.trays.isEmpty {
|
||||||
Picker("Tray", selection: $workflow.print.selectedTray) {
|
Picker("Paper source", selection: $workflow.print.selectedTray) {
|
||||||
ForEach(workflow.print.printerCaps.trays, id: \.id) {
|
ForEach(workflow.print.printerCaps.trays, id: \.id) {
|
||||||
Text($0.name).tag(Optional($0.id))
|
Text($0.name).tag(Optional($0.id))
|
||||||
}
|
}
|
||||||
@@ -322,16 +322,12 @@ struct Stage2View: View {
|
|||||||
if !workflow.print.printerCaps.qualities.isEmpty {
|
if !workflow.print.printerCaps.qualities.isEmpty {
|
||||||
qualityGroup
|
qualityGroup
|
||||||
}
|
}
|
||||||
HStack(spacing: 0) {
|
Picker("Orientation", selection: $workflow.print.printOrientation) {
|
||||||
Button("Portrait") { workflow.print.printOrientation = "portrait" }
|
Text("Portrait").tag("portrait")
|
||||||
.buttonStyle(.bordered)
|
Text("Landscape").tag("landscape")
|
||||||
.tint(workflow.print.printOrientation == "portrait" ? .accentColor : .gray)
|
|
||||||
.accessibilityIdentifier("btnOrientPortrait")
|
|
||||||
Button("Landscape") { workflow.print.printOrientation = "landscape" }
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.tint(workflow.print.printOrientation == "landscape" ? .accentColor : .gray)
|
|
||||||
.accessibilityIdentifier("btnOrientLandscape")
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: 200)
|
||||||
|
.accessibilityIdentifier("printerOrientationSelect")
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
// Stage 1 owns the custom dimensions — the caption lives
|
// Stage 1 owns the custom dimensions — the caption lives
|
||||||
@@ -410,11 +406,13 @@ struct Stage2View: View {
|
|||||||
.accessibilityIdentifier("paperSizeGroup")
|
.accessibilityIdentifier("paperSizeGroup")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quality picker — driver tokens with PPD-enriched labels (#183).
|
/// Quality picker — driver tokens with PPD-enriched labels (#183),
|
||||||
|
/// filtered to what the driver accepts for the selected media
|
||||||
|
/// (#214).
|
||||||
private var qualityGroup: some View {
|
private var qualityGroup: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Picker("Quality", selection: $workflow.print.selectedQuality) {
|
Picker("Quality", selection: $workflow.print.selectedQuality) {
|
||||||
ForEach(workflow.print.printerCaps.qualities, id: \.id) {
|
ForEach(workflow.print.availableQualities, id: \.id) {
|
||||||
Text($0.name).tag(Optional($0.id))
|
Text($0.name).tag(Optional($0.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,28 @@ final class CupsOptionsFilterTests: XCTestCase {
|
|||||||
XCTAssertNil(CupsParsers.extractMediaType(
|
XCTAssertNil(CupsParsers.extractMediaType(
|
||||||
fromOptionsString: "PageSize=A4"))
|
fromOptionsString: "PageSize=A4"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #218 — vendor paper-source keys survive the filter, and an
|
||||||
|
/// empty `AP_D_InputSlot=` is still dropped.
|
||||||
|
func testKeepsTrayKeys() {
|
||||||
|
let raw = "InputSlot=Rear MediaSource=Front EPIJ_FdSo=Rear "
|
||||||
|
+ "EPIJ_PSrc=Front CNIJMediaSupply=1 AP_D_InputSlot=Auto"
|
||||||
|
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
||||||
|
|
||||||
|
let emptyAlias = "AP_D_InputSlot= InputSlot=Rear"
|
||||||
|
XCTAssertEqual(CupsOptionsFilter.filter(emptyAlias), "InputSlot=Rear")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExtractTray() {
|
||||||
|
XCTAssertEqual(CupsParsers.extractTray(
|
||||||
|
fromOptionsString: "EPIJ_FdSo=Rear EPIJ_PSrc=Front",
|
||||||
|
preferredKey: "EPIJ_FdSo"), "Rear")
|
||||||
|
XCTAssertEqual(CupsParsers.extractTray(
|
||||||
|
fromOptionsString: "AP_D_InputSlot=Auto InputSlot=Rear",
|
||||||
|
preferredKey: "InputSlot"), "Rear")
|
||||||
|
XCTAssertNil(CupsParsers.extractTray(
|
||||||
|
fromOptionsString: "PageSize=A4"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Issue 14 — the dlsym attempt order and first-success semantics.
|
/// Issue 14 — the dlsym attempt order and first-success semantics.
|
||||||
|
|||||||
@@ -83,11 +83,13 @@ final class CupsParserTests: XCTestCase {
|
|||||||
let caps = service.capabilities(from: listings, ppd: nil)
|
let caps = service.capabilities(from: listings, ppd: nil)
|
||||||
|
|
||||||
XCTAssertEqual(caps.trays, [
|
XCTAssertEqual(caps.trays, [
|
||||||
PrinterTray(id: 1, name: "Auto"),
|
PrinterTray(id: 1, token: "Auto", name: "Auto"),
|
||||||
PrinterTray(id: 2, name: "Main"),
|
PrinterTray(id: 2, token: "Main", name: "Main"),
|
||||||
PrinterTray(id: 3, name: "Photo"),
|
PrinterTray(id: 3, token: "Photo", name: "Photo"),
|
||||||
PrinterTray(id: 4, name: "Rear"),
|
PrinterTray(id: 4, token: "Rear", name: "Rear"),
|
||||||
])
|
])
|
||||||
|
XCTAssertEqual(caps.trayKey, "InputSlot")
|
||||||
|
XCTAssertEqual(caps.trayDefault, "Main")
|
||||||
XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5"))
|
XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5"))
|
||||||
XCTAssertEqual(caps.paperSizes.count, 10)
|
XCTAssertEqual(caps.paperSizes.count, 10)
|
||||||
XCTAssertEqual(caps.mediaTypes.map(\.id), [
|
XCTAssertEqual(caps.mediaTypes.map(\.id), [
|
||||||
@@ -283,6 +285,82 @@ final class CupsParserTests: XCTestCase {
|
|||||||
XCTAssertNil(pair(["PageSize"]))
|
XCTAssertNil(pair(["PageSize"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - #218 paper-source roster
|
||||||
|
|
||||||
|
func testTrayKeyPrecedence() {
|
||||||
|
// Generic keys first; `EPIJ_FdSo` preferred over `EPIJ_PSrc`.
|
||||||
|
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
|
||||||
|
"InputSlot", "EPIJ_FdSo", "EPIJ_PSrc"]), "InputSlot")
|
||||||
|
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
|
||||||
|
"MediaSource", "EPIJ_FdSo", "EPIJ_PSrc"]), "MediaSource")
|
||||||
|
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
|
||||||
|
"EPIJ_FdSo", "EPIJ_PSrc"]), "EPIJ_FdSo")
|
||||||
|
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
|
||||||
|
"EPIJ_PSrc", "CNIJMediaSupply"]), "EPIJ_PSrc")
|
||||||
|
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
|
||||||
|
"CNIJMediaSupply"]), "CNIJMediaSupply")
|
||||||
|
XCTAssertNil(CupsParsers.detectTrayKey(optionKeys: ["PageSize"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExtractTrayPreferredKeyAndRoster() {
|
||||||
|
let preferred = CupsParsers.extractTray(
|
||||||
|
fromOptionsString: "EPIJ_FdSo=Rear EPIJ_PSrc=Front",
|
||||||
|
preferredKey: "EPIJ_FdSo")
|
||||||
|
XCTAssertEqual(preferred, "Rear")
|
||||||
|
|
||||||
|
let fallback = CupsParsers.extractTray(
|
||||||
|
fromOptionsString: "EPIJ_PSrc=Front", preferredKey: "EPIJ_FdSo")
|
||||||
|
XCTAssertEqual(fallback, "Front")
|
||||||
|
|
||||||
|
let alias = CupsParsers.extractTray(
|
||||||
|
fromOptionsString: "AP_D_InputSlot=Auto")
|
||||||
|
XCTAssertEqual(alias, "Auto")
|
||||||
|
|
||||||
|
XCTAssertNil(CupsParsers.extractTray(
|
||||||
|
fromOptionsString: "PageSize=A4"))
|
||||||
|
XCTAssertNil(CupsParsers.extractTray(
|
||||||
|
fromOptionsString: "AP_D_InputSlot="))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTrayExtractionCaseInsensitive() {
|
||||||
|
XCTAssertEqual(CupsParsers.extractTray(
|
||||||
|
fromOptionsString: "epij_fdso=Rear", preferredKey: "EPIJ_FdSo"),
|
||||||
|
"Rear")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCapabilitiesTraysEpson() {
|
||||||
|
let service = CupsService()
|
||||||
|
let listings = CupsParsers.lpoptionsList("""
|
||||||
|
EPIJ_PSrc/Page Setup: *Front Rear Cassette
|
||||||
|
EPIJ_FdSo/Paper Source: Auto *Rear Front
|
||||||
|
""")
|
||||||
|
let caps = service.capabilities(from: listings, ppd: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(caps.trayKey, "EPIJ_FdSo")
|
||||||
|
XCTAssertEqual(caps.trayDefault, "Rear")
|
||||||
|
XCTAssertEqual(caps.trays.map(\.token), ["Auto", "Rear", "Front"])
|
||||||
|
XCTAssertEqual(caps.trays.map(\.name), ["Auto", "Rear", "Front"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCapabilitiesTraysCanon() {
|
||||||
|
let ppd = """
|
||||||
|
*CNIJMediaSupply 1/Cassette (Upper): ""
|
||||||
|
*CNIJMediaSupply 2/Manual Feed: ""
|
||||||
|
*CNIJMediaSupply 4/Rear Tray: ""
|
||||||
|
"""
|
||||||
|
let service = CupsService()
|
||||||
|
let listings = CupsParsers.lpoptionsList("""
|
||||||
|
CNIJMediaSupply/Media Supply: *1 2 4
|
||||||
|
""")
|
||||||
|
let caps = service.capabilities(from: listings, ppd: ppd)
|
||||||
|
|
||||||
|
XCTAssertEqual(caps.trayKey, "CNIJMediaSupply")
|
||||||
|
XCTAssertEqual(caps.trays.map(\.token), ["1", "2", "4"])
|
||||||
|
XCTAssertEqual(caps.trays.map(\.name), [
|
||||||
|
"Cassette (Upper)", "Manual Feed", "Rear Tray"
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - #181 Canon media locale precedence + PPD encoding
|
// MARK: - #181 Canon media locale precedence + PPD encoding
|
||||||
|
|
||||||
/// The 18 Canon Pro9500 media types named in the issue — the ids
|
/// The 18 Canon Pro9500 media types named in the issue — the ids
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import XCTest
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// #214 — media → allowed-quality resolution: generic PPD
|
||||||
|
/// `*UIConstraints`, Epson `PDEData.dat` `*EPIJUIConstraint` rules,
|
||||||
|
/// and the Canon `cnb_*.tbl` record scan. All fixtures inline /
|
||||||
|
/// injected — no installed drivers touched.
|
||||||
|
final class MediaQualityConstraintsTests: XCTestCase {
|
||||||
|
|
||||||
|
/// Epson-flavoured `lpoptions -l` listings: the XP-55 advertises
|
||||||
|
/// the conditional LHS keys (`EPIJ_PSrc`/`EPIJ_FdSo`/`EPIJ_Ink_`)
|
||||||
|
/// with defaults 2/2/1.
|
||||||
|
private var epsonListings: [CupsOptionListing] {
|
||||||
|
CupsParsers.lpoptionsList("""
|
||||||
|
PageSize/Media Size: 4x6 5x7 *A4 Letter
|
||||||
|
EPIJ_Medi/Media Type: *0 92 13 15 145 12 2 75 26 76 93
|
||||||
|
EPIJ_PSrc/Page Setup: *2 3 25
|
||||||
|
EPIJ_FdSo/Paper Source: *2 3 12
|
||||||
|
EPIJ_Ink_/Grayscale: *1 0
|
||||||
|
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var epsonQualityIDs: Set<String> {
|
||||||
|
["301", "302", "303", "304", "305", "307", "308"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ppdKeyword
|
||||||
|
|
||||||
|
func testPPDKeywordQuotedAndBare() {
|
||||||
|
let ppd = """
|
||||||
|
*EPIJDriverBasePath: "/Library/Printers/EPSON/InkjetPrinter2"
|
||||||
|
*EPIJMachineBundleName: "EP14C0605W.data"
|
||||||
|
*CNIJTableID: 354
|
||||||
|
*CNIJTableIDFoo: 999
|
||||||
|
"""
|
||||||
|
XCTAssertEqual(
|
||||||
|
MediaQualityConstraints.ppdKeyword(ppd, "EPIJDriverBasePath"),
|
||||||
|
"/Library/Printers/EPSON/InkjetPrinter2")
|
||||||
|
XCTAssertEqual(
|
||||||
|
MediaQualityConstraints.ppdKeyword(ppd, "EPIJMachineBundleName"),
|
||||||
|
"EP14C0605W.data")
|
||||||
|
XCTAssertEqual(
|
||||||
|
MediaQualityConstraints.ppdKeyword(ppd, "CNIJTableID"), "354")
|
||||||
|
// Prefix-safety: CNIJTableIDFoo must not satisfy the lookup.
|
||||||
|
XCTAssertNil(MediaQualityConstraints.ppdKeyword(ppd, "CNIJTableIDX"))
|
||||||
|
XCTAssertNil(MediaQualityConstraints.ppdKeyword(ppd, "Missing"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Path derivation
|
||||||
|
|
||||||
|
func testEPIJPDEDataPath() {
|
||||||
|
let ppd = """
|
||||||
|
*EPIJDriverBasePath: "/Library/Printers/EPSON/InkjetPrinter2"
|
||||||
|
*EPIJMachineBundleName: "EP14C0605W.data"
|
||||||
|
"""
|
||||||
|
XCTAssertEqual(
|
||||||
|
MediaQualityConstraints.epijPDEDataPath(ppd: ppd)?.path,
|
||||||
|
"/Library/Printers/EPSON/InkjetPrinter2/Machine/"
|
||||||
|
+ "EP14C0605W.data/Contents/Resources/PDEData.dat")
|
||||||
|
XCTAssertNil(MediaQualityConstraints.epijPDEDataPath(
|
||||||
|
ppd: "*CNIJTableID: 354\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCNIJTablePath() {
|
||||||
|
let ppd = """
|
||||||
|
*CNIJNameTblPath: "/Library/Printers/Canon/BJPrinter/Resources/Database/CIJPro9500IIseries.db/Contents/Resources"
|
||||||
|
*CNIJTableID: 354
|
||||||
|
"""
|
||||||
|
XCTAssertEqual(
|
||||||
|
MediaQualityConstraints.cnijTablePath(ppd: ppd)?.path,
|
||||||
|
"/Library/Printers/Canon/BJPrinter/Resources/Database/"
|
||||||
|
+ "CIJPro9500IIseries.db/Contents/Resources/cnb_3540.tbl")
|
||||||
|
XCTAssertNil(MediaQualityConstraints.cnijTablePath(
|
||||||
|
ppd: "*EPIJMachineBundleName: \"x\"\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generic PPD UIConstraints
|
||||||
|
|
||||||
|
func testPPDUIConstraintsBothOrders() {
|
||||||
|
let ppd = """
|
||||||
|
*UIConstraints: *MediaType Glossy *PrintQuality Draft
|
||||||
|
*UIConstraints: *PrintQuality Draft *MediaType Matte
|
||||||
|
*Constraints: *MediaType Glossy *PrintQuality Low
|
||||||
|
*UIConstraints: *InputSlot Rear *PrintQuality Draft
|
||||||
|
"""
|
||||||
|
let forbidden = MediaQualityConstraints.ppdUIConstraints(
|
||||||
|
ppd, mediaKey: "MediaType", qualityKey: "PrintQuality",
|
||||||
|
mediaIDs: ["Glossy", "Matte"],
|
||||||
|
qualityIDs: ["Draft", "Low", "High"])
|
||||||
|
XCTAssertEqual(forbidden["Glossy"], ["Draft", "Low"])
|
||||||
|
XCTAssertEqual(forbidden["Matte"], ["Draft"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Epson EPIJUIConstraint
|
||||||
|
|
||||||
|
/// Realistic XP-55 fragment: pure-media rules plus conditional
|
||||||
|
/// rules on `EPIJ_PSrc`/`EPIJ_Ink_` evaluated against defaults.
|
||||||
|
func testEPIJConstraintsAgainstDefaults() {
|
||||||
|
let dat = """
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 305
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 307
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 308
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 301
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 302
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 303
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 304
|
||||||
|
*EPIJUIConstraint: *EPIJ_PSrc 3 *EPIJ_Medi 0|*EPIJ_Qual 301
|
||||||
|
*EPIJUIConstraint: *EPIJ_PSrc 2 *EPIJ_Medi 0 *EPIJ_Ink_ 0|*EPIJ_Qual 302
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 301
|
||||||
|
*EPIJUIConstraint: *EPIJ_PSrc 3 *EPIJ_Medi 92|*EPIJ_Qual 999
|
||||||
|
"""
|
||||||
|
let forbidden = MediaQualityConstraints.epijUIConstraints(
|
||||||
|
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
|
||||||
|
mediaIDs: ["0", "15", "92"], qualityIDs: epsonQualityIDs,
|
||||||
|
defaults: ["EPIJ_PSrc": "2", "EPIJ_Ink_": "1"])
|
||||||
|
// Media 0: pure rules forbid 305/307/308; the PSrc=3 rule is
|
||||||
|
// inert at default PSrc=2 (301 survives); the PSrc=2 rule
|
||||||
|
// additionally requires Ink_=0 — default is 1, so inert too
|
||||||
|
// (302 survives).
|
||||||
|
XCTAssertEqual(forbidden["0"], ["305", "307", "308"])
|
||||||
|
XCTAssertEqual(forbidden["15"], ["301", "302", "303", "304"])
|
||||||
|
// 999 is not a listed quality id — ignored outright.
|
||||||
|
XCTAssertEqual(forbidden["92"], ["301"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A conditional rule whose extra terms all match the defaults
|
||||||
|
/// fires like a pure-media rule.
|
||||||
|
func testEPIJConditionalRuleFiresAtDefaults() {
|
||||||
|
let dat = """
|
||||||
|
*EPIJUIConstraint: *EPIJ_PSrc 2 *EPIJ_Medi 0|*EPIJ_Qual 304
|
||||||
|
"""
|
||||||
|
let forbidden = MediaQualityConstraints.epijUIConstraints(
|
||||||
|
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
|
||||||
|
mediaIDs: ["0"], qualityIDs: epsonQualityIDs,
|
||||||
|
defaults: ["EPIJ_PSrc": "2"])
|
||||||
|
XCTAssertEqual(forbidden["0"], ["304"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rule with no media term applies to every listed media; a term
|
||||||
|
/// on a key the queue does not advertise counts as satisfied
|
||||||
|
/// (conservative-forbid — #214 semantics).
|
||||||
|
func testEPIJGlobalAndUnknownKeyRules() {
|
||||||
|
let dat = """
|
||||||
|
*EPIJUIConstraint: *EPIJ_Ink_ 1|*EPIJ_Qual 305
|
||||||
|
*EPIJUIConstraint: *EPIJ_Mode 9 *EPIJ_Medi 0|*EPIJ_Qual 307
|
||||||
|
"""
|
||||||
|
let forbidden = MediaQualityConstraints.epijUIConstraints(
|
||||||
|
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
|
||||||
|
mediaIDs: ["0", "15"], qualityIDs: epsonQualityIDs,
|
||||||
|
defaults: ["EPIJ_Ink_": "1"])
|
||||||
|
// Global rule: Ink_=1 matches default → 305 forbidden on both.
|
||||||
|
XCTAssertTrue(forbidden["0"]!.contains("305"))
|
||||||
|
XCTAssertTrue(forbidden["15"]!.contains("305"))
|
||||||
|
// EPIJ_Mode is unlisted → satisfied → (0, 307) forbidden too.
|
||||||
|
XCTAssertTrue(forbidden["0"]!.contains("307"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The end-to-end XP-55 expectation through `resolve` — the exact
|
||||||
|
/// sets the user reported from the driver PDE.
|
||||||
|
func testResolveEpsonProducesDriverSets() {
|
||||||
|
let dat = """
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 305
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 307
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 308
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 301
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 302
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 303
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 304
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 308
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 301
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 302
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 303
|
||||||
|
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 304
|
||||||
|
"""
|
||||||
|
let ppd = """
|
||||||
|
*EPIJDriverBasePath: "/drivers/epson"
|
||||||
|
*EPIJMachineBundleName: "EP14C0605W.data"
|
||||||
|
"""
|
||||||
|
let datURL = URL(fileURLWithPath:
|
||||||
|
"/drivers/epson/Machine/EP14C0605W.data/"
|
||||||
|
+ "Contents/Resources/PDEData.dat")
|
||||||
|
let map = MediaQualityConstraints.resolve(
|
||||||
|
listings: epsonListings, ppd: ppd,
|
||||||
|
readFile: { $0 == datURL ? dat.data(using: .utf8) : nil })
|
||||||
|
// Plain paper: Fast Economy / Economy / Normal / Fine.
|
||||||
|
XCTAssertEqual(map["0"], ["301", "302", "303", "304"])
|
||||||
|
// Premium Semigloss: Quality / Best Quality / Draft.
|
||||||
|
XCTAssertEqual(map["15"], ["305", "307", "308"])
|
||||||
|
// Ultra Glossy drops Fast Economy…Fine and Draft.
|
||||||
|
XCTAssertEqual(map["92"], ["305", "307"])
|
||||||
|
// Unconstrained media (no rules) produce no entry.
|
||||||
|
XCTAssertNil(map["13"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - #218 tray-aware quality constraints
|
||||||
|
|
||||||
|
/// A conditional rule keyed by the selected tray/token must use the
|
||||||
|
/// supplied override rather than the queue's lpoptions default.
|
||||||
|
func testResolveWithTrayOverride() {
|
||||||
|
let dat = """
|
||||||
|
*EPIJUIConstraint: *EPIJ_FdSo 3 *EPIJ_Medi 0|*EPIJ_Qual 305
|
||||||
|
*EPIJUIConstraint: *EPIJ_FdSo 12 *EPIJ_Medi 0|*EPIJ_Qual 307
|
||||||
|
"""
|
||||||
|
// The queue default is EPIJ_FdSo=2 (from `epsonListings`), so
|
||||||
|
// the default map has no forbidden qualities for media "0".
|
||||||
|
let defaultMap = MediaQualityConstraints.resolve(
|
||||||
|
listings: epsonListings, ppd: nil)
|
||||||
|
XCTAssertNil(defaultMap["0"])
|
||||||
|
|
||||||
|
// Override to tray "3" -> 305 forbidden; override to tray "12"
|
||||||
|
// -> 307 forbidden.
|
||||||
|
let ppd = """
|
||||||
|
*EPIJDriverBasePath: "/drivers/epson"
|
||||||
|
*EPIJMachineBundleName: "EP14C0605W.data"
|
||||||
|
"""
|
||||||
|
let datURL = URL(fileURLWithPath:
|
||||||
|
"/drivers/epson/Machine/EP14C0605W.data/"
|
||||||
|
+ "Contents/Resources/PDEData.dat")
|
||||||
|
let readFile: (URL) -> Data? = {
|
||||||
|
$0 == datURL ? dat.data(using: .utf8) : nil
|
||||||
|
}
|
||||||
|
let tray3 = MediaQualityConstraints.resolve(
|
||||||
|
listings: epsonListings, ppd: ppd,
|
||||||
|
optionOverrides: ["EPIJ_FdSo": "3"], readFile: readFile)
|
||||||
|
XCTAssertEqual(tray3["0"], epsonQualityIDs.subtracting(["305"]))
|
||||||
|
|
||||||
|
let tray12 = MediaQualityConstraints.resolve(
|
||||||
|
listings: epsonListings, ppd: ppd,
|
||||||
|
optionOverrides: ["EPIJ_FdSo": "12"], readFile: readFile)
|
||||||
|
XCTAssertEqual(tray12["0"], epsonQualityIDs.subtracting(["307"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResolveTrayOverrideCacheReadOnlyOnce() {
|
||||||
|
let dat = """
|
||||||
|
*EPIJUIConstraint: *EPIJ_FdSo 3 *EPIJ_Medi 0|*EPIJ_Qual 305
|
||||||
|
"""
|
||||||
|
let ppd = """
|
||||||
|
*EPIJDriverBasePath: "/drivers/epson"
|
||||||
|
*EPIJMachineBundleName: "EP14C0605W.data"
|
||||||
|
"""
|
||||||
|
let datURL = URL(fileURLWithPath:
|
||||||
|
"/drivers/epson/Machine/EP14C0605W.data/"
|
||||||
|
+ "Contents/Resources/PDEData.dat")
|
||||||
|
var reads = 0
|
||||||
|
var cache: [URL: Data] = [:]
|
||||||
|
let readFile: (URL) -> Data? = {
|
||||||
|
if let cached = cache[$0] { return cached }
|
||||||
|
reads += 1
|
||||||
|
let data = $0 == datURL ? dat.data(using: .utf8) : nil
|
||||||
|
cache[$0] = data
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
_ = MediaQualityConstraints.resolve(
|
||||||
|
listings: epsonListings, ppd: ppd,
|
||||||
|
optionOverrides: ["EPIJ_FdSo": "3"], readFile: readFile)
|
||||||
|
_ = MediaQualityConstraints.resolve(
|
||||||
|
listings: epsonListings, ppd: ppd,
|
||||||
|
optionOverrides: ["EPIJ_FdSo": "12"], readFile: readFile)
|
||||||
|
XCTAssertEqual(reads, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Canon cnb table
|
||||||
|
|
||||||
|
/// Builds one 20-byte Canon record: `30 00 01 00 00 00 00 00`,
|
||||||
|
/// `family:u16`, `flag:u16`, `media:u32`, `pad:u16`, `quality:u16`.
|
||||||
|
private func canonRecord(
|
||||||
|
family: UInt16 = 3, flag: UInt16 = 0,
|
||||||
|
media: UInt32, pad: UInt16 = 0, quality: UInt16
|
||||||
|
) -> Data {
|
||||||
|
var d = Data([0x30, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00])
|
||||||
|
d.append(contentsOf: [UInt8(family & 0xFF), UInt8(family >> 8)])
|
||||||
|
d.append(contentsOf: [UInt8(flag & 0xFF), UInt8(flag >> 8)])
|
||||||
|
d.append(contentsOf: [
|
||||||
|
UInt8(media & 0xFF), UInt8((media >> 8) & 0xFF),
|
||||||
|
UInt8((media >> 16) & 0xFF), UInt8((media >> 24) & 0xFF)])
|
||||||
|
d.append(contentsOf: [UInt8(pad & 0xFF), UInt8(pad >> 8)])
|
||||||
|
d.append(contentsOf: [UInt8(quality & 0xFF), UInt8(quality >> 8)])
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCNBTableScan() {
|
||||||
|
var data = Data()
|
||||||
|
// Junk prefix — even length keeps the records 2-byte aligned.
|
||||||
|
data.append(contentsOf: [0xFF, 0x00])
|
||||||
|
// media 0 → {0, 10, 15, 20} across flag 0/1 variants.
|
||||||
|
for q: UInt16 in [10, 15, 20, 0] {
|
||||||
|
data.append(canonRecord(media: 0, quality: q))
|
||||||
|
}
|
||||||
|
data.append(canonRecord(flag: 1, media: 0, quality: 10))
|
||||||
|
// media 50 → {0, 5, 10}; plus its borderless variant 0x10032.
|
||||||
|
for q: UInt16 in [0, 5, 10] {
|
||||||
|
data.append(canonRecord(media: 50, quality: q))
|
||||||
|
}
|
||||||
|
data.append(canonRecord(media: 0x10032, quality: 0))
|
||||||
|
// Noise: unknown media, unknown quality, nonzero pad, and a
|
||||||
|
// different record family that must lose the modal vote.
|
||||||
|
data.append(canonRecord(media: 999, quality: 5))
|
||||||
|
data.append(canonRecord(media: 50, quality: 7))
|
||||||
|
data.append(canonRecord(media: 50, pad: 1, quality: 15))
|
||||||
|
data.append(canonRecord(family: 9, media: 50, quality: 15))
|
||||||
|
data.append(Data([0x11, 0x22, 0x33]))
|
||||||
|
|
||||||
|
let map = MediaQualityConstraints.cnijMediaQualityTable(
|
||||||
|
data,
|
||||||
|
mediaIDs: ["0", "50", "42"],
|
||||||
|
qualityIDs: ["0", "5", "10", "15", "20"])
|
||||||
|
XCTAssertEqual(map["0"], ["0", "10", "15", "20"])
|
||||||
|
XCTAssertEqual(map["50"], ["0", "5", "10"])
|
||||||
|
XCTAssertNil(map["42"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCNBGarbageFailsOpen() {
|
||||||
|
let map = MediaQualityConstraints.cnijMediaQualityTable(
|
||||||
|
Data((0..<4096).map { _ in UInt8.random(in: 0...255) }),
|
||||||
|
mediaIDs: ["0", "50"], qualityIDs: ["0", "5", "10"])
|
||||||
|
XCTAssertTrue(map.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - resolve ordering / fallback
|
||||||
|
|
||||||
|
func testResolvePrefersPPDConstraints() {
|
||||||
|
let ppd = """
|
||||||
|
*UIConstraints: *EPIJ_Medi 0 *EPIJ_Qual 305
|
||||||
|
*EPIJDriverBasePath: "/should/not/be/read"
|
||||||
|
*EPIJMachineBundleName: "x.data"
|
||||||
|
"""
|
||||||
|
var reads = 0
|
||||||
|
let map = MediaQualityConstraints.resolve(
|
||||||
|
listings: epsonListings, ppd: ppd,
|
||||||
|
readFile: { _ in reads += 1; return nil })
|
||||||
|
XCTAssertEqual(map["0"], epsonQualityIDs.subtracting(["305"]))
|
||||||
|
// The Epson path is never touched once PPD constraints hit.
|
||||||
|
XCTAssertEqual(reads, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResolveNoPPDOrKeysFailsOpen() {
|
||||||
|
XCTAssertTrue(MediaQualityConstraints.resolve(
|
||||||
|
listings: epsonListings, ppd: nil).isEmpty)
|
||||||
|
// No media key in the roster → no map.
|
||||||
|
XCTAssertTrue(MediaQualityConstraints.resolve(
|
||||||
|
listings: CupsParsers.lpoptionsList(
|
||||||
|
"PageSize/Media Size: *A4 Letter\n"),
|
||||||
|
ppd: "*CNIJTableID: 354\n").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PrinterCapabilities accessors
|
||||||
|
|
||||||
|
func testCapabilitiesAccessorFallbacks() {
|
||||||
|
var caps = PrinterCapabilities(
|
||||||
|
mediaTypes: [
|
||||||
|
PrinterMediaType(id: "0", name: "Plain"),
|
||||||
|
PrinterMediaType(id: "15", name: "Semigloss"),
|
||||||
|
],
|
||||||
|
qualities: ["301", "302", "303", "308", "304", "305", "307"]
|
||||||
|
.map { PrinterQuality(id: $0, name: "Q\($0)") })
|
||||||
|
// No map → everything, in driver order (#180).
|
||||||
|
XCTAssertEqual(caps.qualities(forMediaType: "0").map(\.id),
|
||||||
|
["301", "302", "303", "308", "304", "305", "307"])
|
||||||
|
XCTAssertTrue(caps.allowsQuality("305", forMediaType: "0"))
|
||||||
|
|
||||||
|
caps.qualityIDsByMediaType = [
|
||||||
|
"0": ["301", "302", "303", "304"],
|
||||||
|
"15": ["305", "307", "308"],
|
||||||
|
]
|
||||||
|
XCTAssertEqual(caps.qualities(forMediaType: "0").map(\.id),
|
||||||
|
["301", "302", "303", "304"])
|
||||||
|
// Driver order preserved — 308 sits before 304 in lpoptions.
|
||||||
|
XCTAssertEqual(caps.qualities(forMediaType: "15").map(\.id),
|
||||||
|
["308", "305", "307"])
|
||||||
|
XCTAssertTrue(caps.allowsQuality("308", forMediaType: "15"))
|
||||||
|
XCTAssertFalse(caps.allowsQuality("308", forMediaType: "0"))
|
||||||
|
// Unknown media → unconstrained.
|
||||||
|
XCTAssertEqual(caps.qualities(forMediaType: "99").count, 7)
|
||||||
|
XCTAssertTrue(caps.allowsQuality("305", forMediaType: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTraySpecificQualityMap() {
|
||||||
|
var caps = PrinterCapabilities(
|
||||||
|
mediaTypes: [
|
||||||
|
PrinterMediaType(id: "0", name: "Plain"),
|
||||||
|
],
|
||||||
|
qualities: ["301", "302", "303", "304", "305"]
|
||||||
|
.map { PrinterQuality(id: $0, name: "Q\($0)") },
|
||||||
|
qualityIDsByMediaType: ["0": ["301", "302", "303"]])
|
||||||
|
caps.qualityIDsByMediaTypeByTray = [
|
||||||
|
"Rear": ["0": ["301", "302", "303"]],
|
||||||
|
"Front": ["0": ["303", "304", "305"]],
|
||||||
|
]
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
caps.qualities(forMediaType: "0", trayToken: "Rear")
|
||||||
|
.map { $0.id }, ["301", "302", "303"])
|
||||||
|
XCTAssertEqual(
|
||||||
|
caps.qualities(forMediaType: "0", trayToken: "Front")
|
||||||
|
.map { $0.id }, ["303", "304", "305"])
|
||||||
|
// Unknown tray falls back to the default map.
|
||||||
|
XCTAssertEqual(
|
||||||
|
caps.qualities(forMediaType: "0", trayToken: "Unknown")
|
||||||
|
.map { $0.id }, ["301", "302", "303"])
|
||||||
|
// No tray token also falls back.
|
||||||
|
XCTAssertEqual(
|
||||||
|
caps.qualities(forMediaType: "0").map { $0.id },
|
||||||
|
["301", "302", "303"])
|
||||||
|
|
||||||
|
XCTAssertTrue(caps.allowsQuality(
|
||||||
|
"304", forMediaType: "0", trayToken: "Front"))
|
||||||
|
XCTAssertFalse(caps.allowsQuality(
|
||||||
|
"304", forMediaType: "0", trayToken: "Rear"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,10 +44,16 @@ final class PrintPanelStubTests: XCTestCase {
|
|||||||
"ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear",
|
"ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear",
|
||||||
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
|
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
|
||||||
]) {
|
]) {
|
||||||
let result = UITestHooks.printPanelResult(forQueue: "q")
|
let trays = [
|
||||||
|
PrinterTray(id: 1, token: "Auto", name: "Auto"),
|
||||||
|
PrinterTray(id: 2, token: "Rear", name: "Rear"),
|
||||||
|
]
|
||||||
|
let result = UITestHooks.printPanelResult(
|
||||||
|
forQueue: "q", trays: trays, preferredTrayKey: "InputSlot")
|
||||||
XCTAssertEqual(result?.selectedPrinter, "Other_Queue")
|
XCTAssertEqual(result?.selectedPrinter, "Other_Queue")
|
||||||
XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
|
XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
|
||||||
XCTAssertEqual(result?.options.mediaType, "Photo")
|
XCTAssertEqual(result?.options.mediaType, "Photo")
|
||||||
|
XCTAssertEqual(result?.options.paperSource, 2)
|
||||||
XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
|
XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,4 +105,60 @@ final class PrintPanelStubTests: XCTestCase {
|
|||||||
XCTAssertNil(result?.options.cupsOptions)
|
XCTAssertNil(result?.options.cupsOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #218 — vendor tray keys and the `AP_D_InputSlot` capture alias
|
||||||
|
/// map back through `paperSource` when the token matches the roster.
|
||||||
|
func testOkResultExtractsTrayToken() throws {
|
||||||
|
let trays = [
|
||||||
|
PrinterTray(id: 1, token: "Auto", name: "Auto"),
|
||||||
|
PrinterTray(id: 2, token: "Rear", name: "Rear Tray"),
|
||||||
|
PrinterTray(id: 3, token: "Manual", name: "Manual Feed"),
|
||||||
|
]
|
||||||
|
try withEnv([
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||||
|
"ICCERY_TEST_PANEL_OPTIONS": "PageSize=A4 EPIJ_FdSo=Rear",
|
||||||
|
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||||
|
]) {
|
||||||
|
let result = UITestHooks.printPanelResult(
|
||||||
|
forQueue: "q", trays: trays,
|
||||||
|
preferredTrayKey: "EPIJ_FdSo")
|
||||||
|
XCTAssertEqual(result?.options.paperSource, 2)
|
||||||
|
XCTAssertEqual(result?.options.cupsOptions,
|
||||||
|
"PageSize=A4 EPIJ_FdSo=Rear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOkResultExtractsAPDInputSlotAlias() throws {
|
||||||
|
let trays = [
|
||||||
|
PrinterTray(id: 1, token: "Auto", name: "Auto"),
|
||||||
|
PrinterTray(id: 2, token: "Manual", name: "Manual Feed"),
|
||||||
|
]
|
||||||
|
try withEnv([
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||||
|
"ICCERY_TEST_PANEL_OPTIONS": "AP_D_InputSlot=Manual",
|
||||||
|
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||||
|
]) {
|
||||||
|
let result = UITestHooks.printPanelResult(
|
||||||
|
forQueue: "q", trays: trays)
|
||||||
|
XCTAssertEqual(result?.options.paperSource, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOkResultUnknownTrayNoPaperSource() throws {
|
||||||
|
let trays = [
|
||||||
|
PrinterTray(id: 1, token: "Auto", name: "Auto"),
|
||||||
|
]
|
||||||
|
try withEnv([
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||||
|
"ICCERY_TEST_PANEL_OPTIONS": "InputSlot=Unknown",
|
||||||
|
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||||
|
]) {
|
||||||
|
let result = UITestHooks.printPanelResult(
|
||||||
|
forQueue: "q", trays: trays)
|
||||||
|
XCTAssertNil(result?.options.paperSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCery
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue #217 — the panel's pre-modal seam
|
||||||
|
/// (`PrintPanelService.configureBoundPanel`) must rehydrate the
|
||||||
|
/// captured `PrintTicket` before the Stage 2 writes, validate the
|
||||||
|
/// result, and refuse a foreign-queue ticket — all without driving
|
||||||
|
/// the modal or needing a real queue (`printer: nil` exercises
|
||||||
|
/// restore → selections → validate on a session-less `NSPrintInfo`).
|
||||||
|
@MainActor
|
||||||
|
final class PrintPanelTicketTests: XCTestCase {
|
||||||
|
|
||||||
|
private let service = PrintPanelService()
|
||||||
|
|
||||||
|
/// The queue a destination `NSPrintInfo` will report as bound —
|
||||||
|
/// the session's current printer, else a synthetic token for a
|
||||||
|
/// queue-less environment (same convention as PrintTicketTests).
|
||||||
|
private func boundQueue(of printInfo: NSPrintInfo) -> String {
|
||||||
|
PMTicketBridge.currentPrinterID(
|
||||||
|
session: PMTicketBridge.session(printInfo),
|
||||||
|
fallback: "UnboundQueue")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTicket(
|
||||||
|
queue: String,
|
||||||
|
key: String = "EPIJ_Qual",
|
||||||
|
value: String = "305"
|
||||||
|
) throws -> PrintTicket {
|
||||||
|
let source = NSPrintInfo()
|
||||||
|
XCTAssertTrue(
|
||||||
|
PMTicketBridge.setValue(
|
||||||
|
value, forKey: key, locked: false,
|
||||||
|
in: PMTicketBridge.settings(source),
|
||||||
|
context: "PrintPanelTicketTests"))
|
||||||
|
return try PMTicketBridge.serialise(source, queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request(
|
||||||
|
ticket: PrintTicket?,
|
||||||
|
selections: PrintPanelInitialSelections =
|
||||||
|
PrintPanelInitialSelections(),
|
||||||
|
trays: [PrinterTray] = []
|
||||||
|
) -> PrintPanelRequest {
|
||||||
|
PrintPanelRequest(
|
||||||
|
queue: "Panel_Q", displayName: nil, optionKeys: [],
|
||||||
|
initialSelections: selections, ticket: ticket, trays: trays)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RC1 — a captured vendor key is present in the configured
|
||||||
|
/// printInfo before the modal would open.
|
||||||
|
func testTicketRestoredPreModal() throws {
|
||||||
|
let printInfo = NSPrintInfo()
|
||||||
|
let ticket = try makeTicket(queue: boundQueue(of: printInfo))
|
||||||
|
|
||||||
|
service.configureBoundPanel(
|
||||||
|
printInfo, request: request(ticket: ticket), printer: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(
|
||||||
|
forKey: "EPIJ_Qual",
|
||||||
|
in: PMTicketBridge.settings(printInfo)),
|
||||||
|
"305")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// D6 parity — Stage 2 selections win over the restored ticket.
|
||||||
|
func testStage2WinsOverTicket() throws {
|
||||||
|
let printInfo = NSPrintInfo()
|
||||||
|
let ticket = try makeTicket(queue: boundQueue(of: printInfo))
|
||||||
|
var selections = PrintPanelInitialSelections()
|
||||||
|
selections.qualityKey = "EPIJ_Qual"
|
||||||
|
selections.quality = "303"
|
||||||
|
|
||||||
|
service.configureBoundPanel(
|
||||||
|
printInfo,
|
||||||
|
request: request(ticket: ticket, selections: selections),
|
||||||
|
printer: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(
|
||||||
|
forKey: "EPIJ_Qual",
|
||||||
|
in: PMTicketBridge.settings(printInfo)),
|
||||||
|
"303")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// R3 — a ticket captured for another queue is refused; the
|
||||||
|
/// destination's settings stay untouched and nothing throws.
|
||||||
|
func testCrossQueueTicketRefusedAtPanel() throws {
|
||||||
|
let printInfo = NSPrintInfo()
|
||||||
|
let bound = boundQueue(of: printInfo)
|
||||||
|
let foreign = "DefinitelyNot_\(bound)"
|
||||||
|
try XCTSkipIf(
|
||||||
|
bound == foreign,
|
||||||
|
"destination resolved to the foreign queue")
|
||||||
|
XCTAssertTrue(
|
||||||
|
PMTicketBridge.setValue(
|
||||||
|
"999", forKey: "EPIJ_Qual", locked: false,
|
||||||
|
in: PMTicketBridge.settings(printInfo),
|
||||||
|
context: "PrintPanelTicketTests"))
|
||||||
|
let ticket = try makeTicket(queue: foreign)
|
||||||
|
|
||||||
|
service.configureBoundPanel(
|
||||||
|
printInfo, request: request(ticket: ticket), printer: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(
|
||||||
|
forKey: "EPIJ_Qual",
|
||||||
|
in: PMTicketBridge.settings(printInfo)),
|
||||||
|
"999")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #218 — the Stage 2 tray selection lands after `PageSize` and
|
||||||
|
/// before the media key in the configured `NSPrintInfo`.
|
||||||
|
func testStage2TrayWrittenPreModal() {
|
||||||
|
let printInfo = NSPrintInfo()
|
||||||
|
var selections = PrintPanelInitialSelections()
|
||||||
|
selections.paperSize = "A4"
|
||||||
|
selections.trayKey = "EPIJ_FdSo"
|
||||||
|
selections.tray = "Rear"
|
||||||
|
selections.mediaType = "Photo"
|
||||||
|
|
||||||
|
var req = request(ticket: nil, selections: selections)
|
||||||
|
req = PrintPanelRequest(
|
||||||
|
queue: req.queue, displayName: req.displayName,
|
||||||
|
optionKeys: ["MediaType"], initialSelections: selections,
|
||||||
|
ticket: nil, trays: [])
|
||||||
|
service.configureBoundPanel(
|
||||||
|
printInfo, request: req, printer: nil)
|
||||||
|
|
||||||
|
let settings = PMTicketBridge.settings(printInfo)
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(forKey: "PageSize", in: settings),
|
||||||
|
"A4")
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(forKey: "EPIJ_FdSo", in: settings),
|
||||||
|
"Rear")
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(
|
||||||
|
forKey: "MediaType", in: settings), "Photo")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First-ever open — no ticket, selections still land and the
|
||||||
|
/// post-selections validate keeps them readable.
|
||||||
|
func testNoTicketKeepsSelections() {
|
||||||
|
let printInfo = NSPrintInfo()
|
||||||
|
var selections = PrintPanelInitialSelections()
|
||||||
|
selections.paperSize = "A4"
|
||||||
|
selections.qualityKey = "EPIJ_Qual"
|
||||||
|
selections.quality = "303"
|
||||||
|
selections.orientation = "landscape"
|
||||||
|
|
||||||
|
service.configureBoundPanel(
|
||||||
|
printInfo,
|
||||||
|
request: request(ticket: nil, selections: selections),
|
||||||
|
printer: nil)
|
||||||
|
|
||||||
|
let settings = PMTicketBridge.settings(printInfo)
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(
|
||||||
|
forKey: "PageSize", in: settings), "A4")
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(
|
||||||
|
forKey: "EPIJ_Qual", in: settings), "303")
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(
|
||||||
|
forKey: "orientation-requested", in: settings), "4")
|
||||||
|
// The suppression writes still land on the ticket so the PDE
|
||||||
|
// sees the locked AP_* pair (③) and Quartz vocabulary (⑤′).
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(
|
||||||
|
forKey: "AP_ColorMatchingMode", in: settings),
|
||||||
|
"AP_ApplicationColorMatching")
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(
|
||||||
|
forKey: "PMColorMatchingMode", in: settings),
|
||||||
|
"APCustomColorMatching")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ final class PrintSessionViewModelTests: XCTestCase {
|
|||||||
done
|
done
|
||||||
if [ "$list" = "1" ]; then
|
if [ "$list" = "1" ]; then
|
||||||
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal Custom.WIDTHxHEIGHT\\n'
|
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal Custom.WIDTHxHEIGHT\\n'
|
||||||
printf 'InputSlot/Media Source: Auto *Main Rear\\n'
|
printf 'InputSlot/Media Source: Auto Main *Rear\\n'
|
||||||
printf 'MediaType/Media Type: *Stationery Glossy Matte\\n'
|
printf 'MediaType/Media Type: *Stationery Glossy Matte\\n'
|
||||||
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\\n'
|
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\\n'
|
||||||
exit 0
|
exit 0
|
||||||
@@ -99,6 +99,10 @@ final class PrintSessionViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(workflow.pageSize, .a4)
|
XCTAssertEqual(workflow.pageSize, .a4)
|
||||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||||
|
// Tray seeds from the `*` default on caps load, not the first
|
||||||
|
// list element (#218).
|
||||||
|
XCTAssertEqual(workflow.print.selectedTray, 3)
|
||||||
|
XCTAssertEqual(workflow.print.selectedTrayToken, "Rear")
|
||||||
// Quality seeds from the `*` default on caps load.
|
// Quality seeds from the `*` default on caps load.
|
||||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||||
// All seven Epson codes enumerate in driver order (#180).
|
// All seven Epson codes enumerate in driver order (#180).
|
||||||
@@ -209,6 +213,7 @@ final class PrintSessionViewModelTests: XCTestCase {
|
|||||||
let log = await waitForFile(spoolLogURL)
|
let log = await waitForFile(spoolLogURL)
|
||||||
XCTAssertTrue(log.contains("PageSize=Letter"), log)
|
XCTAssertTrue(log.contains("PageSize=Letter"), log)
|
||||||
XCTAssertTrue(log.contains("EPIJ_Qual=301"), log)
|
XCTAssertTrue(log.contains("EPIJ_Qual=301"), log)
|
||||||
|
XCTAssertTrue(log.contains("InputSlot=Rear"), log)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #201 — the stubbed panel never produces a `PrintTicket`, so a
|
/// #201 — the stubbed panel never produces a `PrintTicket`, so a
|
||||||
@@ -241,6 +246,7 @@ final class PrintSessionViewModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(log.contains("PageSize=A4"), log)
|
XCTAssertTrue(log.contains("PageSize=A4"), log)
|
||||||
XCTAssertTrue(log.contains("EPIJ_Qual=303"), log)
|
XCTAssertTrue(log.contains("EPIJ_Qual=303"), log)
|
||||||
XCTAssertTrue(log.contains("MediaType=Stationery"), log)
|
XCTAssertTrue(log.contains("MediaType=Stationery"), log)
|
||||||
|
XCTAssertTrue(log.contains("InputSlot=Rear"), log)
|
||||||
XCTAssertTrue(log.contains("orientation-requested=3"), log)
|
XCTAssertTrue(log.contains("orientation-requested=3"), log)
|
||||||
XCTAssertTrue(log.contains(
|
XCTAssertTrue(log.contains(
|
||||||
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), log)
|
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), log)
|
||||||
@@ -307,6 +313,52 @@ final class PrintSessionViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(workflow.pageSize, .a4)
|
XCTAssertEqual(workflow.pageSize, .a4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #218 — a captured `InputSlot=` (or any roster key) maps back to
|
||||||
|
/// `selectedTray` and populates `PrintOptions.paperSource`.
|
||||||
|
func testPanelResultAppliesBackTray() async throws {
|
||||||
|
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||||
|
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||||
|
setenv("ICCERY_TEST_PANEL_OPTIONS", "InputSlot=Auto", 1)
|
||||||
|
defer {
|
||||||
|
unsetenv("ICCERY_UI_TESTING")
|
||||||
|
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||||
|
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
workflow.pageSize = .a4
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
XCTAssertEqual(workflow.print.selectedTray, 3)
|
||||||
|
|
||||||
|
workflow.print.openPrinterPreferences()
|
||||||
|
await waitForNotice(workflow.print, containing: "Settings captured")
|
||||||
|
XCTAssertEqual(workflow.print.selectedTray, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #218 — a captured tray token absent from the roster is appended
|
||||||
|
/// as a synthetic entry and selected.
|
||||||
|
func testPanelResultUnknownTrayAppended() async throws {
|
||||||
|
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||||
|
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||||
|
setenv("ICCERY_TEST_PANEL_OPTIONS", "InputSlot=OnlyInDialog", 1)
|
||||||
|
defer {
|
||||||
|
unsetenv("ICCERY_UI_TESTING")
|
||||||
|
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||||
|
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
workflow.pageSize = .a4
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
let originalCount = workflow.print.printerCaps.trays.count
|
||||||
|
|
||||||
|
workflow.print.openPrinterPreferences()
|
||||||
|
await waitForNotice(workflow.print, containing: "Settings captured")
|
||||||
|
XCTAssertEqual(workflow.print.printerCaps.trays.count,
|
||||||
|
originalCount + 1)
|
||||||
|
XCTAssertEqual(workflow.print.selectedTrayToken, "OnlyInDialog")
|
||||||
|
}
|
||||||
|
|
||||||
/// #186 — a captured `PageSize` token with no capability match
|
/// #186 — a captured `PageSize` token with no capability match
|
||||||
/// leaves `selectedPaperSize` unchanged (never a guessed id).
|
/// leaves `selectedPaperSize` unchanged (never a guessed id).
|
||||||
func testPanelResultUnknownPaperLeavesSelection() async throws {
|
func testPanelResultUnknownPaperLeavesSelection() async throws {
|
||||||
@@ -408,6 +460,198 @@ final class PrintSessionViewModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty)
|
XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Media-aware quality filtering (#214)
|
||||||
|
|
||||||
|
/// Constrains the loaded fixture caps: `Stationery` allows
|
||||||
|
/// {301,302,303,304}, `Glossy` allows {305,307}, `Matte` is
|
||||||
|
/// unconstrained. Mirrors the Epson `EPIJUIConstraint` matrix.
|
||||||
|
private func constrainCaps(_ vm: PrintSessionViewModel) {
|
||||||
|
var caps = vm.printerCaps
|
||||||
|
caps.qualityIDsByMediaType = [
|
||||||
|
"Stationery": ["301", "302", "303", "304"],
|
||||||
|
"Glossy": ["305", "307"],
|
||||||
|
]
|
||||||
|
vm.printerCaps = caps
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Media switch → an invalid quality pick re-seeds to the first
|
||||||
|
/// allowed choice; the picker source shrinks to the media's set.
|
||||||
|
func testMediaChangeClampsInvalidQuality() async {
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
constrainCaps(workflow.print)
|
||||||
|
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
|
||||||
|
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||||
|
|
||||||
|
workflow.print.selectedMediaType = "Glossy"
|
||||||
|
XCTAssertEqual(workflow.print.selectedQuality, "305")
|
||||||
|
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
|
||||||
|
["305", "307"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A still-valid pick survives a media switch; an unconstrained
|
||||||
|
/// media keeps the pick too.
|
||||||
|
func testMediaChangeKeepsValidQuality() async {
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
constrainCaps(workflow.print)
|
||||||
|
workflow.print.selectedQuality = "302"
|
||||||
|
|
||||||
|
workflow.print.selectedMediaType = "Matte" // unconstrained
|
||||||
|
XCTAssertEqual(workflow.print.selectedQuality, "302")
|
||||||
|
|
||||||
|
workflow.print.selectedMediaType = "Glossy" // 302 invalid
|
||||||
|
XCTAssertEqual(workflow.print.selectedQuality, "305")
|
||||||
|
workflow.print.selectedQuality = "307"
|
||||||
|
workflow.print.selectedMediaType = "Stationery" // 307 invalid
|
||||||
|
// Driver default 303 is allowed there → preferred over first.
|
||||||
|
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #218 — a per-tray quality map is selected when the current tray
|
||||||
|
/// token has an entry; otherwise it falls back to the default map.
|
||||||
|
func testTraySpecificQualityMapSelected() async {
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
var caps = workflow.print.printerCaps
|
||||||
|
caps.qualityIDsByMediaType = [
|
||||||
|
"Stationery": ["301", "302", "303"],
|
||||||
|
"Glossy": ["305", "307"],
|
||||||
|
]
|
||||||
|
caps.qualityIDsByMediaTypeByTray = [
|
||||||
|
"Auto": ["Stationery": ["301", "302"]],
|
||||||
|
"Rear": ["Stationery": ["301", "302", "303", "304"]],
|
||||||
|
]
|
||||||
|
workflow.print.printerCaps = caps
|
||||||
|
XCTAssertEqual(workflow.print.selectedTrayToken, "Rear")
|
||||||
|
// Non-empty tray map for Rear wins over the default media map.
|
||||||
|
XCTAssertEqual(workflow.print.availableQualities.map { $0.id },
|
||||||
|
["301", "302", "303", "304"])
|
||||||
|
|
||||||
|
workflow.print.selectedTray = 1 // Auto
|
||||||
|
XCTAssertEqual(workflow.print.availableQualities.map { $0.id },
|
||||||
|
["301", "302"])
|
||||||
|
|
||||||
|
workflow.print.selectedTray = 999 // invalid -> fallback
|
||||||
|
XCTAssertEqual(workflow.print.availableQualities.map { $0.id },
|
||||||
|
["301", "302", "303"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seeding lands inside the allowed set even when the driver
|
||||||
|
/// default quality is invalid for the first media — end to end
|
||||||
|
/// through `CupsService.capabilities(for:)` + a fixture
|
||||||
|
/// `PDEData.dat` (#214).
|
||||||
|
func testQualitySeedRespectsMediaMap() async throws {
|
||||||
|
let ppdDir = env.root.appendingPathComponent("ppd")
|
||||||
|
let epsonRoot = env.root.appendingPathComponent("epson-driver")
|
||||||
|
let datDir = epsonRoot.appendingPathComponent(
|
||||||
|
"Machine/M.data/Contents/Resources")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: ppdDir, withIntermediateDirectories: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: datDir, withIntermediateDirectories: true)
|
||||||
|
try """
|
||||||
|
*EPIJDriverBasePath: "\(epsonRoot.path)"
|
||||||
|
*EPIJMachineBundleName: "M.data"
|
||||||
|
""".write(
|
||||||
|
to: ppdDir.appendingPathComponent("Mock_Q.ppd"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
// Stationery forbids everything except 305/307 — including the
|
||||||
|
// `lpoptions` default 303.
|
||||||
|
try """
|
||||||
|
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 301
|
||||||
|
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 302
|
||||||
|
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 303
|
||||||
|
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 308
|
||||||
|
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 304
|
||||||
|
""".write(
|
||||||
|
to: datDir.appendingPathComponent("PDEData.dat"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
var environment = env.environment
|
||||||
|
environment = AppEnvironment(
|
||||||
|
stateStore: environment.stateStore,
|
||||||
|
settingsStore: environment.settingsStore,
|
||||||
|
presetStore: environment.presetStore,
|
||||||
|
runner: environment.runner,
|
||||||
|
cupsService: CupsService(
|
||||||
|
processManager: ProcessManager(),
|
||||||
|
binaryDir: binDir, ppdDir: ppdDir),
|
||||||
|
historyStore: environment.historyStore,
|
||||||
|
mediaStore: environment.mediaStore,
|
||||||
|
recentProjectsStore: environment.recentProjectsStore)
|
||||||
|
let workflow = TargetWorkflowViewModel(environment: environment)
|
||||||
|
workflow.print.selectedPrinter = "Mock_Q"
|
||||||
|
await workflow.print.reloadSelectedCapabilities()
|
||||||
|
|
||||||
|
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
|
||||||
|
XCTAssertEqual(workflow.print.printerCaps
|
||||||
|
.qualityIDsByMediaType["Stationery"], ["305", "307"])
|
||||||
|
// 303 is the driver default but invalid on Stationery → 305.
|
||||||
|
XCTAssertEqual(workflow.print.selectedQuality, "305")
|
||||||
|
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
|
||||||
|
["305", "307"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No constraint map → every quality stays selectable on every
|
||||||
|
/// media (the pre-#214 behaviour, by design for unknown drivers).
|
||||||
|
func testUnconstrainedDriverKeepsAllQualities() async {
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
workflow.print.selectedMediaType = "Glossy"
|
||||||
|
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
|
||||||
|
["301", "302", "303", "308", "304", "305", "307"])
|
||||||
|
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A captured quality invalid for the captured media is dropped —
|
||||||
|
/// the clamped selection stands.
|
||||||
|
func testPanelResultInvalidQualityDropped() async throws {
|
||||||
|
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||||
|
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||||
|
setenv("ICCERY_TEST_PANEL_OPTIONS", "EPIJ_Qual=305", 1)
|
||||||
|
defer {
|
||||||
|
unsetenv("ICCERY_UI_TESTING")
|
||||||
|
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||||
|
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
constrainCaps(workflow.print) // Stationery forbids 305
|
||||||
|
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||||
|
|
||||||
|
workflow.print.openPrinterPreferences()
|
||||||
|
await waitForNotice(workflow.print, containing: "Settings captured")
|
||||||
|
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `makeRequest` substitutes a stale-invalid quality before the
|
||||||
|
/// ticket is written — the last gate before the driver (#214).
|
||||||
|
func testSpoolSubstitutesStaleQuality() async throws {
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
constrainCaps(workflow.print)
|
||||||
|
workflow.print.selectedMediaType = "Glossy"
|
||||||
|
workflow.print.selectedQuality = "308" // stale, invalid
|
||||||
|
workflow.print.spooler = RecordingTargetSpooler(
|
||||||
|
logURL: spoolLogURL)
|
||||||
|
|
||||||
|
let tiff = env.root.appendingPathComponent("page1.tif")
|
||||||
|
try Data([0x49, 0x49]).write(to: tiff)
|
||||||
|
let page = GalleryPage(
|
||||||
|
index: 0,
|
||||||
|
page: PrinttargPage(
|
||||||
|
filename: "page1.tif", patches: 10,
|
||||||
|
widthMm: 210, heightMm: 297),
|
||||||
|
fileURL: tiff, previewPNG: nil, previewError: nil)
|
||||||
|
workflow.print.printPage(page)
|
||||||
|
|
||||||
|
let log = await waitForFile(spoolLogURL)
|
||||||
|
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
|
||||||
|
XCTAssertFalse(log.contains("EPIJ_Qual=308"), log)
|
||||||
|
}
|
||||||
|
|
||||||
/// Poll until the panel task posts a notice whose text contains
|
/// Poll until the panel task posts a notice whose text contains
|
||||||
/// `fragment` (the Task-completion signal for `nil` results too).
|
/// `fragment` (the Task-completion signal for `nil` results too).
|
||||||
private func waitForNotice(
|
private func waitForNotice(
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import XCTest
|
|||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
|
|
||||||
/// Issue #201 Phase 4 — the resolved ticket-write list that replaces
|
/// Issue #201 Phase 4 / #218 — the resolved ticket-write list that
|
||||||
/// the `lp` argv goldens: locked order 1–10, D6 (Stage 2 always
|
/// replaces the `lp` argv goldens: locked order 1–11, D6 (Stage 2
|
||||||
/// wins), `raw` can never appear.
|
/// always wins), `raw` can never appear.
|
||||||
final class TicketWriteResolverTests: XCTestCase {
|
final class TicketWriteResolverTests: XCTestCase {
|
||||||
|
|
||||||
private func resolve(
|
private func resolve(
|
||||||
@@ -63,6 +63,7 @@ final class TicketWriteResolverTests: XCTestCase {
|
|||||||
overrides: TargetPrintOverrides(
|
overrides: TargetPrintOverrides(
|
||||||
paperSize: "raw", mediaType: "raw",
|
paperSize: "raw", mediaType: "raw",
|
||||||
qualityKey: "raw", quality: "raw",
|
qualityKey: "raw", quality: "raw",
|
||||||
|
trayKey: "raw", tray: "raw",
|
||||||
orientation: "raw"),
|
orientation: "raw"),
|
||||||
optionKeys: optionKeys)
|
optionKeys: optionKeys)
|
||||||
XCTAssertFalse(r.writes.contains {
|
XCTAssertFalse(r.writes.contains {
|
||||||
@@ -144,8 +145,8 @@ final class TicketWriteResolverTests: XCTestCase {
|
|||||||
XCTAssertNil(value("orientation-requested", in: resolve()))
|
XCTAssertNil(value("orientation-requested", in: resolve()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `nil` override emits no write for that key; quality needs
|
/// A `nil` override emits no write for that key; quality and tray
|
||||||
/// **both** the detected key and the value.
|
/// need **both** the detected key and the value.
|
||||||
func testNilOverridesEmitNoWrite() {
|
func testNilOverridesEmitNoWrite() {
|
||||||
let r = resolve(optionKeys: ["MediaType", "EPIJ_Qual"])
|
let r = resolve(optionKeys: ["MediaType", "EPIJ_Qual"])
|
||||||
XCTAssertNil(value("PageSize", in: r))
|
XCTAssertNil(value("PageSize", in: r))
|
||||||
@@ -168,14 +169,42 @@ final class TicketWriteResolverTests: XCTestCase {
|
|||||||
overrides: TargetPrintOverrides(paperSize: ""))
|
overrides: TargetPrintOverrides(paperSize: ""))
|
||||||
XCTAssertNil(value("PageSize", in: emptyPaper))
|
XCTAssertNil(value("PageSize", in: emptyPaper))
|
||||||
XCTAssertNil(emptyPaper.paperToken)
|
XCTAssertNil(emptyPaper.paperToken)
|
||||||
|
|
||||||
|
// Tray: key or value alone, or an empty value, emits no write.
|
||||||
|
let trayKeyOnly = resolve(
|
||||||
|
overrides: TargetPrintOverrides(trayKey: "EPIJ_FdSo"),
|
||||||
|
optionKeys: ["EPIJ_FdSo"])
|
||||||
|
XCTAssertNil(value("EPIJ_FdSo", in: trayKeyOnly))
|
||||||
|
let trayValueOnly = resolve(
|
||||||
|
overrides: TargetPrintOverrides(tray: "Rear"),
|
||||||
|
optionKeys: ["EPIJ_FdSo"])
|
||||||
|
XCTAssertNil(value("EPIJ_FdSo", in: trayValueOnly))
|
||||||
|
let emptyTray = resolve(
|
||||||
|
overrides: TargetPrintOverrides(trayKey: "EPIJ_FdSo", tray: ""),
|
||||||
|
optionKeys: ["EPIJ_FdSo"])
|
||||||
|
XCTAssertNil(value("EPIJ_FdSo", in: emptyTray))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The write list equals the locked 1–10 order, exactly.
|
/// #218 paper source is emitted with its canonical key and the raw
|
||||||
|
/// token; the display label must never reach the ticket.
|
||||||
|
func testTrayWrite() {
|
||||||
|
let r = resolve(
|
||||||
|
overrides: TargetPrintOverrides(
|
||||||
|
trayKey: "EPIJ_FdSo", tray: "Rear"),
|
||||||
|
optionKeys: ["EPIJ_FdSo"])
|
||||||
|
XCTAssertEqual(value("EPIJ_FdSo", in: r), "Rear")
|
||||||
|
XCTAssertEqual(r.writes.first { $0.key == "EPIJ_FdSo" }?.locked,
|
||||||
|
false)
|
||||||
|
XCTAssertNil(value("InputSlot", in: r))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The write list equals the locked 1–11 order, exactly.
|
||||||
func testLockedWriteOrder() {
|
func testLockedWriteOrder() {
|
||||||
let r = resolve(
|
let r = resolve(
|
||||||
overrides: TargetPrintOverrides(
|
overrides: TargetPrintOverrides(
|
||||||
paperSize: "A4", mediaType: "Photo",
|
paperSize: "A4", mediaType: "Photo",
|
||||||
qualityKey: "EPIJ_Qual", quality: "305",
|
qualityKey: "EPIJ_Qual", quality: "305",
|
||||||
|
trayKey: "EPIJ_FdSo", tray: "Rear",
|
||||||
orientation: "landscape"),
|
orientation: "landscape"),
|
||||||
optionKeys: ["MediaType", "EPIJ_Qual", "EPIJ_CMat"])
|
optionKeys: ["MediaType", "EPIJ_Qual", "EPIJ_CMat"])
|
||||||
XCTAssertEqual(keys(r), [
|
XCTAssertEqual(keys(r), [
|
||||||
@@ -185,6 +214,7 @@ final class TicketWriteResolverTests: XCTestCase {
|
|||||||
"PMCustomColorMatchingProfile",
|
"PMCustomColorMatchingProfile",
|
||||||
"com.apple.print.PrintSettings.PMColorMatchingMode",
|
"com.apple.print.PrintSettings.PMColorMatchingMode",
|
||||||
"PageSize",
|
"PageSize",
|
||||||
|
"EPIJ_FdSo",
|
||||||
"MediaType",
|
"MediaType",
|
||||||
"EPIJ_Qual",
|
"EPIJ_Qual",
|
||||||
"EPIJ_CMat",
|
"EPIJ_CMat",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ for arg in "$@"; do
|
|||||||
done
|
done
|
||||||
if [ "$list" = "1" ]; then
|
if [ "$list" = "1" ]; then
|
||||||
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n'
|
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n'
|
||||||
printf 'InputSlot/Media Source: Auto *Main Rear\n'
|
printf 'EPIJ_FdSo/Paper Source: Auto *Main Rear\n'
|
||||||
printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n'
|
printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n'
|
||||||
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n'
|
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n'
|
||||||
printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n'
|
printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n'
|
||||||
|
|||||||
@@ -124,8 +124,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
|
|||||||
XCTAssertTrue(element("printerSelect").exists)
|
XCTAssertTrue(element("printerSelect").exists)
|
||||||
XCTAssertTrue(element("printerTraySelect").exists)
|
XCTAssertTrue(element("printerTraySelect").exists)
|
||||||
XCTAssertTrue(element("printerMediaTypeSelect").exists)
|
XCTAssertTrue(element("printerMediaTypeSelect").exists)
|
||||||
XCTAssertTrue(element("btnOrientPortrait").exists)
|
XCTAssertTrue(element("printerOrientationSelect").exists)
|
||||||
XCTAssertTrue(element("btnOrientLandscape").exists)
|
|
||||||
XCTAssertTrue(element("btnPrinterProperties").exists)
|
XCTAssertTrue(element("btnPrinterProperties").exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +250,87 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
|
|||||||
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
|
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #214 — a fixture PPD pointing at a fixture `PDEData.dat` (via
|
||||||
|
/// `ICCERY_CUPS_PPD_DIR`) constrains the quality picker to the
|
||||||
|
/// media's allowed set; switching media re-filters and clamps the
|
||||||
|
/// selection.
|
||||||
|
func testQualityPickerFiltersByMediaConstraints() throws {
|
||||||
|
let ppdDir = testRoot.appendingPathComponent("ppd")
|
||||||
|
let epsonRoot = testRoot.appendingPathComponent("epson-driver")
|
||||||
|
let datDir = epsonRoot.appendingPathComponent(
|
||||||
|
"Machine/M.data/Contents/Resources")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: ppdDir, withIntermediateDirectories: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: datDir, withIntermediateDirectories: true)
|
||||||
|
try """
|
||||||
|
*EPIJDriverBasePath: "\(epsonRoot.path)"
|
||||||
|
*EPIJMachineBundleName: "M.data"
|
||||||
|
""".write(
|
||||||
|
to: ppdDir.appendingPathComponent("Mock_Epson_7450.ppd"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
try """
|
||||||
|
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 305
|
||||||
|
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 307
|
||||||
|
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 308
|
||||||
|
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 301
|
||||||
|
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 302
|
||||||
|
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 303
|
||||||
|
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 304
|
||||||
|
""".write(
|
||||||
|
to: datDir.appendingPathComponent("PDEData.dat"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
app.launchEnvironment["ICCERY_CUPS_PPD_DIR"] = ppdDir.path
|
||||||
|
launchAppWithDefaults()
|
||||||
|
reachPrintPanel()
|
||||||
|
_ = waitFor("printerStatusBadge")
|
||||||
|
|
||||||
|
let qualityPopup = app.popUpButtons["printerQualitySelect"]
|
||||||
|
XCTAssertTrue(qualityPopup.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
|
// Default media Stationery → {301,302,303,304} only.
|
||||||
|
qualityPopup.click()
|
||||||
|
let stationeryExpected = ["301", "302", "303", "304"]
|
||||||
|
for token in stationeryExpected {
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.menuItems[token].waitForExistence(timeout: 5),
|
||||||
|
"Missing quality menu item \(token)")
|
||||||
|
}
|
||||||
|
XCTAssertFalse(app.menuItems["307"].exists)
|
||||||
|
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||||
|
|
||||||
|
// Switch to PhotographicGlossy → {305,307,308} in driver
|
||||||
|
// order (308 first — lpoptions order), selection clamped.
|
||||||
|
let mediaPopup = element("printerMediaTypeSelect")
|
||||||
|
.descendants(matching: .popUpButton).firstMatch
|
||||||
|
XCTAssertTrue(mediaPopup.waitForExistence(timeout: 5))
|
||||||
|
mediaPopup.click()
|
||||||
|
let glossyItem = app.menuItems["PhotographicGlossy"]
|
||||||
|
XCTAssertTrue(glossyItem.waitForExistence(timeout: 5))
|
||||||
|
glossyItem.click()
|
||||||
|
|
||||||
|
qualityPopup.click()
|
||||||
|
let glossyExpected = ["308", "305", "307"]
|
||||||
|
for token in glossyExpected {
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.menuItems[token].waitForExistence(timeout: 5),
|
||||||
|
"Missing quality menu item \(token)")
|
||||||
|
}
|
||||||
|
let titles = app.menuItems.allElementsBoundByIndex
|
||||||
|
.map(\.title)
|
||||||
|
.filter { glossyExpected.contains($0) }
|
||||||
|
XCTAssertEqual(titles, glossyExpected)
|
||||||
|
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||||
|
|
||||||
|
// The stale pick (303) clamped to the first allowed token.
|
||||||
|
let deadline = Date().addingTimeInterval(5)
|
||||||
|
while Date() < deadline,
|
||||||
|
selection(of: "printerQualitySelect") != "308" {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(selection(of: "printerQualitySelect"), "308")
|
||||||
|
}
|
||||||
|
|
||||||
/// #201 D5 — per-page mode records one line per page; the
|
/// #201 D5 — per-page mode records one line per page; the
|
||||||
/// `chkSingleSpoolJob` toggle collapses the job into a single
|
/// `chkSingleSpoolJob` toggle collapses the job into a single
|
||||||
/// request logged once with `pages=N`.
|
/// request logged once with `pages=N`.
|
||||||
|
|||||||
@@ -114,18 +114,18 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
return recordedSpoolLog()
|
return recordedSpoolLog()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button
|
/// Scrolls `stage-2` with the synthesized scroll wheel so
|
||||||
/// moves up, clear of the Dock collision zone at the window's
|
/// `identifier`'s button moves up, clear of the Dock collision
|
||||||
/// bottom edge (#132).
|
/// zone at the window's bottom edge (#132).
|
||||||
///
|
///
|
||||||
/// macOS overlay scrollbars are not in the AX tree — never use
|
/// macOS overlay scrollbars are not in the AX tree — never use
|
||||||
/// `app.scrollBars` — and a synthesized scroll wheel is inert on
|
/// `app.scrollBars` — and a click-drag does not scroll a macOS
|
||||||
/// this LazyVGrid, so the scroll is a real drag on the gallery
|
/// ScrollView (content-drag scrolling is iOS-only); the scroll
|
||||||
/// cell's content. A stale/off-screen AX frame resolves to a screen
|
/// wheel is the mechanism the platform supports (#215). A
|
||||||
/// point that can be a Dock icon — a coordinate click there once
|
/// stale/off-screen AX frame resolves to a screen point that can
|
||||||
/// opened Calendar instead of Print. Callers must click only when
|
/// be a Dock icon — a coordinate click there once opened Calendar
|
||||||
/// the returned element `isHittable`; never coordinate-click a
|
/// instead of Print. Callers must click only when the returned
|
||||||
/// stale frame.
|
/// element `isHittable`; never coordinate-click a stale frame.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func scrollStage2UntilHittable(
|
private func scrollStage2UntilHittable(
|
||||||
_ identifier: String,
|
_ identifier: String,
|
||||||
@@ -134,6 +134,8 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
var button = app.buttons[identifier]
|
var button = app.buttons[identifier]
|
||||||
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
|
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
|
||||||
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0")
|
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0")
|
||||||
|
let scrollView = app.scrollViews["stage-2"]
|
||||||
|
XCTAssertTrue(scrollView.waitForExistence(timeout: 10), "stage-2")
|
||||||
|
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
@@ -142,25 +144,12 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
button.frame.maxY < windowBottom - 80 {
|
button.frame.maxY < windowBottom - 80 {
|
||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
// Grab the upper half of the cell (the TIFF, not the Print
|
// Wheel-down inside the stage-2 viewport: content moves
|
||||||
// button / Dock) and drag toward the top of the window.
|
// UP ⇒ Print leaves the Dock zone. Negative deltaY scrolls
|
||||||
// Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves
|
// toward the document bottom (#215).
|
||||||
// the Dock zone.
|
scrollView.coordinate(withNormalizedOffset:
|
||||||
if cell.isHittable {
|
CGVector(dx: 0.5, dy: 0.5))
|
||||||
let start = cell.coordinate(withNormalizedOffset:
|
.scroll(byDeltaX: 0, deltaY: -60)
|
||||||
CGVector(dx: 0.5, dy: 0.25))
|
|
||||||
let end = start.withOffset(CGVector(dx: 0, dy: -280))
|
|
||||||
start.press(forDuration: 0.15, thenDragTo: end)
|
|
||||||
} else {
|
|
||||||
// Cell not hit-testable: drag the stage-2 content
|
|
||||||
// directly — still content, still never scrollBars.
|
|
||||||
let scrollView = app.scrollViews["stage-2"]
|
|
||||||
scrollView.coordinate(withNormalizedOffset:
|
|
||||||
CGVector(dx: 0.5, dy: 0.55))
|
|
||||||
.press(forDuration: 0.15, thenDragTo:
|
|
||||||
scrollView.coordinate(withNormalizedOffset:
|
|
||||||
CGVector(dx: 0.5, dy: 0.15)))
|
|
||||||
}
|
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||||
button = app.buttons[identifier]
|
button = app.buttons[identifier]
|
||||||
}
|
}
|
||||||
@@ -183,8 +172,7 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
.waitForExistence(timeout: 10))
|
.waitForExistence(timeout: 10))
|
||||||
XCTAssertTrue(element("printerTraySelect").exists)
|
XCTAssertTrue(element("printerTraySelect").exists)
|
||||||
XCTAssertTrue(element("printerMediaTypeSelect").exists)
|
XCTAssertTrue(element("printerMediaTypeSelect").exists)
|
||||||
XCTAssertTrue(element("btnOrientPortrait").exists)
|
XCTAssertTrue(element("printerOrientationSelect").exists)
|
||||||
XCTAssertTrue(element("btnOrientLandscape").exists)
|
|
||||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +201,7 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
func testPanelSelectionsApplyBackToSpoolWrites() throws {
|
func testPanelSelectionsApplyBackToSpoolWrites() throws {
|
||||||
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
|
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
|
||||||
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
|
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
|
||||||
"InputSlot=Rear MediaType=PhotographicGlossy"
|
"EPIJ_FdSo=Rear MediaType=PhotographicGlossy"
|
||||||
launchApp()
|
launchApp()
|
||||||
reachPrintPanel()
|
reachPrintPanel()
|
||||||
_ = waitFor("printerStatusBadge")
|
_ = waitFor("printerStatusBadge")
|
||||||
@@ -231,6 +219,7 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
XCTAssertTrue(log.contains(
|
XCTAssertTrue(log.contains(
|
||||||
"AP.ColorMatchingMode=AP_ApplicationColorMatching"), log)
|
"AP.ColorMatchingMode=AP_ApplicationColorMatching"), log)
|
||||||
XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log)
|
XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log)
|
||||||
|
XCTAssertTrue(log.contains("EPIJ_FdSo=Rear"), log)
|
||||||
// Detected bypass for the mock queue (EPIJ_CMat present in
|
// Detected bypass for the mock queue (EPIJ_CMat present in
|
||||||
// lpoptions -l) is always written.
|
// lpoptions -l) is always written.
|
||||||
XCTAssertTrue(log.contains("EPIJ_CMat=3"), log)
|
XCTAssertTrue(log.contains("EPIJ_CMat=3"), log)
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor
|
|||||||
|
|
||||||
| Field | Type | Meaning |
|
| Field | Type | Meaning |
|
||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
| `id` | `u16` | Windows: Win32 bin ID from `DC_BINS`. Unix: 1-based index of the `InputSlot` / `MediaSource` value (not a PPD code). |
|
| `id` | `u16` | Windows: Win32 bin ID from `DC_BINS`. Unix/macOS: 1-based index of the detected tray listing. |
|
||||||
| `name` | `String` | Windows: 24-WCHAR `DC_BINNAMES` string, or `"Tray {id}"` if empty. Unix: PPD choice with leading `*` stripped (e.g. `Auto`, `Upper`). |
|
| `token` | `String` | **v2** (#218): the exact CUPS/PPD machine value (`Auto`, `Rear`, `1`, ...). |
|
||||||
|
| `name` | `String` | Windows: 24-WCHAR `DC_BINNAMES` string, or `"Tray {id}"` if empty. Unix/macOS: PPD-enriched human label; if no PPD label is available, the raw token is used for display. |
|
||||||
|
|
||||||
### `PrinterPaperSize` (mod.rs:22–25)
|
### `PrinterPaperSize` (mod.rs:22–25)
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor
|
|||||||
|
|
||||||
| Field | Type | Windows | Linux | macOS |
|
| Field | Type | Windows | Linux | macOS |
|
||||||
|-------|------|---------|-------|-------|
|
|-------|------|---------|-------|-------|
|
||||||
| `paper_source` | `Option<u16>` | `dmDefaultSource` (tray id) | **ignored** (unix `build_lp_args` never emits `InputSlot`) | **ignored** unless present in captured `cups_options` |
|
| `paper_source` | `Option<u16>` | `dmDefaultSource` (tray id) | **ignored** (unix `build_lp_args` never emits `InputSlot`) | v2 (#218): 1-based tray id driven by the Stage 2 picker; written to the detected tray key (`InputSlot`/`MediaSource`/`EPIJ_FdSo`/`EPIJ_PSrc`/`CNIJMediaSupply`) on the native ticket. |
|
||||||
| `orientation` | `Option<String>` | `"landscape"` → `DMORIENT_LANDSCAPE` (2), else portrait (1) | `-o orientation-requested=4` (landscape) or `=3` (portrait) | same, skipped if `cups_options` already has the key |
|
| `orientation` | `Option<String>` | `"landscape"` → `DMORIENT_LANDSCAPE` (2), else portrait (1) | `-o orientation-requested=4` (landscape) or `=3` (portrait) | same, skipped if `cups_options` already has the key |
|
||||||
| `paper_size` | `Option<String>` | **ignored** | `-o PageSize=<value>` | same, skipped if `pagesize` already added |
|
| `paper_size` | `Option<String>` | **ignored** | `-o PageSize=<value>` | same, skipped if `pagesize` already added |
|
||||||
| `media_type` | `Option<String>` | parsed as `u32` → `dmMediaType` | `-o MediaType=<value>` (generic key only) | uses `detect_media_type_key` (`CNIJMediaType` / `EPIJ_Medi` / `StpMediaType` / `MediaType`); skipped if any of those keys already in `cups_options` |
|
| `media_type` | `Option<String>` | parsed as `u32` → `dmMediaType` | `-o MediaType=<value>` (generic key only) | uses `detect_media_type_key` (`CNIJMediaType` / `EPIJ_Medi` / `StpMediaType` / `MediaType`); skipped if any of those keys already in `cups_options` |
|
||||||
|
|||||||
@@ -236,7 +236,8 @@ After OK:
|
|||||||
4. Copy via `CStr`, `free(opts_ptr)`.
|
4. Copy via `CStr`, `free(opts_ptr)`.
|
||||||
5. `filter_cups_options_string` → `cups_options`.
|
5. `filter_cups_options_string` → `cups_options`.
|
||||||
6. `extract_media_type_from_options` → `media_type`.
|
6. `extract_media_type_from_options` → `media_type`.
|
||||||
7. Return `PrintPropertiesResult { selected_printer, options: PrintOptions { media_type, cups_options, ppd_uncorrected_passthrough: Some(true), ..Default } }`.
|
7. `extract_tray_from_options` → the selected tray token and a 1-based `paper_source` id when it matches the roster.
|
||||||
|
8. Return `PrintPropertiesResult { selected_printer, options: PrintOptions { media_type, cups_options, paper_source, ppd_uncorrected_passthrough: Some(true), ..Default } }`.
|
||||||
|
|
||||||
Failure of `PMPrintSettingsToOptions` is a hard `Err`.
|
Failure of `PMPrintSettingsToOptions` is a hard `Err`.
|
||||||
|
|
||||||
@@ -264,7 +265,7 @@ Captured from the panel into the Stage 2 mirror (v1 forwarded them to `lp`):
|
|||||||
|
|
||||||
```
|
```
|
||||||
Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType
|
Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType
|
||||||
Tray: InputSlot, AP_D_InputSlot
|
Tray: InputSlot, MediaSource, AP_D_InputSlot, EPIJ_FdSo, EPIJ_PSrc, CNIJMediaSupply
|
||||||
Size: PageSize
|
Size: PageSize
|
||||||
Color: CNIJIntent2, CNIJIntent, EPIJ_CMat, EPIJ_CCor, EPIJ_OSColMat,
|
Color: CNIJIntent2, CNIJIntent, EPIJ_CMat, EPIJ_CCor, EPIJ_OSColMat,
|
||||||
ColorCorrection, StpColorCorrection, EpsonColorMode, ColorModel
|
ColorCorrection, StpColorCorrection, EpsonColorMode, ColorModel
|
||||||
@@ -306,8 +307,9 @@ S14 PMRelease the printer on every path (defer)
|
|||||||
|
|
||||||
`TicketWriteResolver` produces the resolved write list as a pure value, in a
|
`TicketWriteResolver` produces the resolved write list as a pure value, in a
|
||||||
locked order: both `AP_*` keys (locked) → the three Quartz keys (⑤′) →
|
locked order: both `AP_*` keys (locked) → the three Quartz keys (⑤′) →
|
||||||
`PageSize` → the detected media key → the detected quality key → the driver
|
`PageSize` → the detected tray key → the detected media key → the detected
|
||||||
colour bypass → `orientation-requested`. Stage 2 overrides **always win**
|
quality key → the driver colour bypass → `orientation-requested`. Stage 2
|
||||||
|
overrides **always win**
|
||||||
over the rehydrated ticket (D6 — the v1 captured-wins inversion is gone);
|
over the rehydrated ticket (D6 — the v1 captured-wins inversion is gone);
|
||||||
`raw` can never appear because there is no `lp`. Panels stay off
|
`raw` can never appear because there is no `lp`. Panels stay off
|
||||||
(`showsPrintPanel` / `showsProgressPanel` false, `canSpawnSeparateThread`
|
(`showsPrintPanel` / `showsProgressPanel` false, `canSpawnSeparateThread`
|
||||||
@@ -407,8 +409,9 @@ AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPan
|
|||||||
### PPD keys (detection)
|
### PPD keys (detection)
|
||||||
|
|
||||||
```
|
```
|
||||||
Media: CNIJMediaType, EPIJ_Medi, StpMediaType, MediaType, MediaSource, InputSlot, PageSize, MediaSize
|
Media: CNIJMediaType, EPIJ_Medi, StpMediaType, MediaType, PageSize, MediaSize
|
||||||
Color: CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, EpsonColorMode
|
Tray: InputSlot, MediaSource, EPIJ_FdSo, EPIJ_PSrc, CNIJMediaSupply (EPIJ_FdSo preferred over EPIJ_PSrc when both present)
|
||||||
|
Color: CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, EpsonColorMode
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -130,7 +130,7 @@ Grouped by subsystem. Issue numbers are always cited.
|
|||||||
|
|
||||||
**Sister-product traps (ICCery-CPU `#1`, `#2`, `#5`) that also apply to any CUPS rewrite:**
|
**Sister-product traps (ICCery-CPU `#1`, `#2`, `#5`) that also apply to any CUPS rewrite:**
|
||||||
- PPD `ChoiceName/TranslationString` — UI must show the title, send the code (`MediaType=13` not `MediaType=Epson Premium Glossy`).
|
- PPD `ChoiceName/TranslationString` — UI must show the title, send the code (`MediaType=13` not `MediaType=Epson Premium Glossy`).
|
||||||
- Tray keywords are vendor-specific (`InputSlot` vs Epson `EPIJ_FdSo` vs Canon `CNIJMediaSupply`). Empty arrays are not `nil` — coalescing `?? ["Auto"]` does not fire.
|
- Tray keywords are vendor-specific (`InputSlot`/`MediaSource` vs Epson `EPIJ_FdSo`/`EPIJ_PSrc` vs Canon `CNIJMediaSupply`). When both Epson keys are present, `EPIJ_FdSo` is the feed-source control and takes precedence over `EPIJ_PSrc`. Empty arrays are not `nil` — coalescing `?? ["Auto"]` does not fire.
|
||||||
- Canon Super Fine is three coordinated keys (`CNIJPrintQuality=0`, `CNIJPrintMode2=5`, `CNIJPQualitySlider=5`), not `cupsPrintQuality=High`.
|
- Canon Super Fine is three coordinated keys (`CNIJPrintQuality=0`, `CNIJPrintMode2=5`, `CNIJPQualitySlider=5`), not `cupsPrintQuality=High`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-1
@@ -61,7 +61,7 @@ targets:
|
|||||||
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
|
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
|
||||||
INFOPLIST_KEY_NSPrincipalClass: NSApplication
|
INFOPLIST_KEY_NSPrincipalClass: NSApplication
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
||||||
MARKETING_VERSION: "2.0.2"
|
MARKETING_VERSION: "2.0.3"
|
||||||
CURRENT_PROJECT_VERSION: "1"
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
# Stamped by scripts/version.sh at packaging/CI time (release tag or
|
# Stamped by scripts/version.sh at packaging/CI time (release tag or
|
||||||
# `git describe` string); About shows it next to the marketing version.
|
# `git describe` string); About shows it next to the marketing version.
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ if [ ! -f "$DMG_BACKGROUND" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Building DMG"
|
echo "==> Building DMG"
|
||||||
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.0')"
|
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.3')"
|
||||||
BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')"
|
BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')"
|
||||||
# Tagged/described builds carry the tag in the DMG name so prerelease
|
# Tagged/described builds carry the tag in the DMG name so prerelease
|
||||||
# artefacts are self-describing (ICCery-2.0.0-pre2-grok-236.dmg); a plain
|
# artefacts are self-describing (ICCery-2.0.0-pre2-grok-236.dmg); a plain
|
||||||
|
|||||||
Reference in New Issue
Block a user