Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7f6a8bd87 | ||
|
|
ed91ebf807 | ||
|
|
77c065f433 | ||
|
|
f7099a9808 | ||
|
|
ddb9245f44 | ||
|
|
226e7b2a78 | ||
|
|
2407abfd9e | ||
|
|
356ae0bfa9 | ||
|
|
60f3aa3991 | ||
|
|
b89d9777ad | ||
|
|
7a3a0886be | ||
|
|
5389e4c2bd | ||
|
|
972368aa85 | ||
|
|
4f3caf335d | ||
|
|
de142a9690 | ||
|
|
a2f994821f | ||
|
|
1d6032456d | ||
|
|
fc1530ab2e | ||
|
|
ffbe56f43a | ||
|
|
ee5da4a637 | ||
|
|
0513b272cb |
@@ -11,8 +11,12 @@ Native macOS printer ICC/ICM profiling frontend. Drives the Gronod ArgyllCMS 3.5
|
|||||||
- No Tauri, no Rust host, no WKWebView, no Three.js.
|
- No Tauri, no Rust host, no WKWebView, no Three.js.
|
||||||
|
|
||||||
## Package layout
|
## Package layout
|
||||||
- `ICCery` — app target (SwiftUI shell).
|
- `ICCery` — app target (SwiftUI shell). Also owns the native print stack in
|
||||||
- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel).
|
`Sources/ICCery/Print/`: `PMTicketBridge`, `PrintTicket`,
|
||||||
|
`TicketWriteResolver`, `NativeTargetSpooler` (+ `RecordingTargetSpooler`),
|
||||||
|
`TargetRaster`, `TargetPageCanvasView` (#201 D1 — AppKit/`NSPrintOperation`
|
||||||
|
lives here, never in `ICCeryCore`).
|
||||||
|
- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel; CUPS enumeration/parsers only).
|
||||||
- `ICCeryPrintKit` — v2.1 only (issue 16). Zero deps on wizard types.
|
- `ICCeryPrintKit` — v2.1 only (issue 16). Zero deps on wizard types.
|
||||||
|
|
||||||
## AGPL boundary
|
## AGPL boundary
|
||||||
@@ -39,6 +43,17 @@ Empty cwd illegal (#59). Atomic writes = `.tmp` + rename (#213).
|
|||||||
User-supplied strings via SwiftUI `Text` only (#114).
|
User-supplied strings via SwiftUI `Text` only (#114).
|
||||||
TIFF never rendered directly — host-side PNG preview (#58).
|
TIFF never rendered directly — host-side PNG preview (#58).
|
||||||
|
|
||||||
|
## Print spool — native since v2.0 (#201); v1 `lp` path eradicated
|
||||||
|
Target printing is a headless `NSPrintOperation` via `NativeTargetSpooler`
|
||||||
|
(#201): restore the captured `PrintTicket`, apply `TicketWriteResolver`
|
||||||
|
(Stage 2 always wins, D6), draw 1:1 with interpolation off.
|
||||||
|
`lp` is eradicated from the target-print path (historical v1: `LpArgs`,
|
||||||
|
`CupsService.printTarget`, `ICCERY_TEST_LP_ARGV` all deleted).
|
||||||
|
`CupsParsers`/`CupsOptionsFilter` stay (D4): enumeration, capabilities,
|
||||||
|
media/quality/bypass key detection and the Stage 2 mirror.
|
||||||
|
UI-test seam: `ICCERY_TEST_SPOOL_LOG` — DEBUG `RecordingTargetSpooler`
|
||||||
|
appends one resolved-ticket line per page (D8).
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
`scripts/version.sh` is the single source: tag/describe → `ICCERY_RELEASE_TAG`
|
`scripts/version.sh` is the single source: tag/describe → `ICCERY_RELEASE_TAG`
|
||||||
(About shows `tag (marketing)`), `MARKETING_VERSION` = strict `X.Y.Z`,
|
(About shows `tag (marketing)`), `MARKETING_VERSION` = strict `X.Y.Z`,
|
||||||
@@ -50,6 +65,27 @@ bump `project.yml` on `develop` before tagging. CI needs `fetch-depth: 0`.
|
|||||||
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
|
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
|
||||||
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
|
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
|
||||||
|
|
||||||
|
## Issue ticket style
|
||||||
|
- Title: `[Kind/Priority] short description` — e.g. `[Bug/Critical] …`, `[Feature/Medium] …`.
|
||||||
|
- Labels: `Kind/Bug` or `Kind/Feature` (also `Kind/Testing` for test work),
|
||||||
|
one `Bug/<area>` or `Feature/<area>` (Architecture/Backend/UI/DevOps),
|
||||||
|
one `Priority/*`, plus `Project/ICCery-v2`. Set the milestone when the work
|
||||||
|
belongs to an active `mN` milestone.
|
||||||
|
- Bug bodies: `## Summary` → `## Root Cause Analysis` (file:line evidence;
|
||||||
|
note checked-and-dismissed hypotheses) → `## Proposed Fix` (options or
|
||||||
|
deterministic plan) → `## Acceptance Criteria` (checkbox list) →
|
||||||
|
`## Dependencies` → `## References`.
|
||||||
|
- Feature bodies: same skeleton minus Root Cause; lead with Summary and a
|
||||||
|
concrete implementation plan.
|
||||||
|
- Dependencies/blockers must **always** be recorded via the gitea MCP
|
||||||
|
`issue_write` methods (`add_dependency`, `block_issue`; reads via
|
||||||
|
`issue_read` `list_dependencies` / `list_blocks` — see "Gitea issue
|
||||||
|
dependencies"), not just mentioned in the body. This is
|
||||||
|
mandatory when issues share a milestone with an implementation order:
|
||||||
|
wire up `add_dependency` (blocked-by) and `block_issue` (blocks) links so
|
||||||
|
the order is machine-readable. The `## Dependencies` body section may
|
||||||
|
still summarise them for readability, but the MCP links are authoritative.
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
```
|
```
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"
|
||||||
@@ -57,10 +93,62 @@ 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)
|
||||||
|
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`.
|
||||||
`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix.
|
One spool path remains (#201 D2): write **both** vocabularies on the native
|
||||||
|
path — locked AP_* (`AP_ColorMatchingMode` + `AP.ColorMatchingMode` =
|
||||||
|
`AP_ApplicationColorMatching`) **and** the Quartz dictionary
|
||||||
|
(`PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`,
|
||||||
|
legacy `com.apple.print.PrintSettings.PMColorMatchingMode`, nested
|
||||||
|
`com.apple.print.printSettings` mirror).
|
||||||
|
|
||||||
## Gitea issue dependencies
|
## Gitea issue dependencies
|
||||||
Use the `gitea` MCP (custom build with blocking support — verified working):
|
Use the `gitea` MCP (custom build with blocking support — verified working):
|
||||||
|
|||||||
@@ -46,4 +46,23 @@ public enum ColorMatchingAttempts {
|
|||||||
"AP_ColorMatchingMode",
|
"AP_ColorMatchingMode",
|
||||||
"AP.ColorMatchingMode",
|
"AP.ColorMatchingMode",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// The Quartz/`NSPrintOperation` colour-matching vocabulary
|
||||||
|
/// (docs/14 §7). With the `lp` path removed there is a single
|
||||||
|
/// spool path and it carries **both** dictionaries — these keys
|
||||||
|
/// are written alongside the AP_* pair (#201 D2).
|
||||||
|
///
|
||||||
|
/// `PMColorMatchingMode=APCustomColorMatching` plus an empty
|
||||||
|
/// `PMCustomColorMatchingProfile` tell Quartz the application
|
||||||
|
/// supplies device colour; the `com.apple.print.PrintSettings.*`
|
||||||
|
/// legacy spelling covers drivers that read the flattened
|
||||||
|
/// dictionary.
|
||||||
|
public static let quartzModeKey = "PMColorMatchingMode"
|
||||||
|
public static let quartzCustomMatching = "APCustomColorMatching"
|
||||||
|
public static let quartzProfileKey = "PMCustomColorMatchingProfile"
|
||||||
|
public static let quartzLegacyModeKey =
|
||||||
|
"com.apple.print.PrintSettings.PMColorMatchingMode"
|
||||||
|
/// The nested sub-dictionary inside `NSPrintInfo.dictionary()` the
|
||||||
|
/// colour keys are mirrored into.
|
||||||
|
public static let quartzNestedDictKey = "com.apple.print.printSettings"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,26 @@ public enum CupsParsers {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `lpstat -v` — `device for <name>: <uri>` → queue → device URI.
|
||||||
|
/// Entries with no URI (e.g. `network <name>` remote stubs) are
|
||||||
|
/// skipped (#202 AirPrint detection).
|
||||||
|
public static func lpstatDeviceURIs(output: String) -> [String: String] {
|
||||||
|
var result: [String: String] = [:]
|
||||||
|
for line in output.split(separator: "\n") {
|
||||||
|
let text = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard text.hasPrefix("device for ") else { continue }
|
||||||
|
let rest = text.dropFirst("device for ".count)
|
||||||
|
guard let colon = rest.firstIndex(of: ":") else { continue }
|
||||||
|
let name = String(rest[..<colon])
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
let uri = String(rest[rest.index(after: colon)...])
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !name.isEmpty, !uri.isEmpty else { continue }
|
||||||
|
result[name] = uri
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - lpoptions -p <queue>
|
// MARK: - lpoptions -p <queue>
|
||||||
|
|
||||||
/// `lpoptions -p` — `key=value` pairs, values may be
|
/// `lpoptions -p` — `key=value` pairs, values may be
|
||||||
@@ -137,6 +157,16 @@ public enum CupsParsers {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Make-and-model from `printer-make-and-model` in `lpoptions -p`
|
||||||
|
/// output — an AirPrint detection input (#202).
|
||||||
|
public static func lpoptionsMakeAndModel(output: String) -> String? {
|
||||||
|
guard let value = lpoptions(output)
|
||||||
|
.first(where: { $0.key == "printer-make-and-model" })?.value,
|
||||||
|
!value.isEmpty
|
||||||
|
else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - lpoptions -l
|
// MARK: - lpoptions -l
|
||||||
|
|
||||||
/// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`.
|
/// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`.
|
||||||
@@ -352,4 +382,62 @@ public enum CupsParsers {
|
|||||||
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
||||||
"EpsonColorMode",
|
"EpsonColorMode",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// MARK: - AirPrint detection (#202, docs/14 §10.2)
|
||||||
|
|
||||||
|
/// AirPrint queues always colour-manage the URF raster path, so
|
||||||
|
/// unmanaged device colour cannot be guaranteed — Stage 2 warns
|
||||||
|
/// via `airPrintWarningBadge`. `true` when ANY rule matches:
|
||||||
|
///
|
||||||
|
/// 1. device URI contains `apple-airprint://`
|
||||||
|
/// 2. PPD declares `*APAirPrint: True`
|
||||||
|
/// 3. make-and-model contains "Apple" **and** "AirPrint"
|
||||||
|
/// 4. `ipps://` URI **and** the PPD text mentions "airprint"
|
||||||
|
/// (case-insensitive)
|
||||||
|
/// 5. local unencrypted `ipp://` URI resolved via an AirPrint mDNS
|
||||||
|
/// subtype (`_universal._sub._ipp._tcp`)
|
||||||
|
/// 6. PPD carries a `*cupsFilter2` rule whose destination MIME
|
||||||
|
/// type is `image/urf` — the raster only AirPrint consumes
|
||||||
|
public static func detectAirPrint(
|
||||||
|
deviceURI: String?, makeAndModel: String?, ppd: String
|
||||||
|
) -> Bool {
|
||||||
|
if let uri = deviceURI {
|
||||||
|
let lower = uri.lowercased()
|
||||||
|
if lower.contains("apple-airprint://") { return true }
|
||||||
|
if lower.hasPrefix("ipps://"),
|
||||||
|
ppd.range(of: "airprint", options: .caseInsensitive) != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if lower.hasPrefix("ipp://"),
|
||||||
|
lower.contains("_universal._sub._ipp._tcp") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ppd.contains("*APAirPrint: True") { return true }
|
||||||
|
if let model = makeAndModel,
|
||||||
|
model.contains("Apple"), model.contains("AirPrint") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return ppdCarriesURFFilter(ppd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 6 — `*cupsFilter2: "src dst cost program"` (quotes
|
||||||
|
/// optional); `image/urf` as the destination token means the queue
|
||||||
|
/// consumes the AirPrint raster.
|
||||||
|
private static func ppdCarriesURFFilter(_ ppd: String) -> Bool {
|
||||||
|
for rawLine in ppd.split(separator: "\n") {
|
||||||
|
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard line.lowercased().hasPrefix("*cupsfilter2"),
|
||||||
|
let colon = line.firstIndex(of: ":")
|
||||||
|
else { continue }
|
||||||
|
let tokens = line[line.index(after: colon)...]
|
||||||
|
.replacingOccurrences(of: "\"", with: "")
|
||||||
|
.split(separator: " ")
|
||||||
|
if tokens.count >= 2,
|
||||||
|
tokens[1].lowercased() == "image/urf" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Foundation
|
|||||||
/// Errors from CUPS tool invocations.
|
/// Errors from CUPS tool invocations.
|
||||||
public enum CupsError: LocalizedError, Equatable {
|
public enum CupsError: LocalizedError, Equatable {
|
||||||
case toolFailed(tool: String, code: Int32, stderr: String)
|
case toolFailed(tool: String, code: Int32, stderr: String)
|
||||||
case tiffMissing(String)
|
|
||||||
case noPrinterSelected
|
case noPrinterSelected
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
@@ -13,8 +12,6 @@ public enum CupsError: LocalizedError, Equatable {
|
|||||||
return detail.isEmpty
|
return detail.isEmpty
|
||||||
? "\(tool) failed with exit code \(code)"
|
? "\(tool) failed with exit code \(code)"
|
||||||
: "\(tool) failed (\(code)): \(detail)"
|
: "\(tool) failed (\(code)): \(detail)"
|
||||||
case .tiffMissing(let path):
|
|
||||||
return "Target TIFF does not exist: \(path)"
|
|
||||||
case .noPrinterSelected:
|
case .noPrinterSelected:
|
||||||
return "No printer selected."
|
return "No printer selected."
|
||||||
}
|
}
|
||||||
@@ -27,9 +24,11 @@ public enum CupsError: LocalizedError, Equatable {
|
|||||||
/// `ProcessManager.runCaptured` so spawns are logged, get killAll
|
/// `ProcessManager.runCaptured` so spawns are logged, get killAll
|
||||||
/// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are
|
/// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are
|
||||||
/// injectable so tests use fixture scripts and never touch real CUPS.
|
/// injectable so tests use fixture scripts and never touch real CUPS.
|
||||||
|
/// Target spooling is **not** here — it is the app-target
|
||||||
|
/// `NativeTargetSpooler` (`NSPrintOperation`, #201 D1/D4).
|
||||||
public struct CupsService: Sendable {
|
public struct CupsService: Sendable {
|
||||||
public let processManager: ProcessManager
|
public let processManager: ProcessManager
|
||||||
/// Directory containing `lpstat`/`lpoptions`/`lp` — `/usr/bin` in
|
/// Directory containing `lpstat`/`lpoptions` — `/usr/bin` in
|
||||||
/// production, a fixture dir under test.
|
/// production, a fixture dir under test.
|
||||||
public let binaryDir: URL
|
public let binaryDir: URL
|
||||||
/// `/etc/cups/ppd` in production.
|
/// `/etc/cups/ppd` in production.
|
||||||
@@ -53,24 +52,34 @@ public struct CupsService: Sendable {
|
|||||||
// lpstat exits non-zero when no destinations exist — an empty
|
// lpstat exits non-zero when no destinations exist — an empty
|
||||||
// queue list is a valid result, not a failure (issue 12).
|
// queue list is a valid result, not a failure (issue 12).
|
||||||
let destinationsOut = try await run(
|
let destinationsOut = try await run(
|
||||||
"lpstat", ["-e"], id: ProcessID.lpstat("e"), tolerateFailure: true)
|
"lpstat", ["-e"], id: ProcessID.cupsLpstat("e"), tolerateFailure: true)
|
||||||
let statusOut = try await run(
|
let statusOut = try await run(
|
||||||
"lpstat", ["-p"], id: ProcessID.lpstat("p"), tolerateFailure: true)
|
"lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true)
|
||||||
let defaultOut = try await run(
|
let defaultOut = try await run(
|
||||||
"lpstat", ["-d"], id: ProcessID.lpstat("d"), tolerateFailure: true)
|
"lpstat", ["-d"], id: ProcessID.cupsLpstat("d"), tolerateFailure: true)
|
||||||
|
// One-shot device-URI fetch for AirPrint detection (#202). A
|
||||||
|
// failed `lpstat -v` is tolerated — enumeration proceeds with
|
||||||
|
// no URIs and no fallback respawn.
|
||||||
|
let deviceOut = try await run(
|
||||||
|
"lpstat", ["-v"], id: ProcessID.cupsLpstat("v"), tolerateFailure: true)
|
||||||
|
|
||||||
let names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
|
let names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
|
||||||
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
|
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
|
||||||
let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout)
|
let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout)
|
||||||
|
let deviceURIs = CupsParsers.lpstatDeviceURIs(output: deviceOut.stdout)
|
||||||
|
|
||||||
var printers: [Printer] = []
|
var printers: [Printer] = []
|
||||||
for name in names {
|
for name in names {
|
||||||
let displayName = try? await displayName(for: name)
|
let identity = try? await queueIdentity(for: name)
|
||||||
printers.append(Printer(
|
printers.append(Printer(
|
||||||
name: name,
|
name: name,
|
||||||
status: statuses[name] ?? .unknown,
|
status: statuses[name] ?? .unknown,
|
||||||
isDefault: name == defaultName,
|
isDefault: name == defaultName,
|
||||||
displayName: displayName
|
displayName: identity?.displayName,
|
||||||
|
isAirPrint: CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: deviceURIs[name],
|
||||||
|
makeAndModel: identity?.makeAndModel,
|
||||||
|
ppd: loadPPD(for: name) ?? "")
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return printers
|
return printers
|
||||||
@@ -79,9 +88,19 @@ public struct CupsService: Sendable {
|
|||||||
/// `lpoptions -p <queue>` → `printer-info` (the NSPrinter fallback
|
/// `lpoptions -p <queue>` → `printer-info` (the NSPrinter fallback
|
||||||
/// display name, docs/11 §binding).
|
/// display name, docs/11 §binding).
|
||||||
public func displayName(for queue: String) async throws -> String? {
|
public func displayName(for queue: String) async throws -> String? {
|
||||||
|
try await queueIdentity(for: queue).displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `lpoptions -p <queue>` spawn yields both identity fields —
|
||||||
|
/// `printer-info` (display name) and `printer-make-and-model`
|
||||||
|
/// (AirPrint rule 3, #202).
|
||||||
|
private func queueIdentity(
|
||||||
|
for queue: String
|
||||||
|
) async throws -> (displayName: String?, makeAndModel: String?) {
|
||||||
let result = try await run(
|
let result = try await run(
|
||||||
"lpoptions", ["-p", queue], id: ProcessID.lpoptions(queue))
|
"lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue))
|
||||||
return CupsParsers.lpoptionsDisplayName(result.stdout)
|
return (CupsParsers.lpoptionsDisplayName(result.stdout),
|
||||||
|
CupsParsers.lpoptionsMakeAndModel(output: result.stdout))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Capabilities (lpoptions -l + PPD)
|
// MARK: - Capabilities (lpoptions -l + PPD)
|
||||||
@@ -90,15 +109,22 @@ public struct CupsService: Sendable {
|
|||||||
/// to media-key and colour-bypass detection (docs/11 layer ④).
|
/// to media-key and colour-bypass detection (docs/11 layer ④).
|
||||||
public func optionListings(for queue: String) async throws -> [CupsOptionListing] {
|
public func optionListings(for queue: String) async throws -> [CupsOptionListing] {
|
||||||
let result = try await run(
|
let result = try await run(
|
||||||
"lpoptions", ["-p", queue, "-l"], id: ProcessID.lpoptions("\(queue)-l"))
|
"lpoptions", ["-p", queue, "-l"], id: ProcessID.cupsLpoptions("\(queue)-l"))
|
||||||
return CupsParsers.lpoptionsList(result.stdout)
|
return CupsParsers.lpoptionsList(result.stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)
|
||||||
|
caps.qualityIDsByMediaType = MediaQualityConstraints.resolve(
|
||||||
|
listings: listings, ppd: ppd)
|
||||||
|
return caps
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure mapping — extracted so fixture tests need no process.
|
/// Pure mapping — extracted so fixture tests need no process.
|
||||||
@@ -162,27 +188,6 @@ public struct CupsService: Sendable {
|
|||||||
Set(try await optionListings(for: queue).map(\.key))
|
Set(try await optionListings(for: queue).map(\.key))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Spool (issue 15)
|
|
||||||
|
|
||||||
/// `lp -d <queue> … <tiff>` — spool one target page unmanaged.
|
|
||||||
/// Never uses `-o raw` (#92). `page` disambiguates the process id
|
|
||||||
/// when several pages are spooled in sequence.
|
|
||||||
public func printTarget(
|
|
||||||
queue: String,
|
|
||||||
tiffPath: String,
|
|
||||||
options: PrintOptions,
|
|
||||||
page: Int = 0
|
|
||||||
) async throws {
|
|
||||||
guard FileManager.default.fileExists(atPath: tiffPath) else {
|
|
||||||
throw CupsError.tiffMissing(tiffPath)
|
|
||||||
}
|
|
||||||
let optionKeys = (try? await self.optionKeys(for: queue)) ?? []
|
|
||||||
let argv = try LpArgs.build(
|
|
||||||
queue: queue, tiffPath: tiffPath,
|
|
||||||
options: options, optionKeys: optionKeys)
|
|
||||||
try await run("lp", argv, id: ProcessID.lp(queue, page: page))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PPD
|
// MARK: - PPD
|
||||||
|
|
||||||
private func loadPPD(for queue: String) -> String? {
|
private func loadPPD(for queue: String) -> String? {
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Errors from `buildLpArgs`.
|
|
||||||
public enum LpArgsError: LocalizedError, Equatable {
|
|
||||||
case unsanitisedOption(String)
|
|
||||||
|
|
||||||
public var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .unsanitisedOption(let option):
|
|
||||||
return "Captured CUPS option contains unsafe characters: \(option)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `lp` argv builder — issue 15, docs/11 `build_lp_args`.
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// lp -d <queue> -t "ICCery Target - <file>"
|
|
||||||
/// -o AP_ColorMatchingMode=AP_ApplicationColorMatching
|
|
||||||
/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching
|
|
||||||
/// <captured cups_options>
|
|
||||||
/// <media_type, if no media key already captured>
|
|
||||||
/// <quality, if no quality key already captured> (#183)
|
|
||||||
/// <driver bypass, if no bypass key captured>
|
|
||||||
/// <orientation-requested=3|4, unless captured>
|
|
||||||
/// <PageSize, unless captured>
|
|
||||||
/// <tiff>
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// - **Never `-o raw`** — `raw` skips the raster filter that honours
|
|
||||||
/// `AP_ApplicationColorMatching` (#92).
|
|
||||||
/// - Captured options **win** over explicit fields: any key already
|
|
||||||
/// present (case-insensitive) suppresses the derived `-o`.
|
|
||||||
/// - Captured keys/values are sanitised — `;`, newlines, or shell
|
|
||||||
/// metacharacters throw `unsanitisedOption`; args are passed as a
|
|
||||||
/// `Process` argv array, never through a shell.
|
|
||||||
/// - The TIFF path is always the **last** argument.
|
|
||||||
public enum LpArgs {
|
|
||||||
|
|
||||||
/// `options` = the captured `PrintOptions`; `optionKeys` = the
|
|
||||||
/// queue's `lpoptions -l` key set (for media-key/bypass detection).
|
|
||||||
public static func build(
|
|
||||||
queue: String,
|
|
||||||
tiffPath: String,
|
|
||||||
options: PrintOptions,
|
|
||||||
optionKeys: Set<String>
|
|
||||||
) throws -> [String] {
|
|
||||||
var argv: [String] = [
|
|
||||||
"-d", queue,
|
|
||||||
"-t", "ICCery Target - \((tiffPath as NSString).lastPathComponent)",
|
|
||||||
"-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching",
|
|
||||||
"-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching",
|
|
||||||
]
|
|
||||||
var addedKeys: Set<String> = [
|
|
||||||
"ap_colormatchingmode", "ap.colormatchingmode",
|
|
||||||
]
|
|
||||||
|
|
||||||
// Captured CUPS options — sanitised, lowercased-key dedup.
|
|
||||||
if let captured = options.cupsOptions, !captured.isEmpty {
|
|
||||||
// Newlines can't survive the tokeniser — check the raw
|
|
||||||
// string so embedded line breaks are still rejected.
|
|
||||||
if captured.contains("\n") || captured.contains("\r") {
|
|
||||||
throw LpArgsError.unsanitisedOption(captured)
|
|
||||||
}
|
|
||||||
for pair in CupsParsers.lpoptions(captured) {
|
|
||||||
try sanitize(pair.key, pair.value)
|
|
||||||
let lowered = pair.key.lowercased()
|
|
||||||
// Defence in depth: never let a captured `raw` reach
|
|
||||||
// argv — `-o raw` skips the raster filter that honours
|
|
||||||
// AP_ApplicationColorMatching (#92).
|
|
||||||
if lowered == "raw" { continue }
|
|
||||||
guard !addedKeys.contains(lowered) else { continue }
|
|
||||||
addedKeys.insert(lowered)
|
|
||||||
argv += ["-o", "\(pair.key)=\(pair.value)"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Media type — only when the captured options didn't carry one.
|
|
||||||
if let mediaType = options.mediaType,
|
|
||||||
let mediaKey = CupsParsers.detectMediaTypeKey(optionKeys: optionKeys),
|
|
||||||
!addedKeys.contains(mediaKey.lowercased()) {
|
|
||||||
addedKeys.insert(mediaKey.lowercased())
|
|
||||||
argv += ["-o", "\(mediaKey)=\(mediaType)"]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print quality — after media, before the driver bypass; the
|
|
||||||
// detected queue key is skipped when already captured (#183).
|
|
||||||
if let quality = options.quality,
|
|
||||||
let qualityKey = CupsParsers.detectQualityKey(optionKeys: optionKeys),
|
|
||||||
!addedKeys.contains(qualityKey.lowercased()) {
|
|
||||||
addedKeys.insert(qualityKey.lowercased())
|
|
||||||
argv += ["-o", "\(qualityKey)=\(quality)"]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Driver colour bypass — when no bypass key was captured. NOT
|
|
||||||
// gated on ppdUncorrectedPassthrough (macOS always bypasses).
|
|
||||||
let capturedKeys = Set(
|
|
||||||
CupsParsers.lpoptions(options.cupsOptions ?? "")
|
|
||||||
.map { $0.key })
|
|
||||||
if capturedKeys.isDisjoint(with: CupsParsers.bypassKeys),
|
|
||||||
let bypass = CupsParsers.detectDriverColorBypass(optionKeys: optionKeys),
|
|
||||||
!addedKeys.contains(bypass.key.lowercased()) {
|
|
||||||
addedKeys.insert(bypass.key.lowercased())
|
|
||||||
argv += ["-o", "\(bypass.key)=\(bypass.value)"]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Orientation — portrait=3, landscape=4.
|
|
||||||
if let orientation = options.orientation,
|
|
||||||
!addedKeys.contains("orientation-requested") {
|
|
||||||
let value = orientation == "landscape" ? "4" : "3"
|
|
||||||
addedKeys.insert("orientation-requested")
|
|
||||||
argv += ["-o", "orientation-requested=\(value)"]
|
|
||||||
}
|
|
||||||
|
|
||||||
// PageSize — the printtarg layout page size.
|
|
||||||
if let paperSize = options.paperSize, !paperSize.isEmpty,
|
|
||||||
!addedKeys.contains("pagesize") {
|
|
||||||
argv += ["-o", "PageSize=\(paperSize)"]
|
|
||||||
}
|
|
||||||
|
|
||||||
argv.append(tiffPath)
|
|
||||||
return argv
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reject shell/metachar injection — args go to `Process` as an
|
|
||||||
/// argv array, but a hostile captured string must not smuggle a
|
|
||||||
/// second option or command.
|
|
||||||
static func sanitize(_ key: String, _ value: String) throws {
|
|
||||||
let forbidden = CharacterSet(charactersIn: ";\n\r`|$&<>\\\"'")
|
|
||||||
if key.rangeOfCharacter(from: forbidden) != nil
|
|
||||||
|| value.rangeOfCharacter(from: forbidden) != nil
|
|
||||||
|| key.isEmpty {
|
|
||||||
throw LpArgsError.unsanitisedOption("\(key)=\(value)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
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?,
|
||||||
|
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)
|
||||||
|
let defaults = Dictionary(
|
||||||
|
listings.compactMap { l in l.defaultChoice.map { (l.key, $0) } },
|
||||||
|
uniquingKeysWith: { first, _ in first })
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,17 +17,33 @@ public struct Printer: Codable, Equatable, Sendable {
|
|||||||
public var status: PrinterStatus
|
public var status: PrinterStatus
|
||||||
public var isDefault: Bool
|
public var isDefault: Bool
|
||||||
public var displayName: String?
|
public var displayName: String?
|
||||||
|
/// AirPrint queue (#202, docs/14 §10.2) — the URF pipeline is always
|
||||||
|
/// colour-managed, so Stage 2 shows a persistent warning badge.
|
||||||
|
public var isAirPrint: Bool = false
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
name: String,
|
name: String,
|
||||||
status: PrinterStatus = .unknown,
|
status: PrinterStatus = .unknown,
|
||||||
isDefault: Bool = false,
|
isDefault: Bool = false,
|
||||||
displayName: String? = nil
|
displayName: String? = nil,
|
||||||
|
isAirPrint: Bool = false
|
||||||
) {
|
) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.status = status
|
self.status = status
|
||||||
self.isDefault = isDefault
|
self.isDefault = isDefault
|
||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
|
self.isAirPrint = isAirPrint
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `isAirPrint` predates #202 payloads — a missing key decodes as
|
||||||
|
/// `false` instead of failing the whole decode.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
status = try c.decode(PrinterStatus.self, forKey: .status)
|
||||||
|
isDefault = try c.decode(Bool.self, forKey: .isDefault)
|
||||||
|
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
|
||||||
|
isAirPrint = try c.decodeIfPresent(Bool.self, forKey: .isAirPrint) ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +107,11 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
|||||||
public var qualities: [PrinterQuality]
|
public var qualities: [PrinterQuality]
|
||||||
/// 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>]
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
trays: [PrinterTray] = [],
|
trays: [PrinterTray] = [],
|
||||||
@@ -99,7 +120,8 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
|||||||
supportsOrientation: Bool = true,
|
supportsOrientation: Bool = true,
|
||||||
qualityKey: String? = nil,
|
qualityKey: String? = nil,
|
||||||
qualities: [PrinterQuality] = [],
|
qualities: [PrinterQuality] = [],
|
||||||
qualityDefault: String? = nil
|
qualityDefault: String? = nil,
|
||||||
|
qualityIDsByMediaType: [String: Set<String>] = [:]
|
||||||
) {
|
) {
|
||||||
self.trays = trays
|
self.trays = trays
|
||||||
self.paperSizes = paperSizes
|
self.paperSizes = paperSizes
|
||||||
@@ -108,27 +130,56 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
|||||||
self.qualityKey = qualityKey
|
self.qualityKey = qualityKey
|
||||||
self.qualities = qualities
|
self.qualities = qualities
|
||||||
self.qualityDefault = qualityDefault
|
self.qualityDefault = qualityDefault
|
||||||
|
self.qualityIDsByMediaType = qualityIDsByMediaType
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Qualities valid for `mediaID`, preserving the driver's listing
|
||||||
|
/// order (#180). 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?) -> [PrinterQuality] {
|
||||||
|
guard let mediaID,
|
||||||
|
let allowed = qualityIDsByMediaType[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` — `true` whenever
|
||||||
|
/// the driver exposes no constraint for that media (#214). 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?
|
||||||
|
) -> Bool {
|
||||||
|
guard let mediaID,
|
||||||
|
let allowed = qualityIDsByMediaType[mediaID],
|
||||||
|
!allowed.isEmpty
|
||||||
|
else { return true }
|
||||||
|
return allowed.contains(qualityID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Options carried into `lp` (docs/10 §PrintOptions). On macOS
|
/// The Stage 2 mirror — panel selections and the captured `k=v`
|
||||||
/// `paperSource` is ignored unless already present inside captured
|
/// string (docs/10 §PrintOptions). Since #201 removed the `lp` path
|
||||||
/// `cupsOptions`; `ppdUncorrectedPassthrough` is stored (the panel sets
|
/// these fields feed `TargetPrintOverrides` (Stage 2 always wins, D6)
|
||||||
/// it on OK) but never gates the argv — macOS always bypasses driver
|
/// and the mirror apply-back; the opaque vendor state now travels
|
||||||
/// colour management.
|
/// inside the `PrintTicket`, not a flattened option string.
|
||||||
public struct PrintOptions: Codable, Equatable, Sendable {
|
public struct PrintOptions: Codable, Equatable, Sendable {
|
||||||
public var paperSource: Int?
|
public var paperSource: Int?
|
||||||
/// `"portrait"` / `"landscape"` → `orientation-requested=3|4`.
|
/// `"portrait"` / `"landscape"` → `orientation-requested=3|4`.
|
||||||
public var orientation: String?
|
public var orientation: String?
|
||||||
/// Stage 2 paper token → `PageSize=` (skipped if captured, #183).
|
/// Stage 2 paper token → `PageSize=` ticket write + `PMPaper`.
|
||||||
public var paperSize: String?
|
public var paperSize: String?
|
||||||
public var mediaType: String?
|
public var mediaType: String?
|
||||||
/// Print-quality token → `-o <detectedQualityKey>=` (skipped if
|
/// Print-quality token → `<detectedQualityKey>=` ticket write.
|
||||||
/// captured, #183).
|
|
||||||
public var quality: String?
|
public var quality: String?
|
||||||
public var ppdUncorrectedPassthrough: Bool?
|
public var ppdUncorrectedPassthrough: Bool?
|
||||||
/// Space-separated `key=value` captured from
|
/// Space-separated `key=value` captured from
|
||||||
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥).
|
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) —
|
||||||
|
/// the Stage 2 mirror only, never an `lp` payload.
|
||||||
public var cupsOptions: String?
|
public var cupsOptions: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ public enum ProcessID {
|
|||||||
public static func applycal(_ stem: String) -> String { "applycal_\(stem)" }
|
public static func applycal(_ stem: String) -> String { "applycal_\(stem)" }
|
||||||
|
|
||||||
/// CUPS system tools (`/usr/bin/…`) — captured one-shots, not
|
/// CUPS system tools (`/usr/bin/…`) — captured one-shots, not
|
||||||
/// streaming Argyll children.
|
/// streaming Argyll children. Named `cups*` so no `lp*`-prefixed
|
||||||
public static func lpstat(_ mode: String) -> String { "lpstat_\(mode)" }
|
/// identifier survives the target-print removal (#201).
|
||||||
public static func lpoptions(_ queue: String) -> String { "lpoptions_\(queue)" }
|
public static func cupsLpstat(_ mode: String) -> String { "lpstat_\(mode)" }
|
||||||
public static func lp(_ queue: String, page: Int) -> String { "lp_\(queue)_\(page)" }
|
public static func cupsLpoptions(_ queue: String) -> String { "lpoptions_\(queue)" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -207,15 +207,16 @@ not in this tree.
|
|||||||
leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate.
|
leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate.
|
||||||
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`,
|
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`,
|
||||||
`ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`,
|
`ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`,
|
||||||
`ProfcheckArgs`, `LpArgs`, `SpotReadArgs`, …). UI must not concatenate flags.
|
`ProfcheckArgs`, `SpotReadArgs`, …). UI must not concatenate flags.
|
||||||
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal`
|
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal`
|
||||||
must not replace the input profile on cancel or non-zero exit.
|
must not replace the input profile on cancel or non-zero exit.
|
||||||
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main
|
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main
|
||||||
actor. Swift 5.7 / macOS 12: `ObservableObject`, not Observation
|
actor. Swift 5.7 / macOS 12: `ObservableObject`, not Observation
|
||||||
`@Observable`.
|
`@Observable`.
|
||||||
- **Print.** Unmanaged `lp` with ColorSync suppression
|
- **Print.** Unmanaged headless `NSPrintOperation` (#201 — no `lp`):
|
||||||
(`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel`
|
restore the captured `PrintTicket`, write both ColorSync vocabularies
|
||||||
options win over derived CUPS keys. Never `lp -o raw`.
|
(locked `AP_*` + Quartz `PMColorMatchingMode`), Stage 2 selections always
|
||||||
|
win over the ticket, draw 1:1 with interpolation off.
|
||||||
- **SwiftUI ViewBuilder.** Xcode 14.2 / Swift 5.7 still has the ten-child
|
- **SwiftUI ViewBuilder.** Xcode 14.2 / Swift 5.7 still has the ten-child
|
||||||
limit. Split large `VStack`/`Group` trees (#146).
|
limit. Split large `VStack`/`Group` trees (#146).
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -106,12 +112,14 @@ enum UITestHooks {
|
|||||||
|
|
||||||
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
||||||
|
|
||||||
/// Directory of mock `lp`/`lpstat`/`lpoptions` fixture scripts —
|
/// Directory of mock `lpstat`/`lpoptions` fixture scripts —
|
||||||
/// `CupsService.binaryDir` under UI tests.
|
/// `CupsService.binaryDir` under UI tests.
|
||||||
static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") }
|
static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") }
|
||||||
|
|
||||||
/// Path the mock `lp` script appends its argv to, for assertions.
|
/// Path the `RecordingTargetSpooler` appends resolved ticket
|
||||||
static var lpArgvOutURL: URL? { url("ICCERY_TEST_LP_ARGV") }
|
/// lines to, for assertions (#201 D8 — replaces the `lp` argv
|
||||||
|
/// log).
|
||||||
|
static var spoolLogURL: URL? { url("ICCERY_TEST_SPOOL_LOG") }
|
||||||
|
|
||||||
/// Whether the `NSPrintPanel` should be stubbed under UI testing —
|
/// Whether the `NSPrintPanel` should be stubbed under UI testing —
|
||||||
/// separate from the stub's *result* so "cancel" (`nil`) does not
|
/// separate from the stub's *result* so "cancel" (`nil`) does not
|
||||||
|
|||||||
@@ -84,6 +84,35 @@ struct ColorSyncSuppressor {
|
|||||||
return applied
|
return applied
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Layer ⑤′ Quartz vocabulary (#201 D2)
|
||||||
|
|
||||||
|
/// With `lp` gone there is one print path and it carries **both**
|
||||||
|
/// dictionaries: write keys 3–5 of the resolver order into
|
||||||
|
/// `PMPrintSettings` so the PDE and the spool job see them
|
||||||
|
/// (docs/14 §7). Warn-only — a rejected key never aborts.
|
||||||
|
@discardableResult
|
||||||
|
func applyQuartzMode(to settings: PMPrintSettings) -> Int {
|
||||||
|
var applied = 0
|
||||||
|
let pairs: [(key: String, value: String, locked: Bool)] = [
|
||||||
|
(ColorMatchingAttempts.quartzModeKey,
|
||||||
|
ColorMatchingAttempts.quartzCustomMatching, true),
|
||||||
|
(ColorMatchingAttempts.quartzProfileKey, "", false),
|
||||||
|
(ColorMatchingAttempts.quartzLegacyModeKey,
|
||||||
|
ColorMatchingAttempts.quartzCustomMatching, false),
|
||||||
|
]
|
||||||
|
for pair in pairs {
|
||||||
|
let status = PMPrintSettingsSetValue(
|
||||||
|
settings, pair.key as CFString, pair.value as CFString,
|
||||||
|
pair.locked)
|
||||||
|
if status == 0 { applied += 1 }
|
||||||
|
}
|
||||||
|
if applied == 0 {
|
||||||
|
log("ColorSync: PMPrintSettingsSetValue rejected the "
|
||||||
|
+ "Quartz colour-matching keys")
|
||||||
|
}
|
||||||
|
return applied
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Layer ④ driver bypass
|
// MARK: - Layer ④ driver bypass
|
||||||
|
|
||||||
/// Pre-select the driver "no colour adjustment" option, unlocked —
|
/// Pre-select the driver "no colour adjustment" option, unlocked —
|
||||||
@@ -112,7 +141,11 @@ struct ColorSyncSuppressor {
|
|||||||
// MARK: - Layer ⑤ NSPrintInfo mirror
|
// MARK: - Layer ⑤ NSPrintInfo mirror
|
||||||
|
|
||||||
/// Mirror the applied keys into `printSettings` so the PDE pick
|
/// Mirror the applied keys into `printSettings` so the PDE pick
|
||||||
/// sees them.
|
/// sees them. Also populates the nested
|
||||||
|
/// `com.apple.print.printSettings` sub-dictionary of
|
||||||
|
/// `printInfo.dictionary()` with the AP_* **and** Quartz keys —
|
||||||
|
/// the single remaining path carries both vocabularies (#201 D2,
|
||||||
|
/// docs/14 §7).
|
||||||
func mirror(
|
func mirror(
|
||||||
into printInfo: NSPrintInfo,
|
into printInfo: NSPrintInfo,
|
||||||
driverBypass: (key: String, value: String)?
|
driverBypass: (key: String, value: String)?
|
||||||
@@ -121,9 +154,29 @@ struct ColorSyncSuppressor {
|
|||||||
for key in ColorMatchingAttempts.printSettingsKeys {
|
for key in ColorMatchingAttempts.printSettingsKeys {
|
||||||
settings[key as NSString] = ColorMatchingAttempts.applicationMatchingValue as NSString
|
settings[key as NSString] = ColorMatchingAttempts.applicationMatchingValue as NSString
|
||||||
}
|
}
|
||||||
|
settings[ColorMatchingAttempts.quartzModeKey as NSString] =
|
||||||
|
ColorMatchingAttempts.quartzCustomMatching as NSString
|
||||||
|
settings[ColorMatchingAttempts.quartzProfileKey as NSString] =
|
||||||
|
"" as NSString
|
||||||
|
settings[ColorMatchingAttempts.quartzLegacyModeKey as NSString] =
|
||||||
|
ColorMatchingAttempts.quartzCustomMatching as NSString
|
||||||
if let driverBypass {
|
if let driverBypass {
|
||||||
settings[driverBypass.key as NSString] = driverBypass.value as NSString
|
settings[driverBypass.key as NSString] = driverBypass.value as NSString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nested mirror — drivers that read the flattened dictionary.
|
||||||
|
let nestedKey = ColorMatchingAttempts.quartzNestedDictKey as NSString
|
||||||
|
let nested = (settings[nestedKey] as? NSMutableDictionary)
|
||||||
|
?? NSMutableDictionary()
|
||||||
|
for key in ColorMatchingAttempts.printSettingsKeys {
|
||||||
|
nested[key] = ColorMatchingAttempts.applicationMatchingValue
|
||||||
|
}
|
||||||
|
nested[ColorMatchingAttempts.quartzModeKey] =
|
||||||
|
ColorMatchingAttempts.quartzCustomMatching
|
||||||
|
nested[ColorMatchingAttempts.quartzProfileKey] = ""
|
||||||
|
nested[ColorMatchingAttempts.quartzLegacyModeKey] =
|
||||||
|
ColorMatchingAttempts.quartzCustomMatching
|
||||||
|
settings[nestedKey] = nested
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Layer ⑥ capture
|
// MARK: - Layer ⑥ capture
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import AppKit
|
||||||
|
import ApplicationServices
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// One page to draw — the TIFF plus its printtarg manifest size for
|
||||||
|
/// the drift check (#201 D9).
|
||||||
|
struct TargetPrintPage: Equatable {
|
||||||
|
let url: URL
|
||||||
|
let expectedWidthMm: Double?
|
||||||
|
let expectedHeightMm: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One spool job — a single-element `pages` in per-page mode, all
|
||||||
|
/// pages when `singleJobForAllPages` is on (#201 D5).
|
||||||
|
struct TargetPrintRequest {
|
||||||
|
let queue: String
|
||||||
|
let displayName: String?
|
||||||
|
let pages: [TargetPrintPage]
|
||||||
|
let jobTitle: String // "ICCery Target - <basename>"
|
||||||
|
/// nil → bind + PM defaults + suppression only (the ticket carries
|
||||||
|
/// the captured PDE state; without it the job is Stage 2 writes
|
||||||
|
/// over queue defaults).
|
||||||
|
let ticket: PrintTicket?
|
||||||
|
let overrides: TargetPrintOverrides
|
||||||
|
/// The queue's `lpoptions -l` key roster — feeds vendor media-key
|
||||||
|
/// and driver-bypass detection.
|
||||||
|
let optionKeys: Set<String>
|
||||||
|
/// Test-only: `.save` to this URL instead of `.spool` (#201 D8).
|
||||||
|
var savePDFTo: URL?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TargetSpoolError: LocalizedError, Equatable {
|
||||||
|
case noPages
|
||||||
|
case printerUnresolved(queue: String)
|
||||||
|
case operationFailed(queue: String, jobTitle: String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noPages:
|
||||||
|
return "Nothing to print."
|
||||||
|
case .printerUnresolved(let queue):
|
||||||
|
return "The print queue '\(queue)' could not be resolved."
|
||||||
|
case .operationFailed(_, let jobTitle):
|
||||||
|
return "The print operation for '\(jobTitle)' failed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol TargetSpooling {
|
||||||
|
/// Warn-only diagnostics (manifest drift, oversize page, rejected
|
||||||
|
/// key) are delivered here; the throw path is reserved for real
|
||||||
|
/// failures.
|
||||||
|
var diagnostics: ((Notice) -> Void)? { get set }
|
||||||
|
func spool(_ request: TargetPrintRequest) throws
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production spooler — rehydrates the captured ticket, applies the
|
||||||
|
/// resolved Stage 2 writes, and draws each TIFF 1:1 through a silent
|
||||||
|
/// `NSPrintOperation` (#201, the S1–S14 trace in the M12 megaplan).
|
||||||
|
@MainActor
|
||||||
|
final class NativeTargetSpooler: TargetSpooling {
|
||||||
|
var suppressor = ColorSyncSuppressor()
|
||||||
|
var diagnostics: ((Notice) -> Void)?
|
||||||
|
var log: (LogLevel, String) -> Void = { AppLogger.shared.log($0, $1) }
|
||||||
|
|
||||||
|
func spool(_ request: TargetPrintRequest) throws {
|
||||||
|
guard !request.pages.isEmpty else { throw TargetSpoolError.noPages }
|
||||||
|
|
||||||
|
// The PM printer handle is held for the whole operation and
|
||||||
|
// released on every path (S14).
|
||||||
|
let printer = PMTicketBridge.makePrinter(queue: request.queue)
|
||||||
|
defer { if let printer { PMTicketBridge.release(printer) } }
|
||||||
|
|
||||||
|
let (printInfo, _) = try makePrintInfo(for: request, boundTo: printer)
|
||||||
|
|
||||||
|
// S10 — decode every page 1:1. Warn-only findings ride the
|
||||||
|
// `diagnostics` seam, never the throw path (D9).
|
||||||
|
var rasters: [TargetPageRaster] = []
|
||||||
|
rasters.reserveCapacity(request.pages.count)
|
||||||
|
for page in request.pages {
|
||||||
|
let raster = try TargetRasterLoader.load(
|
||||||
|
tiff: page.url,
|
||||||
|
expectedWidthMm: page.expectedWidthMm,
|
||||||
|
expectedHeightMm: page.expectedHeightMm)
|
||||||
|
if let drift = raster.manifestDrift {
|
||||||
|
diagnostics?(Notice(kind: .warning, text: drift))
|
||||||
|
}
|
||||||
|
if raster.pointSize.width > printInfo.paperSize.width + 0.5
|
||||||
|
|| raster.pointSize.height
|
||||||
|
> printInfo.paperSize.height + 0.5 {
|
||||||
|
diagnostics?(Notice(
|
||||||
|
kind: .warning,
|
||||||
|
text: "\(page.url.lastPathComponent) exceeds the "
|
||||||
|
+ "paper size — it will be clipped, not scaled."))
|
||||||
|
}
|
||||||
|
rasters.append(raster)
|
||||||
|
}
|
||||||
|
|
||||||
|
// S11–S13 — headless canvas + silent operation (D7).
|
||||||
|
let canvas = TargetPageCanvasView(
|
||||||
|
pages: rasters, paperSize: printInfo.paperSize)
|
||||||
|
let operation = NSPrintOperation(view: canvas, printInfo: printInfo)
|
||||||
|
operation.showsPrintPanel = false
|
||||||
|
operation.showsProgressPanel = false
|
||||||
|
operation.canSpawnSeparateThread = false
|
||||||
|
operation.jobTitle = request.jobTitle
|
||||||
|
guard operation.run() else {
|
||||||
|
throw TargetSpoolError.operationFailed(
|
||||||
|
queue: request.queue, jobTitle: request.jobTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// S1–S9 — a fully configured `NSPrintInfo` plus the resolved
|
||||||
|
/// write list. Extracted for the PDF harness + unit tests;
|
||||||
|
/// `spool(_:)` is the production entry point (it keeps the PM
|
||||||
|
/// printer handle alive through `run()`).
|
||||||
|
func makePrintInfo(
|
||||||
|
for request: TargetPrintRequest
|
||||||
|
) throws -> (NSPrintInfo, ResolvedTicketWrites) {
|
||||||
|
let printer = PMTicketBridge.makePrinter(queue: request.queue)
|
||||||
|
defer { if let printer { PMTicketBridge.release(printer) } }
|
||||||
|
return try makePrintInfo(for: request, boundTo: printer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePrintInfo(
|
||||||
|
for request: TargetPrintRequest,
|
||||||
|
boundTo printer: PMPrinter?
|
||||||
|
) throws -> (NSPrintInfo, ResolvedTicketWrites) {
|
||||||
|
// S1
|
||||||
|
let printInfo = NSPrintInfo()
|
||||||
|
|
||||||
|
// S2 — Cocoa-level printer first: it runs before binding and
|
||||||
|
// ticket restore, so a printer-driven settings reset cannot
|
||||||
|
// clobber the ticket (R9). Best effort; PM binding is
|
||||||
|
// authoritative.
|
||||||
|
var resolvedNSPrinter = false
|
||||||
|
if let nsPrinter = NSPrinter(name: request.queue)
|
||||||
|
?? request.displayName.flatMap({ NSPrinter(name: $0) }) {
|
||||||
|
printInfo.printer = nsPrinter
|
||||||
|
resolvedNSPrinter = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3 — PM binding + session defaults. A failed bind degrades
|
||||||
|
// to the NSPrinter fallback; with neither, the job would land
|
||||||
|
// on the default queue — a wrong answer, so it throws.
|
||||||
|
var boundViaPM = false
|
||||||
|
if let printer {
|
||||||
|
do {
|
||||||
|
try PMTicketBridge.bind(printer: printer, to: printInfo)
|
||||||
|
boundViaPM = true
|
||||||
|
} catch {
|
||||||
|
log(.warn, "Spool: PM bind failed for "
|
||||||
|
+ "'\(request.queue)' — continuing on the "
|
||||||
|
+ "NSPrinter fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard boundViaPM || resolvedNSPrinter else {
|
||||||
|
throw TargetSpoolError.printerUnresolved(queue: request.queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// S4 — rehydrate the captured ticket. `restore` itself refuses
|
||||||
|
// a cross-queue replay (R3).
|
||||||
|
if let ticket = request.ticket {
|
||||||
|
try PMTicketBridge.restore(ticket, into: printInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// S5 — Stage 2 writes (AP_* + Quartz vocabularies + media /
|
||||||
|
// quality / bypass, D2/D6) onto the live PMPrintSettings.
|
||||||
|
let resolved = TicketWriteResolver.resolve(
|
||||||
|
overrides: request.overrides, optionKeys: request.optionKeys)
|
||||||
|
let settings = PMTicketBridge.settings(printInfo)
|
||||||
|
for write in resolved.writes {
|
||||||
|
// R4 — write only when the value differs, and log old→new
|
||||||
|
// so a vendor companion-key desync is diagnosable.
|
||||||
|
let current = PMTicketBridge.stringValue(
|
||||||
|
forKey: write.key, in: settings)
|
||||||
|
guard current != write.value else { continue }
|
||||||
|
if let current {
|
||||||
|
log(.info, "Spool: \(write.key): '\(current)' "
|
||||||
|
+ "→ '\(write.value)'")
|
||||||
|
}
|
||||||
|
PMTicketBridge.setValue(
|
||||||
|
write.value, forKey: write.key, locked: write.locked,
|
||||||
|
in: settings, context: "Spool")
|
||||||
|
}
|
||||||
|
printInfo.updateFromPMPrintSettings()
|
||||||
|
mirror(resolved.writes, into: printInfo)
|
||||||
|
|
||||||
|
// S6 — paper override: `PMPaper` match on the bound printer;
|
||||||
|
// `Custom.<w>x<h>` tokens (already points) set the Cocoa paper
|
||||||
|
// size directly.
|
||||||
|
if let token = resolved.paperToken {
|
||||||
|
if let printer, boundViaPM {
|
||||||
|
PMTicketBridge.applyPaper(
|
||||||
|
token: token, printer: printer,
|
||||||
|
session: PMTicketBridge.session(printInfo),
|
||||||
|
printInfo: printInfo)
|
||||||
|
} else if let custom = PMTicketBridge
|
||||||
|
.customPaperDimensions(from: token) {
|
||||||
|
printInfo.paperSize = NSSize(
|
||||||
|
width: custom.width, height: custom.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// S7 — orientation on the Cocoa page format; the
|
||||||
|
// `orientation-requested` write above is the driver half.
|
||||||
|
if let orientation = resolved.orientation {
|
||||||
|
printInfo.orientation = orientation == "landscape"
|
||||||
|
? .landscape : .portrait
|
||||||
|
}
|
||||||
|
|
||||||
|
// S8 — private SPI on the live session; only meaningful with
|
||||||
|
// a bound printer (same constraint as the panel path).
|
||||||
|
if boundViaPM {
|
||||||
|
suppressor.applySPIMode(to: PMTicketBridge.session(printInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
// S9 — Cocoa geometry: margins 0, `.clip` pagination, 1:1
|
||||||
|
// scale, no centring; `.spool`, or `.save` + jobSavingURL
|
||||||
|
// under the PDF harness (D8/D9).
|
||||||
|
printInfo.topMargin = 0
|
||||||
|
printInfo.bottomMargin = 0
|
||||||
|
printInfo.leftMargin = 0
|
||||||
|
printInfo.rightMargin = 0
|
||||||
|
printInfo.horizontalPagination = .clip
|
||||||
|
printInfo.verticalPagination = .clip
|
||||||
|
printInfo.scalingFactor = 1.0
|
||||||
|
printInfo.isHorizontallyCentered = false
|
||||||
|
printInfo.isVerticallyCentered = false
|
||||||
|
if let saveURL = request.savePDFTo {
|
||||||
|
printInfo.jobDisposition = .save
|
||||||
|
printInfo.dictionary()[
|
||||||
|
NSPrintInfo.AttributeKey.jobSavingURL] = saveURL
|
||||||
|
} else {
|
||||||
|
printInfo.jobDisposition = .spool
|
||||||
|
}
|
||||||
|
|
||||||
|
return (printInfo, resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resolved writes also land in `NSPrintInfo.printSettings` so
|
||||||
|
/// the driver sees them through Cocoa — the
|
||||||
|
/// `com.apple.print.printSettings` sub-dictionary carries the
|
||||||
|
/// colour keys (D2 / docs/14 §7).
|
||||||
|
private func mirror(
|
||||||
|
_ writes: [TicketWrite], into printInfo: NSPrintInfo
|
||||||
|
) {
|
||||||
|
let settings = printInfo.printSettings
|
||||||
|
for write in writes {
|
||||||
|
settings[write.key as NSString] = write.value as NSString
|
||||||
|
}
|
||||||
|
let nestedKey = ColorMatchingAttempts.quartzNestedDictKey as NSString
|
||||||
|
let nested = (settings[nestedKey] as? NSMutableDictionary)
|
||||||
|
?? NSMutableDictionary()
|
||||||
|
for write in writes where Self.isColourKey(write.key) {
|
||||||
|
nested[write.key] = write.value
|
||||||
|
}
|
||||||
|
settings[nestedKey] = nested
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isColourKey(_ key: String) -> Bool {
|
||||||
|
ColorMatchingAttempts.printSettingsKeys.contains(key)
|
||||||
|
|| key == ColorMatchingAttempts.quartzModeKey
|
||||||
|
|| key == ColorMatchingAttempts.quartzProfileKey
|
||||||
|
|| key == ColorMatchingAttempts.quartzLegacyModeKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// UI-test seam (#201 D8). Runs the same `TicketWriteResolver` the
|
||||||
|
/// native spooler runs, appends one deterministic line per request to
|
||||||
|
/// `ICCERY_TEST_SPOOL_LOG`, and never touches the print system — so
|
||||||
|
/// UI tests need no queue, no driver and no PM binding.
|
||||||
|
///
|
||||||
|
/// `ICCERY_TEST_SPOOL_FAIL=1` injects an `operationFailed` error for
|
||||||
|
/// the failure-notice tests.
|
||||||
|
@MainActor
|
||||||
|
final class RecordingTargetSpooler: TargetSpooling {
|
||||||
|
let logURL: URL
|
||||||
|
var diagnostics: ((Notice) -> Void)?
|
||||||
|
|
||||||
|
init(logURL: URL) {
|
||||||
|
self.logURL = logURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `queue=<q> title=<t> page=<basename> pages=<n> paper=<token|->
|
||||||
|
/// orientation=<o> keys=<k=v k=v …>` — keys sorted alphabetically
|
||||||
|
/// so assertions are order-free.
|
||||||
|
func spool(_ request: TargetPrintRequest) throws {
|
||||||
|
guard !request.pages.isEmpty else { throw TargetSpoolError.noPages }
|
||||||
|
if ProcessInfo.processInfo.environment["ICCERY_TEST_SPOOL_FAIL"]
|
||||||
|
== "1" {
|
||||||
|
throw TargetSpoolError.operationFailed(
|
||||||
|
queue: request.queue, jobTitle: request.jobTitle)
|
||||||
|
}
|
||||||
|
let resolved = TicketWriteResolver.resolve(
|
||||||
|
overrides: request.overrides, optionKeys: request.optionKeys)
|
||||||
|
let keys = resolved.writes
|
||||||
|
.sorted { $0.key < $1.key }
|
||||||
|
.map { "\($0.key)=\($0.value)" }
|
||||||
|
.joined(separator: " ")
|
||||||
|
let line = "queue=\(request.queue)"
|
||||||
|
+ " title=\(request.jobTitle)"
|
||||||
|
+ " page=\(request.pages[0].url.lastPathComponent)"
|
||||||
|
+ " pages=\(request.pages.count)"
|
||||||
|
+ " paper=\(resolved.paperToken ?? "-")"
|
||||||
|
+ " orientation=\(resolved.orientation ?? "-")"
|
||||||
|
+ " keys=\(keys)\n"
|
||||||
|
// Atomic append — existing content + one line via the
|
||||||
|
// `.tmp`-then-rename convention (#213).
|
||||||
|
let existing = (try? String(contentsOf: logURL, encoding: .utf8))
|
||||||
|
?? ""
|
||||||
|
try AtomicFileWriter.write(existing + line, to: logURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import AppKit
|
||||||
|
import ApplicationServices
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Errors raised by Core Printing bridge operations.
|
||||||
|
enum PMTicketError: LocalizedError, Equatable {
|
||||||
|
case printerUnknown(queue: String)
|
||||||
|
case sessionBindingFailed(OSStatus)
|
||||||
|
case serialiseFailed(stage: String, status: OSStatus)
|
||||||
|
case restoreFailed(stage: String, status: OSStatus)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .printerUnknown(let queue):
|
||||||
|
return "No printer found for queue '\(queue)'."
|
||||||
|
case .sessionBindingFailed(let status):
|
||||||
|
return "Could not bind the print session to the queue (OSStatus \(status))."
|
||||||
|
case .serialiseFailed(let stage, let status):
|
||||||
|
return "Print ticket capture failed at \(stage) (OSStatus \(status))."
|
||||||
|
case .restoreFailed(let stage, let status):
|
||||||
|
return "Print ticket restore failed at \(stage) (OSStatus \(status))."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every Core Printing (`PM*`) call in ICCery lives here (#201 D12).
|
||||||
|
///
|
||||||
|
/// Ownership — the ONLY rules, do not improvise:
|
||||||
|
/// * `PMPrinterCreateFromPrinterID`, `PMCreatePageFormatWithPMPaper`,
|
||||||
|
/// `PMPrintSettingsCreateWithDataRepresentation`,
|
||||||
|
/// `PMPageFormatCreateWithDataRepresentation` return **+1** →
|
||||||
|
/// `PMRelease(object(_:))` on every path (`defer`).
|
||||||
|
/// * `PM*CreateDataRepresentation` writes a **+1 CFData** →
|
||||||
|
/// `takeRetainedValue()` (the headers say "the caller is responsible
|
||||||
|
/// for releasing"; the out-param is not `CF_RETURNS_RETAINED`-annotated,
|
||||||
|
/// so Swift hands back an unbalanced `Unmanaged`).
|
||||||
|
/// * `PMPrinterGetPaperList`, `PMPaperGetID`, `PMPrinterGetID`,
|
||||||
|
/// `PMPrintSettingsGetValue` are **borrowed** → `takeUnretainedValue()`,
|
||||||
|
/// never released. `PMSessionGetCurrentPrinter` hands back the
|
||||||
|
/// session's own printer — also borrowed, never `PMRelease`d (an
|
||||||
|
/// over-release here dangles the session and crashes AppKit's
|
||||||
|
/// `_printerInPrintSession` / session teardown).
|
||||||
|
/// * `printInfo.pmPrintSession()/pmPrintSettings()/pmPageFormat()` are
|
||||||
|
/// borrowed from the `NSPrintInfo` → never released.
|
||||||
|
@MainActor
|
||||||
|
enum PMTicketBridge {
|
||||||
|
|
||||||
|
// MARK: Handles
|
||||||
|
|
||||||
|
/// Borrowed `PMPrintSession` from the `NSPrintInfo` — never released.
|
||||||
|
static func session(_ printInfo: NSPrintInfo) -> PMPrintSession {
|
||||||
|
unsafeBitCast(printInfo.pmPrintSession(), to: PMPrintSession.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrowed `PMPrintSettings` from the `NSPrintInfo` — never released.
|
||||||
|
static func settings(_ printInfo: NSPrintInfo) -> PMPrintSettings {
|
||||||
|
unsafeBitCast(printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrowed `PMPageFormat` from the `NSPrintInfo` — never released.
|
||||||
|
static func pageFormat(_ printInfo: NSPrintInfo) -> PMPageFormat {
|
||||||
|
unsafeBitCast(printInfo.pmPageFormat(), to: PMPageFormat.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Any `PM*` handle → `PMObject` for `PMRelease` — the Carbon API
|
||||||
|
/// wants `UnsafeRawPointer`, Swift imports handles as `OpaquePointer`.
|
||||||
|
static func object<Handle>(_ handle: Handle) -> PMObject {
|
||||||
|
unsafeBitCast(handle, to: PMObject.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Binding (layer ①)
|
||||||
|
|
||||||
|
/// `PMPrinterCreateFromPrinterID` — **+1**, caller must
|
||||||
|
/// `PMTicketBridge.release` on every path.
|
||||||
|
static func makePrinter(queue: String) -> PMPrinter? {
|
||||||
|
PMPrinterCreateFromPrinterID(queue as CFString)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Balances `makePrinter(queue:)` / any +1 PM handle.
|
||||||
|
static func release(_ printer: PMPrinter) {
|
||||||
|
PMRelease(object(printer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PMSessionSetCurrentPMPrinter` + session defaults (warn-only).
|
||||||
|
/// Throws `PMTicketError.sessionBindingFailed` when the bind itself
|
||||||
|
/// fails — the caller still owns `printer` on every path.
|
||||||
|
static func bind(printer: PMPrinter, to printInfo: NSPrintInfo) throws {
|
||||||
|
let session = session(printInfo)
|
||||||
|
let status = PMSessionSetCurrentPMPrinter(session, printer)
|
||||||
|
guard status == 0 else {
|
||||||
|
throw PMTicketError.sessionBindingFailed(status)
|
||||||
|
}
|
||||||
|
// Warn-only: defaults keep the panel consistent with the
|
||||||
|
// queue but are not fatal when they fail.
|
||||||
|
_ = PMSessionDefaultPrintSettings(session, settings(printInfo))
|
||||||
|
_ = PMSessionDefaultPageFormat(session, pageFormat(printInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String.
|
||||||
|
static func currentPrinterID(
|
||||||
|
session: PMPrintSession,
|
||||||
|
fallback: String
|
||||||
|
) -> String {
|
||||||
|
var current: PMPrinter?
|
||||||
|
guard PMSessionGetCurrentPrinter(session, ¤t) == 0,
|
||||||
|
let printer = current
|
||||||
|
else { return fallback }
|
||||||
|
// Borrowed from the session — never released (see header doc).
|
||||||
|
guard let id = PMPrinterGetID(printer)
|
||||||
|
else { return fallback }
|
||||||
|
return id.takeUnretainedValue() as String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Ticket (#201 D3)
|
||||||
|
|
||||||
|
/// Capture the live `PMPrintSettings` + `PMPageFormat` as XML
|
||||||
|
/// `Data` plus a plist-safe `NSPrintInfo.dictionary()` fallback.
|
||||||
|
/// The CFData out-params are **+1** by header contract
|
||||||
|
/// ("the caller is responsible for releasing") →
|
||||||
|
/// `takeRetainedValue()`.
|
||||||
|
static func serialise(
|
||||||
|
_ printInfo: NSPrintInfo,
|
||||||
|
queue: String
|
||||||
|
) throws -> PrintTicket {
|
||||||
|
var settingsRef: Unmanaged<CFData>?
|
||||||
|
let settingsStatus = PMPrintSettingsCreateDataRepresentation(
|
||||||
|
settings(printInfo), &settingsRef, kPMDataFormatXMLDefault)
|
||||||
|
guard settingsStatus == noErr, let settingsRef else {
|
||||||
|
throw PMTicketError.serialiseFailed(
|
||||||
|
stage: "printSettings", status: settingsStatus)
|
||||||
|
}
|
||||||
|
let settingsData = settingsRef.takeRetainedValue() as Data
|
||||||
|
|
||||||
|
var pageFormatRef: Unmanaged<CFData>?
|
||||||
|
let pageFormatStatus = PMPageFormatCreateDataRepresentation(
|
||||||
|
pageFormat(printInfo), &pageFormatRef, kPMDataFormatXMLDefault)
|
||||||
|
guard pageFormatStatus == noErr, let pageFormatRef else {
|
||||||
|
throw PMTicketError.serialiseFailed(
|
||||||
|
stage: "pageFormat", status: pageFormatStatus)
|
||||||
|
}
|
||||||
|
let pageFormatData = pageFormatRef.takeRetainedValue() as Data
|
||||||
|
|
||||||
|
// Cocoa-level fallback — warn-only, never fatal: filter the
|
||||||
|
// dictionary to plist-safe values so one exotic attribute
|
||||||
|
// cannot fail the whole snapshot.
|
||||||
|
let plist = try? PropertyListSerialization.data(
|
||||||
|
fromPropertyList: plistSafe(printInfo.dictionary()) ?? [:],
|
||||||
|
format: .binary, options: 0)
|
||||||
|
|
||||||
|
return PrintTicket(
|
||||||
|
queue: queue,
|
||||||
|
printSettings: settingsData,
|
||||||
|
pageFormat: pageFormatData,
|
||||||
|
printInfoPlist: plist,
|
||||||
|
capturedAt: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rehydrate a ticket into `printInfo`'s live PM objects. Order is
|
||||||
|
/// load-bearing: create → copy → session-validate → Cocoa update.
|
||||||
|
/// Cross-queue replay is refused (R3) — the destination's bound
|
||||||
|
/// queue is the session's current printer; a destination with no
|
||||||
|
/// bound printer accepts the ticket (the spooler always binds
|
||||||
|
/// first, so production replay is always guarded).
|
||||||
|
/// A page-format failure is warn-only — paper is re-derived
|
||||||
|
/// upstream by the spooler's S6/S7.
|
||||||
|
static func restore(
|
||||||
|
_ ticket: PrintTicket,
|
||||||
|
into printInfo: NSPrintInfo
|
||||||
|
) throws {
|
||||||
|
let bound = currentPrinterID(
|
||||||
|
session: session(printInfo),
|
||||||
|
fallback: ticket.queue)
|
||||||
|
guard ticket.queue == bound else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"PrintTicket: refusing to replay a ticket captured "
|
||||||
|
+ "for '\(ticket.queue)' onto '\(bound)'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var srcSettings: PMPrintSettings?
|
||||||
|
let createStatus = PMPrintSettingsCreateWithDataRepresentation(
|
||||||
|
ticket.printSettings as CFData, &srcSettings)
|
||||||
|
defer {
|
||||||
|
if let srcSettings { PMRelease(object(srcSettings)) }
|
||||||
|
}
|
||||||
|
guard createStatus == noErr, let srcSettings else {
|
||||||
|
throw PMTicketError.restoreFailed(
|
||||||
|
stage: "printSettings", status: createStatus)
|
||||||
|
}
|
||||||
|
let copyStatus = PMCopyPrintSettings(
|
||||||
|
srcSettings, settings(printInfo))
|
||||||
|
guard copyStatus == noErr else {
|
||||||
|
throw PMTicketError.restoreFailed(
|
||||||
|
stage: "printSettings", status: copyStatus)
|
||||||
|
}
|
||||||
|
var changed = DarwinBoolean(false)
|
||||||
|
_ = PMSessionValidatePrintSettings(
|
||||||
|
session(printInfo), settings(printInfo), &changed)
|
||||||
|
if changed.boolValue {
|
||||||
|
AppLogger.shared.info(
|
||||||
|
"PrintTicket: driver adjusted the restored ticket")
|
||||||
|
}
|
||||||
|
printInfo.updateFromPMPrintSettings()
|
||||||
|
|
||||||
|
// Page format — warn-only.
|
||||||
|
var srcFormat: PMPageFormat?
|
||||||
|
let formatStatus = PMPageFormatCreateWithDataRepresentation(
|
||||||
|
ticket.pageFormat as CFData, &srcFormat)
|
||||||
|
defer {
|
||||||
|
if let srcFormat { PMRelease(object(srcFormat)) }
|
||||||
|
}
|
||||||
|
guard formatStatus == noErr, let srcFormat else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"PrintTicket: page format restore failed "
|
||||||
|
+ "(\(formatStatus)) — paper re-derived upstream")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard PMCopyPageFormat(srcFormat, pageFormat(printInfo)) == noErr
|
||||||
|
else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"PrintTicket: page format copy failed — "
|
||||||
|
+ "paper re-derived upstream")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var formatChanged = DarwinBoolean(false)
|
||||||
|
_ = PMSessionValidatePageFormat(
|
||||||
|
session(printInfo), pageFormat(printInfo), &formatChanged)
|
||||||
|
printInfo.updateFromPMPageFormat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursive plist-safety filter for `NSPrintInfo.dictionary()`:
|
||||||
|
/// keeps String / NSNumber / Bool / Date / Data / URL (→
|
||||||
|
/// absoluteString) / Array / Dictionary, drops everything else, so
|
||||||
|
/// one non-plist attribute cannot fail the whole snapshot.
|
||||||
|
private static func plistSafe(_ value: Any) -> Any? {
|
||||||
|
switch value {
|
||||||
|
case let string as String:
|
||||||
|
return string
|
||||||
|
case let number as NSNumber:
|
||||||
|
return number
|
||||||
|
case let date as Date:
|
||||||
|
return date
|
||||||
|
case let data as Data:
|
||||||
|
return data
|
||||||
|
case let url as URL:
|
||||||
|
return url.absoluteString
|
||||||
|
case let array as [Any]:
|
||||||
|
return array.compactMap(plistSafe)
|
||||||
|
case let dictionary as [String: Any]:
|
||||||
|
var safe: [String: Any] = [:]
|
||||||
|
for (key, element) in dictionary {
|
||||||
|
if let filtered = plistSafe(element) {
|
||||||
|
safe[key] = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return safe
|
||||||
|
case let dictionary as [NSPrintInfo.AttributeKey: Any]:
|
||||||
|
var safe: [String: Any] = [:]
|
||||||
|
for (key, element) in dictionary {
|
||||||
|
if let filtered = plistSafe(element) {
|
||||||
|
safe[key.rawValue] = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return safe
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Values
|
||||||
|
|
||||||
|
/// Warn-only `PMPrintSettingsSetValue`: a driver that rejects a key
|
||||||
|
/// must not abort the caller's flow. Returns `true` on success.
|
||||||
|
@discardableResult
|
||||||
|
static func setValue(
|
||||||
|
_ value: String,
|
||||||
|
forKey key: String,
|
||||||
|
locked: Bool,
|
||||||
|
in settings: PMPrintSettings,
|
||||||
|
context: String
|
||||||
|
) -> Bool {
|
||||||
|
let status = PMPrintSettingsSetValue(
|
||||||
|
settings, key as CFString, value as CFString, locked)
|
||||||
|
if status != 0 {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"\(context): PMPrintSettingsSetValue(\(key)) "
|
||||||
|
+ "rejected (\(status))")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PMPrintSettingsGetValue` — borrowed value, never released.
|
||||||
|
/// `nil` for absent keys, non-string values, or lookup errors.
|
||||||
|
static func stringValue(
|
||||||
|
forKey key: String,
|
||||||
|
in settings: PMPrintSettings
|
||||||
|
) -> String? {
|
||||||
|
var value: Unmanaged<CFTypeRef>?
|
||||||
|
guard PMPrintSettingsGetValue(
|
||||||
|
settings, key as CFString, &value) == 0,
|
||||||
|
let ref = value?.takeUnretainedValue()
|
||||||
|
else { return nil }
|
||||||
|
return ref as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Paper
|
||||||
|
|
||||||
|
/// The `PMPageFormat` half of paper preselect (#186 E1): the
|
||||||
|
/// panel's paper dropdown reflects the page format's `PMPaper`,
|
||||||
|
/// not `PMPrintSettings`. Match the Stage 2 `PageSize` token to a
|
||||||
|
/// paper from `PMPrinterGetPaperList`, rebuild the page format
|
||||||
|
/// around it, and copy it into the printInfo's format (TN2248:
|
||||||
|
/// `PMCreatePageFormatWithPMPaper` → `PMSessionValidatePageFormat`
|
||||||
|
/// → `PMCopyPageFormat` → `updateFromPMPageFormat`).
|
||||||
|
/// `Custom.<w>x<h>` tokens (already points) have no `PMPaper` —
|
||||||
|
/// set the Cocoa `paperSize` directly. Warn-only throughout: a
|
||||||
|
/// missed match must not keep the panel from opening.
|
||||||
|
static func applyPaper(
|
||||||
|
token: String,
|
||||||
|
printer: PMPrinter,
|
||||||
|
session: PMPrintSession,
|
||||||
|
printInfo: NSPrintInfo
|
||||||
|
) {
|
||||||
|
if let custom = customPaperDimensions(from: token) {
|
||||||
|
printInfo.paperSize = NSSize(
|
||||||
|
width: custom.width, height: custom.height)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var paperList: Unmanaged<CFArray>?
|
||||||
|
guard PMPrinterGetPaperList(printer, &paperList) == 0,
|
||||||
|
let papers = paperList?.takeUnretainedValue()
|
||||||
|
else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: PMPrinterGetPaperList failed — "
|
||||||
|
+ "paper preselect skipped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The list (and its elements) is owned by the printer —
|
||||||
|
// borrowed, never released.
|
||||||
|
var match: PMPaper?
|
||||||
|
for index in 0..<CFArrayGetCount(papers) {
|
||||||
|
let paper = unsafeBitCast(
|
||||||
|
CFArrayGetValueAtIndex(papers, index), to: PMPaper.self)
|
||||||
|
var idRef: Unmanaged<CFString>?
|
||||||
|
guard PMPaperGetID(paper, &idRef) == 0,
|
||||||
|
let paperID = idRef?.takeUnretainedValue() as String?
|
||||||
|
else { continue }
|
||||||
|
if paperID == token {
|
||||||
|
match = paper
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let paper = match else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: no PMPaper id matches '\(token)'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var created: PMPageFormat?
|
||||||
|
guard PMCreatePageFormatWithPMPaper(&created, paper) == 0,
|
||||||
|
let newFormat = created
|
||||||
|
else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: PMCreatePageFormatWithPMPaper failed "
|
||||||
|
+ "for '\(token)'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer { PMRelease(object(newFormat)) }
|
||||||
|
_ = PMSessionValidatePageFormat(session, newFormat, nil)
|
||||||
|
_ = PMCopyPageFormat(newFormat, pageFormat(printInfo))
|
||||||
|
printInfo.updateFromPMPageFormat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Custom.<w>x<h>` → dimensions in points (the token builder
|
||||||
|
/// emits integer points, mm × 72/25.4). `nil` for non-custom or
|
||||||
|
/// malformed tokens — a malformed `Custom.*` then misses the
|
||||||
|
/// `PMPaper` match and logs instead of guessing a size.
|
||||||
|
static func customPaperDimensions(
|
||||||
|
from token: String
|
||||||
|
) -> (width: Double, height: Double)? {
|
||||||
|
guard token.hasPrefix("Custom.") else { return nil }
|
||||||
|
let dims = token.dropFirst("Custom.".count).split(separator: "x")
|
||||||
|
guard dims.count == 2,
|
||||||
|
let width = Double(dims[0]), let height = Double(dims[1]),
|
||||||
|
width > 0, height > 0
|
||||||
|
else { return nil }
|
||||||
|
return (width, height)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,10 @@ import ICCeryCore
|
|||||||
|
|
||||||
/// Errors raised while preparing the bound print panel.
|
/// Errors raised while preparing the bound print panel.
|
||||||
enum PrintPanelError: LocalizedError {
|
enum PrintPanelError: LocalizedError {
|
||||||
case sessionBindingFailed(OSStatus)
|
|
||||||
case noPrinterFound(String)
|
case noPrinterFound(String)
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .sessionBindingFailed(let status):
|
|
||||||
return "Could not bind the print session to the queue (OSStatus \(status))."
|
|
||||||
case .noPrinterFound(let name):
|
case .noPrinterFound(let name):
|
||||||
return "No printer found for '\(name)'."
|
return "No printer found for '\(name)'."
|
||||||
}
|
}
|
||||||
@@ -62,10 +59,12 @@ struct PrintPanelService {
|
|||||||
cupsService: CupsService,
|
cupsService: CupsService,
|
||||||
initialSelections: PrintPanelInitialSelections =
|
initialSelections: PrintPanelInitialSelections =
|
||||||
PrintPanelInitialSelections()
|
PrintPanelInitialSelections()
|
||||||
) async throws -> PrintPropertiesResult? {
|
) async throws -> PanelCaptureResult? {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if UITestHooks.printPanelStubbed {
|
if UITestHooks.printPanelStubbed {
|
||||||
return UITestHooks.printPanelResult(forQueue: queue)
|
return UITestHooks.printPanelResult(forQueue: queue).map {
|
||||||
|
PanelCaptureResult(properties: $0, ticket: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
// `??` rhs is a non-async @autoclosure — fetch first.
|
// `??` rhs is a non-async @autoclosure — fetch first.
|
||||||
@@ -87,30 +86,23 @@ struct PrintPanelService {
|
|||||||
displayName: String?,
|
displayName: String?,
|
||||||
optionKeys: Set<String>,
|
optionKeys: Set<String>,
|
||||||
initialSelections: PrintPanelInitialSelections
|
initialSelections: PrintPanelInitialSelections
|
||||||
) throws -> PrintPropertiesResult? {
|
) throws -> PanelCaptureResult? {
|
||||||
let printInfo = NSPrintInfo()
|
let printInfo = NSPrintInfo()
|
||||||
var pmPrinter: PMPrinter?
|
var pmPrinter: PMPrinter?
|
||||||
var boundViaPM = false
|
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 = PMPrinterCreateFromPrinterID(queue as CFString) {
|
if let printer = PMTicketBridge.makePrinter(queue: queue) {
|
||||||
pmPrinter = printer
|
pmPrinter = printer
|
||||||
let session = unsafeBitCast(
|
let session = PMTicketBridge.session(printInfo)
|
||||||
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
let settings = PMTicketBridge.settings(printInfo)
|
||||||
let settings = unsafeBitCast(
|
|
||||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
|
||||||
let pageFormat = unsafeBitCast(
|
|
||||||
printInfo.pmPageFormat(), to: PMPageFormat.self)
|
|
||||||
|
|
||||||
let status = PMSessionSetCurrentPMPrinter(session, printer)
|
do {
|
||||||
if status != 0 {
|
try PMTicketBridge.bind(printer: printer, to: printInfo)
|
||||||
PMRelease(Self.pmObject(printer))
|
} catch {
|
||||||
throw PrintPanelError.sessionBindingFailed(status)
|
PMTicketBridge.release(printer)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
// Warn-only: defaults keep the panel consistent with the
|
|
||||||
// queue but are not fatal when they fail.
|
|
||||||
_ = PMSessionDefaultPrintSettings(session, settings)
|
|
||||||
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
|
||||||
// Initial selections — after `PMSessionDefault*`, before
|
// Initial selections — after `PMSessionDefault*`, before
|
||||||
// ColorSync suppression ②–⑤ (locked write order,
|
// ColorSync suppression ②–⑤ (locked write order,
|
||||||
// #183/#186). Paper is TWO writes (E1): the `PageSize`
|
// #183/#186). Paper is TWO writes (E1): the `PageSize`
|
||||||
@@ -119,9 +111,9 @@ struct PrintPanelService {
|
|||||||
applyInitialSelections(
|
applyInitialSelections(
|
||||||
initialSelections, to: settings, optionKeys: optionKeys)
|
initialSelections, to: settings, optionKeys: optionKeys)
|
||||||
if let paperToken = initialSelections.paperSize {
|
if let paperToken = initialSelections.paperSize {
|
||||||
applyPaperPageFormat(
|
PMTicketBridge.applyPaper(
|
||||||
paperToken, printer: printer, session: session,
|
token: paperToken, printer: printer,
|
||||||
printInfo: printInfo)
|
session: session, printInfo: printInfo)
|
||||||
}
|
}
|
||||||
boundViaPM = true
|
boundViaPM = true
|
||||||
} else {
|
} else {
|
||||||
@@ -142,21 +134,20 @@ struct PrintPanelService {
|
|||||||
}
|
}
|
||||||
defer {
|
defer {
|
||||||
if let printer = pmPrinter {
|
if let printer = pmPrinter {
|
||||||
PMRelease(Self.pmObject(printer))
|
PMTicketBridge.release(printer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ②–⑤ ColourSync suppression — only on the PM path: the SPI
|
// ②–⑤ ColourSync suppression — only on the PM path: the SPI
|
||||||
// and PMPrintSettingsSetValue need a session with a current
|
// and PMPrintSettingsSetValue need a session with a current
|
||||||
// printer to attach to.
|
// printer to attach to.
|
||||||
var settings = unsafeBitCast(
|
var settings = PMTicketBridge.settings(printInfo)
|
||||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
|
||||||
var driverBypass: (key: String, value: String)?
|
var driverBypass: (key: String, value: String)?
|
||||||
if boundViaPM {
|
if boundViaPM {
|
||||||
let session = unsafeBitCast(
|
let session = PMTicketBridge.session(printInfo)
|
||||||
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
|
||||||
suppressor.applySPIMode(to: session) // ②
|
suppressor.applySPIMode(to: session) // ②
|
||||||
suppressor.applyLockedKeys(to: settings) // ③
|
suppressor.applyLockedKeys(to: settings) // ③
|
||||||
|
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
|
||||||
driverBypass = suppressor.applyDriverBypass( // ④
|
driverBypass = suppressor.applyDriverBypass( // ④
|
||||||
to: settings, optionKeys: optionKeys)
|
to: settings, optionKeys: optionKeys)
|
||||||
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
|
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
|
||||||
@@ -183,30 +174,37 @@ struct PrintPanelService {
|
|||||||
var cupsOptions: String?
|
var cupsOptions: String?
|
||||||
var mediaType: String?
|
var mediaType: String?
|
||||||
if boundViaPM {
|
if boundViaPM {
|
||||||
settings = unsafeBitCast(
|
settings = PMTicketBridge.settings(printInfo)
|
||||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
|
||||||
let captured = suppressor.captureOptions(from: settings)
|
let captured = suppressor.captureOptions(from: settings)
|
||||||
cupsOptions = captured.cupsOptions
|
cupsOptions = captured.cupsOptions
|
||||||
mediaType = captured.mediaType
|
mediaType = captured.mediaType
|
||||||
}
|
}
|
||||||
let capturedOptions = cupsOptions ?? ""
|
let capturedOptions = cupsOptions ?? ""
|
||||||
return PrintPropertiesResult(
|
let resolvedQueue = boundViaPM
|
||||||
selectedPrinter: boundViaPM
|
? PMTicketBridge.currentPrinterID(
|
||||||
? Self.currentPrinterID(
|
session: PMTicketBridge.session(printInfo),
|
||||||
session: unsafeBitCast(
|
fallback: queue)
|
||||||
printInfo.pmPrintSession(), to: PMPrintSession.self),
|
: queue
|
||||||
fallback: queue)
|
// ⑦ Serialise the native ticket — the payload `lp -o` could
|
||||||
: nil,
|
// never carry (#201). Warn-only via `try?`: a serialise
|
||||||
options: PrintOptions(
|
// failure must not lose the Stage 2 mirror above.
|
||||||
orientation: CupsParsers.extractOrientation(
|
let ticket = try? PMTicketBridge.serialise(
|
||||||
fromOptionsString: capturedOptions),
|
printInfo, queue: resolvedQueue)
|
||||||
paperSize: CupsParsers.extractOption(
|
return PanelCaptureResult(
|
||||||
named: "PageSize", fromOptionsString: capturedOptions),
|
properties: PrintPropertiesResult(
|
||||||
mediaType: mediaType,
|
selectedPrinter: boundViaPM ? resolvedQueue : nil,
|
||||||
quality: CupsParsers.extractQuality(
|
options: PrintOptions(
|
||||||
fromOptionsString: capturedOptions),
|
orientation: CupsParsers.extractOrientation(
|
||||||
ppdUncorrectedPassthrough: true,
|
fromOptionsString: capturedOptions),
|
||||||
cupsOptions: cupsOptions))
|
paperSize: CupsParsers.extractOption(
|
||||||
|
named: "PageSize",
|
||||||
|
fromOptionsString: capturedOptions),
|
||||||
|
mediaType: mediaType,
|
||||||
|
quality: CupsParsers.extractQuality(
|
||||||
|
fromOptionsString: capturedOptions),
|
||||||
|
ppdUncorrectedPassthrough: true,
|
||||||
|
cupsOptions: cupsOptions)),
|
||||||
|
ticket: ticket)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initial-selection `PMPrintSettings` writes — paper, quality,
|
/// Initial-selection `PMPrintSettings` writes — paper, quality,
|
||||||
@@ -219,143 +217,30 @@ struct PrintPanelService {
|
|||||||
optionKeys: Set<String>
|
optionKeys: Set<String>
|
||||||
) {
|
) {
|
||||||
if let paperSize = selections.paperSize {
|
if let paperSize = selections.paperSize {
|
||||||
warnOnFailure(PMPrintSettingsSetValue(
|
PMTicketBridge.setValue(
|
||||||
settings, "PageSize" as CFString,
|
paperSize, forKey: "PageSize", locked: false,
|
||||||
paperSize as CFString, false), key: "PageSize")
|
in: settings, context: "Print panel")
|
||||||
}
|
}
|
||||||
if let key = selections.qualityKey, let value = selections.quality {
|
if let key = selections.qualityKey, let value = selections.quality {
|
||||||
warnOnFailure(PMPrintSettingsSetValue(
|
PMTicketBridge.setValue(
|
||||||
settings, key as CFString,
|
value, forKey: key, locked: false,
|
||||||
value as CFString, false), key: key)
|
in: settings, context: "Print panel")
|
||||||
}
|
}
|
||||||
// Media type via the queue's detected vendor key (#186).
|
// Media type via the queue's detected vendor key (#186).
|
||||||
if let mediaType = selections.mediaType,
|
if let mediaType = selections.mediaType,
|
||||||
let mediaKey = CupsParsers.detectMediaTypeKey(
|
let mediaKey = CupsParsers.detectMediaTypeKey(
|
||||||
optionKeys: optionKeys) {
|
optionKeys: optionKeys) {
|
||||||
warnOnFailure(PMPrintSettingsSetValue(
|
PMTicketBridge.setValue(
|
||||||
settings, mediaKey as CFString,
|
mediaType, forKey: mediaKey, locked: false,
|
||||||
mediaType as CFString, false), key: mediaKey)
|
in: settings, context: "Print panel")
|
||||||
}
|
}
|
||||||
// Orientation — portrait=3, landscape=4 (CUPS IPP codes).
|
// Orientation — portrait=3, landscape=4 (CUPS IPP codes).
|
||||||
if let orientation = selections.orientation {
|
if let orientation = selections.orientation {
|
||||||
let code = orientation == "landscape" ? "4" : "3"
|
let code = orientation == "landscape" ? "4" : "3"
|
||||||
warnOnFailure(PMPrintSettingsSetValue(
|
PMTicketBridge.setValue(
|
||||||
settings, "orientation-requested" as CFString,
|
code, forKey: "orientation-requested", locked: false,
|
||||||
code as CFString, false), key: "orientation-requested")
|
in: settings, context: "Print panel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `PMPageFormat` half of paper preselect (#186 E1): the
|
|
||||||
/// panel's paper dropdown reflects the page format's `PMPaper`,
|
|
||||||
/// not `PMPrintSettings`. Match the Stage 2 `PageSize` token to a
|
|
||||||
/// paper from `PMPrinterGetPaperList`, rebuild the page format
|
|
||||||
/// around it, and copy it into the printInfo's format (TN2248:
|
|
||||||
/// `PMCreatePageFormatWithPMPaper` → `PMSessionValidatePageFormat`
|
|
||||||
/// → `PMCopyPageFormat` → `updateFromPMPageFormat`).
|
|
||||||
/// `Custom.<w>x<h>` tokens (already points) have no `PMPaper` —
|
|
||||||
/// set the Cocoa `paperSize` directly. Warn-only throughout: a
|
|
||||||
/// missed match must not keep the panel from opening.
|
|
||||||
private func applyPaperPageFormat(
|
|
||||||
_ token: String,
|
|
||||||
printer: PMPrinter,
|
|
||||||
session: PMPrintSession,
|
|
||||||
printInfo: NSPrintInfo
|
|
||||||
) {
|
|
||||||
if let custom = Self.customPaperDimensions(from: token) {
|
|
||||||
printInfo.paperSize = NSSize(
|
|
||||||
width: custom.width, height: custom.height)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var paperList: Unmanaged<CFArray>?
|
|
||||||
guard PMPrinterGetPaperList(printer, &paperList) == 0,
|
|
||||||
let papers = paperList?.takeUnretainedValue()
|
|
||||||
else {
|
|
||||||
AppLogger.shared.warn(
|
|
||||||
"Print panel: PMPrinterGetPaperList failed — "
|
|
||||||
+ "paper preselect skipped")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// The list (and its elements) is owned by the printer —
|
|
||||||
// borrowed, never released.
|
|
||||||
var match: PMPaper?
|
|
||||||
for index in 0..<CFArrayGetCount(papers) {
|
|
||||||
let paper = unsafeBitCast(
|
|
||||||
CFArrayGetValueAtIndex(papers, index), to: PMPaper.self)
|
|
||||||
var idRef: Unmanaged<CFString>?
|
|
||||||
guard PMPaperGetID(paper, &idRef) == 0,
|
|
||||||
let paperID = idRef?.takeUnretainedValue() as String?
|
|
||||||
else { continue }
|
|
||||||
if paperID == token {
|
|
||||||
match = paper
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard let paper = match else {
|
|
||||||
AppLogger.shared.warn(
|
|
||||||
"Print panel: no PMPaper id matches '\(token)'")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var created: PMPageFormat?
|
|
||||||
guard PMCreatePageFormatWithPMPaper(&created, paper) == 0,
|
|
||||||
let newFormat = created
|
|
||||||
else {
|
|
||||||
AppLogger.shared.warn(
|
|
||||||
"Print panel: PMCreatePageFormatWithPMPaper failed "
|
|
||||||
+ "for '\(token)'")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer { PMRelease(unsafeBitCast(newFormat, to: PMObject.self)) }
|
|
||||||
_ = PMSessionValidatePageFormat(session, newFormat, nil)
|
|
||||||
let destination = unsafeBitCast(
|
|
||||||
printInfo.pmPageFormat(), to: PMPageFormat.self)
|
|
||||||
_ = PMCopyPageFormat(newFormat, destination)
|
|
||||||
printInfo.updateFromPMPageFormat()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `Custom.<w>x<h>` → dimensions in points (the token builder
|
|
||||||
/// emits integer points, mm × 72/25.4). `nil` for non-custom or
|
|
||||||
/// malformed tokens — a malformed `Custom.*` then misses the
|
|
||||||
/// `PMPaper` match and logs instead of guessing a size.
|
|
||||||
static func customPaperDimensions(
|
|
||||||
from token: String
|
|
||||||
) -> (width: Double, height: Double)? {
|
|
||||||
guard token.hasPrefix("Custom.") else { return nil }
|
|
||||||
let dims = token.dropFirst("Custom.".count).split(separator: "x")
|
|
||||||
guard dims.count == 2,
|
|
||||||
let width = Double(dims[0]), let height = Double(dims[1]),
|
|
||||||
width > 0, height > 0
|
|
||||||
else { return nil }
|
|
||||||
return (width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func warnOnFailure(_ status: OSStatus, key: String) {
|
|
||||||
if status != 0 {
|
|
||||||
AppLogger.shared.warn(
|
|
||||||
"Print panel: PMPrintSettingsSetValue(\(key)) "
|
|
||||||
+ "rejected (\(status))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PM helpers
|
|
||||||
|
|
||||||
/// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants
|
|
||||||
/// `UnsafeRawPointer`, Swift imports `PMPrinter` as `OpaquePointer`.
|
|
||||||
static func pmObject(_ printer: PMPrinter) -> PMObject {
|
|
||||||
unsafeBitCast(printer, to: PMObject.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String.
|
|
||||||
private static func currentPrinterID(
|
|
||||||
session: PMPrintSession,
|
|
||||||
fallback: String
|
|
||||||
) -> String {
|
|
||||||
var current: PMPrinter?
|
|
||||||
guard PMSessionGetCurrentPrinter(session, ¤t) == 0,
|
|
||||||
let printer = current
|
|
||||||
else { return fallback }
|
|
||||||
defer { PMRelease(pmObject(printer)) }
|
|
||||||
guard let id = PMPrinterGetID(printer)
|
|
||||||
else { return fallback }
|
|
||||||
return id.takeUnretainedValue() as String
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import Combine
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85).
|
/// CUPS queue selection, bound print panel, and native headless spool
|
||||||
|
/// (issues 12–15, 17 / #85, #201).
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PrintSessionViewModel: ObservableObject {
|
final class PrintSessionViewModel: ObservableObject {
|
||||||
let wizard: WizardViewModel
|
let wizard: WizardViewModel
|
||||||
@@ -13,23 +14,70 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
weak var workflow: TargetWorkflowViewModel?
|
weak var workflow: TargetWorkflowViewModel?
|
||||||
|
|
||||||
@Published var printers: [Printer] = []
|
@Published var printers: [Printer] = []
|
||||||
@Published var selectedPrinter = ""
|
@Published var selectedPrinter = "" {
|
||||||
|
didSet {
|
||||||
|
// Tickets are queue- and driver-version-specific — a stale
|
||||||
|
// ticket must never replay onto a re-selected queue (R3).
|
||||||
|
if selectedPrinter != oldValue {
|
||||||
|
capturedTickets[oldValue] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@Published var printerCaps = PrinterCapabilities()
|
@Published var printerCaps = PrinterCapabilities()
|
||||||
@Published var selectedTray: Int?
|
@Published var selectedTray: Int?
|
||||||
@Published var selectedMediaType: String?
|
@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).
|
||||||
@Published var selectedQuality: String?
|
@Published var selectedQuality: String?
|
||||||
@Published var printOrientation = "portrait"
|
@Published var printOrientation = "portrait"
|
||||||
@Published var capturedCupsOptions: [String: String] = [:]
|
@Published var capturedCupsOptions: [String: String] = [:]
|
||||||
|
/// Native print tickets per queue — the spool payload (#201).
|
||||||
|
/// Session-only; cleared whenever the queue's mirror is.
|
||||||
|
@Published var capturedTickets: [String: PrintTicket] = [:]
|
||||||
@Published var printNotice: Notice?
|
@Published var printNotice: Notice?
|
||||||
@Published var isPrinting = false
|
@Published var isPrinting = false
|
||||||
|
/// Session-only job granularity (#201 D5): off → one job per page
|
||||||
|
/// (per-page notices + error attribution); on → a single job.
|
||||||
|
@Published var singleJobForAllPages = false
|
||||||
|
/// The spool backend — the `ICCERY_TEST_SPOOL_LOG` recording seam
|
||||||
|
/// under UI testing, `NSPrintOperation` otherwise (#201 D8).
|
||||||
|
/// `internal` so unit tests inject.
|
||||||
|
var spooler: TargetSpooling
|
||||||
private var printTask: Task<Void, Never>?
|
private var printTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
self.wizard = wizard
|
self.wizard = wizard
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
|
#if DEBUG
|
||||||
|
if UITestHooks.isEnabled, let logURL = UITestHooks.spoolLogURL {
|
||||||
|
spooler = RecordingTargetSpooler(logURL: logURL)
|
||||||
|
} else {
|
||||||
|
spooler = NativeTargetSpooler()
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
spooler = NativeTargetSpooler()
|
||||||
|
#endif
|
||||||
|
attachDiagnostics()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warn-only spooler diagnostics (manifest drift, oversize page)
|
||||||
|
/// surface as the in-panel notice. Re-attached per request so an
|
||||||
|
/// injected spooler picks it up too — and so `spooler` needs no
|
||||||
|
/// `didSet` (mutating `diagnostics` through the existential would
|
||||||
|
/// re-fire the observer and recurse).
|
||||||
|
private func attachDiagnostics() {
|
||||||
|
spooler.diagnostics = { [weak self] notice in
|
||||||
|
self?.printNotice = notice
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var printerEnumTask: Task<[Printer]?, Never>?
|
private var printerEnumTask: Task<[Printer]?, Never>?
|
||||||
@@ -83,9 +131,16 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
if selectedTray == nil {
|
if selectedTray == nil {
|
||||||
selectedTray = printerCaps.trays.first?.id
|
selectedTray = 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.
|
||||||
@@ -97,6 +152,32 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Paper / quality selection (#183)
|
// MARK: - Paper / quality selection (#183)
|
||||||
|
|
||||||
|
/// The quality picker's source (#214): `printerCaps.qualities`
|
||||||
|
/// filtered to what the driver accepts for `selectedMediaType`.
|
||||||
|
/// Equals the full list whenever the queue exposes no per-media
|
||||||
|
/// quality matrix — and never empty while `qualities` is not.
|
||||||
|
var availableQualities: [PrinterQuality] {
|
||||||
|
printerCaps.qualities(forMediaType: selectedMediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep `selectedQuality` inside the allowed set for the current
|
||||||
|
/// media: keep the pick when still valid, else the driver default
|
||||||
|
/// when valid, else the first allowed choice (#214).
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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`);
|
||||||
@@ -131,7 +212,8 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
|
|
||||||
/// The CUPS `PageSize` token for the current Stage 2 pick — live
|
/// The CUPS `PageSize` token for the current Stage 2 pick — live
|
||||||
/// `Custom.<pt>x<pt>` for the synthetic entry, else the capability
|
/// `Custom.<pt>x<pt>` for the synthetic entry, else the capability
|
||||||
/// name. This is what `lp -o PageSize=` sees.
|
/// name. Resolved into the `PageSize` ticket write and the
|
||||||
|
/// `PMPaper` match on the native spool path (#201).
|
||||||
var selectedPaperSizeToken: String? {
|
var selectedPaperSizeToken: String? {
|
||||||
guard let id = selectedPaperSize else { return nil }
|
guard let id = selectedPaperSize else { return nil }
|
||||||
if id == 0 { return customPaperToken() }
|
if id == 0 { return customPaperToken() }
|
||||||
@@ -164,31 +246,40 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let selected = result.selectedPrinter,
|
if let selected = result.properties.selectedPrinter,
|
||||||
printers.contains(where: { $0.name == selected }),
|
printers.contains(where: { $0.name == selected }),
|
||||||
selected != queue {
|
selected != queue {
|
||||||
selectedPrinter = selected
|
selectedPrinter = selected
|
||||||
await reloadSelectedCapabilities()
|
await reloadSelectedCapabilities()
|
||||||
}
|
}
|
||||||
if let captured = result.options.cupsOptions {
|
if let captured = result.properties.options.cupsOptions {
|
||||||
capturedCupsOptions[selectedPrinter] = captured
|
capturedCupsOptions[selectedPrinter] = captured
|
||||||
}
|
}
|
||||||
if let media = result.options.mediaType {
|
// The native ticket rides alongside the mirror (#201).
|
||||||
|
if let ticket = result.ticket {
|
||||||
|
capturedTickets[ticket.queue] = ticket
|
||||||
|
}
|
||||||
|
if let media = result.properties.options.mediaType {
|
||||||
selectedMediaType = media
|
selectedMediaType = media
|
||||||
}
|
}
|
||||||
// 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
|
||||||
// sacred).
|
// sacred).
|
||||||
if let paper = result.options.paperSize,
|
if let paper = result.properties.options.paperSize,
|
||||||
let match = printerCaps.paperSizes
|
let match = printerCaps.paperSizes
|
||||||
.first(where: { $0.name == paper }) {
|
.first(where: { $0.name == paper }) {
|
||||||
selectedPaperSize = match.id
|
selectedPaperSize = match.id
|
||||||
}
|
}
|
||||||
if let quality = result.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.options.orientation {
|
if let orientation = result.properties.options.orientation {
|
||||||
printOrientation = orientation
|
printOrientation = orientation
|
||||||
}
|
}
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
@@ -209,10 +300,31 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// `defer` cannot mutate isolated state under Swift 5.7
|
// `defer` cannot mutate isolated state under Swift 5.7
|
||||||
// (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113).
|
// (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113).
|
||||||
|
if singleJobForAllPages {
|
||||||
|
// D5 — one job carries every page; a failure names the
|
||||||
|
// job, not a page (R14).
|
||||||
|
do {
|
||||||
|
try await spoolAll(result)
|
||||||
|
printNotice = Notice(
|
||||||
|
kind: .info,
|
||||||
|
text: "Sent \(result.pages.count) page(s) in "
|
||||||
|
+ "one job to \(selectedPrinter).",
|
||||||
|
autoHideAfter: nil
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
printNotice = Notice(
|
||||||
|
kind: .error,
|
||||||
|
text: "Print failed: \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isPrinting = false
|
||||||
|
self.printTask = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
var printed = 0
|
var printed = 0
|
||||||
for page in result.pages {
|
for page in result.pages {
|
||||||
do {
|
do {
|
||||||
try await spool(page, index: page.index)
|
try await spool(page)
|
||||||
printed += 1
|
printed += 1
|
||||||
} catch {
|
} catch {
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
@@ -242,7 +354,7 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
let task = Task { @MainActor [weak self] in
|
let task = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
try await spool(page, index: page.index)
|
try await spool(page)
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
kind: .info,
|
kind: .info,
|
||||||
text: "Sent \(page.page.filename) to \(selectedPrinter).",
|
text: "Sent \(page.page.filename) to \(selectedPrinter).",
|
||||||
@@ -260,25 +372,64 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
printTask = task
|
printTask = task
|
||||||
}
|
}
|
||||||
|
|
||||||
private func spool(_ page: GalleryPage, index: Int) async throws {
|
private func spool(_ page: GalleryPage) async throws {
|
||||||
|
let request = try await makeRequest(
|
||||||
|
pages: [page], title: page.page.filename)
|
||||||
|
try spooler.spool(request)
|
||||||
|
wizard.printerName = selectedPrinter
|
||||||
|
}
|
||||||
|
|
||||||
|
private func spoolAll(_ result: PrinttargResult) async throws {
|
||||||
|
let request = try await makeRequest(
|
||||||
|
pages: result.pages,
|
||||||
|
title: result.ti2URL.deletingPathExtension()
|
||||||
|
.lastPathComponent)
|
||||||
|
try spooler.spool(request)
|
||||||
|
wizard.printerName = selectedPrinter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assemble the deterministic spool request: the captured ticket
|
||||||
|
/// (when the panel produced one) plus the Stage 2 overrides and
|
||||||
|
/// the queue's option-key roster for vendor-key detection (#201).
|
||||||
|
/// The Stage 2 paper token feeds the `PageSize` write;
|
||||||
|
/// `workflow.pageSize` remains the printtarg layout input only
|
||||||
|
/// (#183).
|
||||||
|
private func makeRequest(
|
||||||
|
pages: [GalleryPage], title: String
|
||||||
|
) async throws -> TargetPrintRequest {
|
||||||
guard !selectedPrinter.isEmpty else {
|
guard !selectedPrinter.isEmpty else {
|
||||||
throw CupsError.noPrinterSelected
|
throw CupsError.noPrinterSelected
|
||||||
}
|
}
|
||||||
// The Stage 2 paper token is what `lp -o PageSize=` sees;
|
let queue = selectedPrinter
|
||||||
// `workflow.pageSize` remains the printtarg layout input only
|
let optionKeys = (try? await environment.cupsService
|
||||||
// (#183).
|
.optionKeys(for: queue)) ?? []
|
||||||
let options = PrintOptions(
|
attachDiagnostics()
|
||||||
orientation: printOrientation,
|
// Defence-in-depth (#214): an invalid media+quality pair can
|
||||||
paperSize: selectedPaperSizeToken,
|
// never reach the ticket — substitute the first allowed
|
||||||
mediaType: selectedMediaType,
|
// quality when the pick is stale (UI already clamps; this is
|
||||||
quality: selectedQuality,
|
// the last gate before the driver).
|
||||||
ppdUncorrectedPassthrough: true,
|
var quality = selectedQuality
|
||||||
cupsOptions: capturedCupsOptions[selectedPrinter])
|
if let q = quality,
|
||||||
try await environment.cupsService.printTarget(
|
!printerCaps.allowsQuality(q, forMediaType: selectedMediaType) {
|
||||||
queue: selectedPrinter,
|
quality = availableQualities.first?.id ?? q
|
||||||
tiffPath: page.fileURL.path,
|
}
|
||||||
options: options,
|
return TargetPrintRequest(
|
||||||
page: index)
|
queue: queue,
|
||||||
wizard.printerName = selectedPrinter
|
displayName: printers.first { $0.name == queue }?.displayName,
|
||||||
|
pages: pages.map {
|
||||||
|
TargetPrintPage(
|
||||||
|
url: $0.fileURL,
|
||||||
|
expectedWidthMm: $0.page.widthMm,
|
||||||
|
expectedHeightMm: $0.page.heightMm)
|
||||||
|
},
|
||||||
|
jobTitle: "ICCery Target - \(title)",
|
||||||
|
ticket: capturedTickets[queue],
|
||||||
|
overrides: TargetPrintOverrides(
|
||||||
|
paperSize: selectedPaperSizeToken,
|
||||||
|
mediaType: selectedMediaType,
|
||||||
|
qualityKey: printerCaps.qualityKey,
|
||||||
|
quality: quality,
|
||||||
|
orientation: printOrientation),
|
||||||
|
optionKeys: optionKeys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// A captured native print ticket (#201) — the payload `lp -o` could
|
||||||
|
/// never carry. Vendor PDE state, including opaque binary blobs, is
|
||||||
|
/// preserved verbatim inside `printSettings`.
|
||||||
|
///
|
||||||
|
/// In-memory, session-only. Never written to `settings.json` or an
|
||||||
|
/// `.icceryproj` (a ticket is queue- and driver-version-specific).
|
||||||
|
struct PrintTicket: Equatable, Sendable {
|
||||||
|
/// CUPS queue id this ticket was captured for. Replay onto any other
|
||||||
|
/// queue is refused (R3).
|
||||||
|
let queue: String
|
||||||
|
/// `PMPrintSettingsCreateDataRepresentation(…, kPMDataFormatXMLDefault)`.
|
||||||
|
let printSettings: Data
|
||||||
|
/// `PMPageFormatCreateDataRepresentation(…, kPMDataFormatXMLDefault)`.
|
||||||
|
let pageFormat: Data
|
||||||
|
/// Binary-plist snapshot of `NSPrintInfo.dictionary()`, plist-filtered.
|
||||||
|
/// Cocoa-level fallback only — never the primary restore path.
|
||||||
|
let printInfoPlist: Data?
|
||||||
|
let capturedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App-level panel outcome: the ICCeryCore `PrintPropertiesResult`
|
||||||
|
/// (Stage 2 mirror values, #183/#186) **plus** the native ticket the
|
||||||
|
/// spooler replays (#201).
|
||||||
|
struct PanelCaptureResult {
|
||||||
|
var properties: PrintPropertiesResult
|
||||||
|
var ticket: PrintTicket?
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Headless print canvas (#201, docs/14 §6). Never installed in a
|
||||||
|
/// window; `NSPrintOperation` is its only client.
|
||||||
|
@MainActor
|
||||||
|
final class TargetPageCanvasView: NSView {
|
||||||
|
|
||||||
|
private let pages: [TargetPageRaster]
|
||||||
|
private let paperSize: NSSize
|
||||||
|
|
||||||
|
/// Test probe — invoked once per drawn page with the live context
|
||||||
|
/// so the interpolation/antialias contract is assertable without a
|
||||||
|
/// printer.
|
||||||
|
var drawProbe: ((NSGraphicsContext, Int, NSRect) -> Void)?
|
||||||
|
|
||||||
|
init(pages: [TargetPageRaster], paperSize: NSSize) {
|
||||||
|
self.pages = pages
|
||||||
|
self.paperSize = paperSize
|
||||||
|
super.init(frame: NSRect(
|
||||||
|
x: 0, y: 0,
|
||||||
|
width: paperSize.width,
|
||||||
|
height: paperSize.height * CGFloat(pages.count)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("TargetPageCanvasView is code-only")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bottom-up CoreGraphics space: `CGContext.draw(_:in:)` renders
|
||||||
|
/// the raster upright — a flipped view prints mirrored (#211).
|
||||||
|
/// Page 1 is the BOTTOM band of the frame, the non-flipped
|
||||||
|
/// pagination convention.
|
||||||
|
override var isFlipped: Bool { false }
|
||||||
|
override var isOpaque: Bool { true }
|
||||||
|
|
||||||
|
override func knowsPageRange(_ range: NSRangePointer) -> Bool {
|
||||||
|
range.pointee = NSRange(location: 1, length: pages.count)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1-based page → its paper-sized rect, stacked bottom-up.
|
||||||
|
override func rectForPage(_ page: Int) -> NSRect {
|
||||||
|
NSRect(x: 0,
|
||||||
|
y: CGFloat(page - 1) * paperSize.height,
|
||||||
|
width: paperSize.width,
|
||||||
|
height: paperSize.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where page `page`'s image lands: top-left anchored inside
|
||||||
|
/// `rectForPage`, sized `raster.pointSize`, origin snapped to
|
||||||
|
/// 0.001 pt (docs/14 §6) so a fractional origin cannot trigger
|
||||||
|
/// device resampling. `internal` for unit tests.
|
||||||
|
func destinationRect(forPage page: Int) -> NSRect {
|
||||||
|
let pageRect = rectForPage(page)
|
||||||
|
let size = pages[page - 1].pointSize
|
||||||
|
return NSRect(x: Self.snap(pageRect.minX),
|
||||||
|
y: Self.snap(pageRect.maxY - size.height),
|
||||||
|
width: size.width,
|
||||||
|
height: size.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
guard let ctx = NSGraphicsContext.current else { return }
|
||||||
|
ctx.imageInterpolation = .none
|
||||||
|
ctx.shouldAntialias = false
|
||||||
|
let cg = ctx.cgContext
|
||||||
|
cg.interpolationQuality = .none
|
||||||
|
cg.setShouldAntialias(false)
|
||||||
|
cg.setAllowsAntialiasing(false)
|
||||||
|
cg.setShouldSmoothFonts(false)
|
||||||
|
for index in pages.indices {
|
||||||
|
let pageRect = rectForPage(index + 1)
|
||||||
|
guard pageRect.intersects(dirtyRect) else { continue }
|
||||||
|
cg.saveGState() // page N never inherits N-1's CTM
|
||||||
|
NSColor.white.setFill()
|
||||||
|
pageRect.fill()
|
||||||
|
cg.draw(pages[index].cgImage,
|
||||||
|
in: destinationRect(forPage: index + 1))
|
||||||
|
cg.restoreGState()
|
||||||
|
drawProbe?(ctx, index + 1, destinationRect(forPage: index + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func snap(_ value: CGFloat) -> CGFloat {
|
||||||
|
(value * 1000).rounded() / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
import ImageIO
|
||||||
|
|
||||||
|
/// One decoded target page ready to draw 1:1 (#201, docs/14 §6).
|
||||||
|
struct TargetPageRaster: Equatable {
|
||||||
|
let sourceURL: URL
|
||||||
|
/// Re-tagged into a **device** colour space — the image Quartz draws.
|
||||||
|
let cgImage: CGImage
|
||||||
|
let pixelWidth: Int
|
||||||
|
let pixelHeight: Int
|
||||||
|
let dpiX: Double
|
||||||
|
let dpiY: Double
|
||||||
|
let componentCount: Int
|
||||||
|
let bitsPerComponent: Int
|
||||||
|
/// Expected physical size from the printtarg manifest, when known.
|
||||||
|
let expectedWidthMm: Double?
|
||||||
|
let expectedHeightMm: Double?
|
||||||
|
|
||||||
|
/// `pixels / dpi * 72`. The ONLY size the canvas ever draws at.
|
||||||
|
/// 72 pt = 1 in. **Never** `backingScaleFactor` (docs/14 §6).
|
||||||
|
var pointSize: CGSize {
|
||||||
|
CGSize(width: Double(pixelWidth) / dpiX * 72,
|
||||||
|
height: Double(pixelHeight) / dpiY * 72)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pixels / dpi * 25.4` — the physical size the DPI implies.
|
||||||
|
var physicalMm: CGSize {
|
||||||
|
CGSize(width: Double(pixelWidth) / dpiX * 25.4,
|
||||||
|
height: Double(pixelHeight) / dpiY * 25.4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `nil` when within 0.5 mm of the manifest, else the drift
|
||||||
|
/// message (D9 — warn + spool, never rescale).
|
||||||
|
var manifestDrift: String? {
|
||||||
|
var parts: [String] = []
|
||||||
|
if let expectedWidthMm,
|
||||||
|
abs(physicalMm.width - expectedWidthMm) > 0.5 {
|
||||||
|
parts.append(String(
|
||||||
|
format: "width %.2f mm vs manifest %.2f mm",
|
||||||
|
physicalMm.width, expectedWidthMm))
|
||||||
|
}
|
||||||
|
if let expectedHeightMm,
|
||||||
|
abs(physicalMm.height - expectedHeightMm) > 0.5 {
|
||||||
|
parts.append(String(
|
||||||
|
format: "height %.2f mm vs manifest %.2f mm",
|
||||||
|
physicalMm.height, expectedHeightMm))
|
||||||
|
}
|
||||||
|
guard !parts.isEmpty else { return nil }
|
||||||
|
return "TIFF DPI disagrees with the printtarg manifest — "
|
||||||
|
+ parts.joined(separator: "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: TargetPageRaster, rhs: TargetPageRaster) -> Bool {
|
||||||
|
lhs.sourceURL == rhs.sourceURL
|
||||||
|
&& lhs.cgImage === rhs.cgImage
|
||||||
|
&& lhs.pixelWidth == rhs.pixelWidth
|
||||||
|
&& lhs.pixelHeight == rhs.pixelHeight
|
||||||
|
&& lhs.dpiX == rhs.dpiX
|
||||||
|
&& lhs.dpiY == rhs.dpiY
|
||||||
|
&& lhs.componentCount == rhs.componentCount
|
||||||
|
&& lhs.bitsPerComponent == rhs.bitsPerComponent
|
||||||
|
&& lhs.expectedWidthMm == rhs.expectedWidthMm
|
||||||
|
&& lhs.expectedHeightMm == rhs.expectedHeightMm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TargetRasterError: LocalizedError, Equatable {
|
||||||
|
case tiffMissing(String)
|
||||||
|
case undecodable(String)
|
||||||
|
case unsupportedLayout(path: String, components: Int,
|
||||||
|
bitsPerComponent: Int, hasAlpha: Bool)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .tiffMissing(let path):
|
||||||
|
return "Target TIFF is missing: \(path)"
|
||||||
|
case .undecodable(let path):
|
||||||
|
return "Target TIFF could not be decoded: \(path)"
|
||||||
|
case .unsupportedLayout(let path, let components,
|
||||||
|
let bitsPerComponent, let hasAlpha):
|
||||||
|
return "Target TIFF has an unsupported layout "
|
||||||
|
+ "(\(components) components, \(bitsPerComponent) bpc"
|
||||||
|
+ "\(hasAlpha ? ", alpha" : "")): \(path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TargetRasterLoader {
|
||||||
|
/// Absent DPI metadata defaults to 72 (docs/14 §6) — and logs `warn`.
|
||||||
|
static let fallbackDPI: Double = 72
|
||||||
|
|
||||||
|
static func load(
|
||||||
|
tiff url: URL,
|
||||||
|
expectedWidthMm: Double? = nil,
|
||||||
|
expectedHeightMm: Double? = nil
|
||||||
|
) throws -> TargetPageRaster {
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
throw TargetRasterError.tiffMissing(url.path)
|
||||||
|
}
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil)
|
||||||
|
else {
|
||||||
|
throw TargetRasterError.undecodable(url.path)
|
||||||
|
}
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageSourceShouldCache: true,
|
||||||
|
kCGImageSourceShouldAllowFloat: false,
|
||||||
|
]
|
||||||
|
guard let image = CGImageSourceCreateImageAtIndex(
|
||||||
|
source, 0, options as CFDictionary)
|
||||||
|
else {
|
||||||
|
throw TargetRasterError.undecodable(url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DPI lives in the container metadata, not the CGImage.
|
||||||
|
var dpiX = fallbackDPI
|
||||||
|
var dpiY = fallbackDPI
|
||||||
|
var foundX = false
|
||||||
|
var foundY = false
|
||||||
|
if let properties = CGImageSourceCopyPropertiesAtIndex(
|
||||||
|
source, 0, nil) as? [CFString: Any] {
|
||||||
|
if let x = (properties[kCGImagePropertyDPIWidth]
|
||||||
|
as? NSNumber)?.doubleValue, x > 0 {
|
||||||
|
dpiX = x
|
||||||
|
foundX = true
|
||||||
|
}
|
||||||
|
if let y = (properties[kCGImagePropertyDPIHeight]
|
||||||
|
as? NSNumber)?.doubleValue, y > 0 {
|
||||||
|
dpiY = y
|
||||||
|
foundY = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundX || !foundY {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"TargetRaster: '\(url.lastPathComponent)' carries no "
|
||||||
|
+ "usable DPI metadata — assuming \(fallbackDPI) dpi")
|
||||||
|
}
|
||||||
|
|
||||||
|
let components = image.colorSpace?.numberOfComponents ?? 0
|
||||||
|
guard !Self.hasRealAlpha(image), let tagged = deviceTagged(image)
|
||||||
|
else {
|
||||||
|
throw TargetRasterError.unsupportedLayout(
|
||||||
|
path: url.path,
|
||||||
|
components: components,
|
||||||
|
bitsPerComponent: image.bitsPerComponent,
|
||||||
|
hasAlpha: Self.hasRealAlpha(image))
|
||||||
|
}
|
||||||
|
|
||||||
|
return TargetPageRaster(
|
||||||
|
sourceURL: url,
|
||||||
|
cgImage: tagged,
|
||||||
|
pixelWidth: image.width,
|
||||||
|
pixelHeight: image.height,
|
||||||
|
dpiX: dpiX,
|
||||||
|
dpiY: dpiY,
|
||||||
|
componentCount: components,
|
||||||
|
bitsPerComponent: image.bitsPerComponent,
|
||||||
|
expectedWidthMm: expectedWidthMm,
|
||||||
|
expectedHeightMm: expectedHeightMm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-tag into `DeviceGray` (1 comp) / `DeviceRGB` (3) /
|
||||||
|
/// `DeviceCMYK` (4) by reusing the decoded image's own
|
||||||
|
/// `dataProvider`, `bitsPerComponent`, `bitsPerPixel`, `bytesPerRow`
|
||||||
|
/// and `bitmapInfo` — **no resample, no bit-depth change, no
|
||||||
|
/// `CGImageCreateCopyWithColorSpace` into a calibrated space**
|
||||||
|
/// (docs/14 §6.3). `shouldInterpolate: false`,
|
||||||
|
/// `intent: .defaultIntent`.
|
||||||
|
///
|
||||||
|
/// Returns `nil` (caller throws `.unsupportedLayout`) for alpha,
|
||||||
|
/// indexed, float, or >4-component images — a profiling target
|
||||||
|
/// never has those, and guessing would corrupt patches.
|
||||||
|
static func deviceTagged(_ image: CGImage) -> CGImage? {
|
||||||
|
guard !hasRealAlpha(image),
|
||||||
|
!image.bitmapInfo.contains(.floatComponents),
|
||||||
|
let colorSpace = image.colorSpace,
|
||||||
|
colorSpace.model != .indexed,
|
||||||
|
colorSpace.model != .pattern,
|
||||||
|
let space = deviceSpace(for: colorSpace.numberOfComponents),
|
||||||
|
let provider = image.dataProvider
|
||||||
|
else { return nil }
|
||||||
|
return CGImage(
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
bitsPerComponent: image.bitsPerComponent,
|
||||||
|
bitsPerPixel: image.bitsPerPixel,
|
||||||
|
bytesPerRow: image.bytesPerRow,
|
||||||
|
space: space,
|
||||||
|
bitmapInfo: image.bitmapInfo,
|
||||||
|
provider: provider,
|
||||||
|
decode: image.decode,
|
||||||
|
shouldInterpolate: false,
|
||||||
|
intent: .defaultIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip-only padding (`noneSkipLast`/`noneSkipFirst`) is **not**
|
||||||
|
/// alpha — ImageIO pads opaque RGB TIFFs to 32 bpp that way, so
|
||||||
|
/// accepting it is required for real printtarg files. True coverage
|
||||||
|
/// alpha (`last`/`first`/`premultiplied*`/`only`) is rejected.
|
||||||
|
static func hasRealAlpha(_ image: CGImage) -> Bool {
|
||||||
|
switch image.alphaInfo {
|
||||||
|
case .none, .noneSkipLast, .noneSkipFirst:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The device colour space for a component count — `nil` for
|
||||||
|
/// layouts a profiling target can never legally be (2 channels,
|
||||||
|
/// DeviceN, >4 components).
|
||||||
|
static func deviceSpace(for componentCount: Int) -> CGColorSpace? {
|
||||||
|
switch componentCount {
|
||||||
|
case 1: return CGColorSpaceCreateDeviceGray()
|
||||||
|
case 3: return CGColorSpaceCreateDeviceRGB()
|
||||||
|
case 4: return CGColorSpaceCreateDeviceCMYK()
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Stage 2 inline overrides applied on top of a rehydrated ticket
|
||||||
|
/// (#201 AC3 / D6 — Stage 2 always wins).
|
||||||
|
struct TargetPrintOverrides: Equatable {
|
||||||
|
var paperSize: String? // CUPS `PageSize` token or `Custom.<w>x<h>`
|
||||||
|
var mediaType: String?
|
||||||
|
var qualityKey: String? // PrinterCapabilities.qualityKey
|
||||||
|
var quality: String?
|
||||||
|
var orientation: String? // "portrait" | "landscape"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One resolved ticket write.
|
||||||
|
struct TicketWrite: Equatable {
|
||||||
|
let key: String
|
||||||
|
let value: String
|
||||||
|
let locked: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fully-resolved write list for one job — a **pure value**,
|
||||||
|
/// computed with zero Core Printing calls so it is unit- and
|
||||||
|
/// UI-testable without a live queue. Replaces the old `lp` argv
|
||||||
|
/// builder (#201 D4/D8).
|
||||||
|
struct ResolvedTicketWrites: Equatable {
|
||||||
|
var writes: [TicketWrite]
|
||||||
|
var paperToken: String?
|
||||||
|
var orientation: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TicketWriteResolver {
|
||||||
|
|
||||||
|
/// Quartz vocabulary constants (docs/14 §7) live on
|
||||||
|
/// `ColorMatchingAttempts` next to the AP_* half — written
|
||||||
|
/// alongside because there is no `lp` path left (D2).
|
||||||
|
///
|
||||||
|
/// Order is locked and is the contract the tests assert:
|
||||||
|
/// 1 `AP_ColorMatchingMode` = AP_ApplicationColorMatching (locked)
|
||||||
|
/// 2 `AP.ColorMatchingMode` = AP_ApplicationColorMatching (locked)
|
||||||
|
/// 3 `PMColorMatchingMode` = APCustomColorMatching (locked)
|
||||||
|
/// 4 `PMCustomColorMatchingProfile` = "" (unlocked)
|
||||||
|
/// 5 `com.apple.print.PrintSettings.PMColorMatchingMode`
|
||||||
|
/// = APCustomColorMatching (unlocked)
|
||||||
|
/// 6 `PageSize` = overrides.paperSize
|
||||||
|
/// 7 detectMediaTypeKey(optionKeys) = overrides.mediaType
|
||||||
|
/// 8 overrides.qualityKey = overrides.quality
|
||||||
|
/// 9 detectDriverColorBypass(optionKeys) (unlocked)
|
||||||
|
/// 10 `orientation-requested` = 3 | 4
|
||||||
|
///
|
||||||
|
/// Invariants: `raw` can never appear (there is no `lp`); nothing
|
||||||
|
/// is suppressed because a captured key already exists (D6 — the
|
||||||
|
/// ticket carries the captured keys and Stage 2 writes land on
|
||||||
|
/// top unconditionally); an override with a `nil` value emits no
|
||||||
|
/// write.
|
||||||
|
static func resolve(
|
||||||
|
overrides: TargetPrintOverrides,
|
||||||
|
optionKeys: Set<String>
|
||||||
|
) -> ResolvedTicketWrites {
|
||||||
|
var writes: [TicketWrite] = []
|
||||||
|
|
||||||
|
// 1–2 — locked AP_* pair (layer ③).
|
||||||
|
for key in ColorMatchingAttempts.printSettingsKeys {
|
||||||
|
writes.append(TicketWrite(
|
||||||
|
key: key,
|
||||||
|
value: ColorMatchingAttempts.applicationMatchingValue,
|
||||||
|
locked: true))
|
||||||
|
}
|
||||||
|
// 3–5 — Quartz/`NSPrintOperation` vocabulary (D2).
|
||||||
|
writes.append(TicketWrite(
|
||||||
|
key: ColorMatchingAttempts.quartzModeKey,
|
||||||
|
value: ColorMatchingAttempts.quartzCustomMatching,
|
||||||
|
locked: true))
|
||||||
|
writes.append(TicketWrite(
|
||||||
|
key: ColorMatchingAttempts.quartzProfileKey,
|
||||||
|
value: "",
|
||||||
|
locked: false))
|
||||||
|
writes.append(TicketWrite(
|
||||||
|
key: ColorMatchingAttempts.quartzLegacyModeKey,
|
||||||
|
value: ColorMatchingAttempts.quartzCustomMatching,
|
||||||
|
locked: false))
|
||||||
|
|
||||||
|
// 6 — the Stage 2 paper token.
|
||||||
|
var paperToken: String?
|
||||||
|
if let paperSize = overrides.paperSize, !paperSize.isEmpty {
|
||||||
|
paperToken = paperSize
|
||||||
|
writes.append(TicketWrite(
|
||||||
|
key: "PageSize", value: paperSize, locked: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7 — media type via the queue's detected vendor key.
|
||||||
|
if let mediaType = overrides.mediaType,
|
||||||
|
let mediaKey = CupsParsers.detectMediaTypeKey(
|
||||||
|
optionKeys: optionKeys) {
|
||||||
|
writes.append(TicketWrite(
|
||||||
|
key: mediaKey, value: mediaType, locked: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8 — print quality; the detected enumeration key travels in
|
||||||
|
// the overrides (`PrinterCapabilities.qualityKey`, #183).
|
||||||
|
if let qualityKey = overrides.qualityKey,
|
||||||
|
let quality = overrides.quality {
|
||||||
|
writes.append(TicketWrite(
|
||||||
|
key: qualityKey, value: quality, locked: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9 — driver "no colour adjustment" bypass. Never gated on
|
||||||
|
// `ppdUncorrectedPassthrough` — macOS always bypasses.
|
||||||
|
if let bypass = CupsParsers.detectDriverColorBypass(
|
||||||
|
optionKeys: optionKeys) {
|
||||||
|
writes.append(TicketWrite(
|
||||||
|
key: bypass.key, value: bypass.value, locked: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10 — orientation, CUPS IPP codes portrait=3 / landscape=4.
|
||||||
|
if let orientation = overrides.orientation {
|
||||||
|
writes.append(TicketWrite(
|
||||||
|
key: "orientation-requested",
|
||||||
|
value: orientation == "landscape" ? "4" : "3",
|
||||||
|
locked: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolvedTicketWrites(
|
||||||
|
writes: writes.filter { $0.key.lowercased() != "raw" },
|
||||||
|
paperToken: paperToken,
|
||||||
|
orientation: overrides.orientation)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -218,7 +218,7 @@ struct Stage2View: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Raw print panel (#rawPrintPanel) — unmanaged lp path
|
// MARK: - Raw print panel (#rawPrintPanel) — native spool path (#201)
|
||||||
|
|
||||||
private var printPanel: some View {
|
private var printPanel: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
@@ -266,6 +266,18 @@ struct Stage2View: View {
|
|||||||
.background(Theme.background)
|
.background(Theme.background)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
.accessibilityIdentifier("printerStatusBadge")
|
.accessibilityIdentifier("printerStatusBadge")
|
||||||
|
// AirPrint queues colour-manage the URF path —
|
||||||
|
// unmanaged colour cannot be guaranteed (#202).
|
||||||
|
if selected.isAirPrint {
|
||||||
|
Text("AirPrint queue — unmanaged colour "
|
||||||
|
+ "cannot be guaranteed.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||||
|
.background(Color.orange.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.accessibilityIdentifier("airPrintWarningBadge")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Button(action: workflow.print.refreshPrinters) {
|
Button(action: workflow.print.refreshPrinters) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
@@ -325,26 +337,7 @@ struct Stage2View: View {
|
|||||||
// Stage 1 owns the custom dimensions — the caption lives
|
// Stage 1 owns the custom dimensions — the caption lives
|
||||||
// inside `paperSizeGroup` (#183).
|
// inside `paperSizeGroup` (#183).
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
printAllRow
|
||||||
Button(action: {
|
|
||||||
if let result = workflow.printtargResult {
|
|
||||||
workflow.print.printAllPages(from: result)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
|
|
||||||
systemImage: "printer")
|
|
||||||
}
|
|
||||||
.controlSize(.large)
|
|
||||||
.disabled(workflow.print.isPrinting
|
|
||||||
|| workflow.printtargResult == nil
|
|
||||||
|| workflow.print.selectedPrinter.isEmpty)
|
|
||||||
.accessibilityIdentifier("btnPrintAll")
|
|
||||||
Spacer()
|
|
||||||
Button("Advance to Stage 3") { workflow.advanceToStage3() }
|
|
||||||
.accessibilityIdentifier("btnAdvanceToStage3")
|
|
||||||
.disabled(workflow.printtargResult == nil
|
|
||||||
|| !workflow.wizard.isUnlocked(.measure))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(Theme.panel)
|
.background(Theme.panel)
|
||||||
@@ -361,6 +354,37 @@ struct Stage2View: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Print All + single-job granularity (#201 D5) + advance.
|
||||||
|
/// Extracted so every ViewBuilder body stays ≤10 children (R13 —
|
||||||
|
/// Xcode 14.2 has no `buildPartialBlock`).
|
||||||
|
private var printAllRow: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(action: {
|
||||||
|
if let result = workflow.printtargResult {
|
||||||
|
workflow.print.printAllPages(from: result)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
|
||||||
|
systemImage: "printer")
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(workflow.print.isPrinting
|
||||||
|
|| workflow.printtargResult == nil
|
||||||
|
|| workflow.print.selectedPrinter.isEmpty)
|
||||||
|
.accessibilityIdentifier("btnPrintAll")
|
||||||
|
Toggle("Single spool job",
|
||||||
|
isOn: $workflow.print.singleJobForAllPages)
|
||||||
|
.help("Send all pages as one print job instead of "
|
||||||
|
+ "one job per page")
|
||||||
|
.accessibilityIdentifier("chkSingleSpoolJob")
|
||||||
|
Spacer()
|
||||||
|
Button("Advance to Stage 3") { workflow.advanceToStage3() }
|
||||||
|
.accessibilityIdentifier("btnAdvanceToStage3")
|
||||||
|
.disabled(workflow.printtargResult == nil
|
||||||
|
|| !workflow.wizard.isUnlocked(.measure))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Paper picker + custom-size caption under the `paperSizeGroup`
|
/// Paper picker + custom-size caption under the `paperSizeGroup`
|
||||||
/// container (#183). `caps.paperSizes` plus the synthetic custom
|
/// container (#183). `caps.paperSizes` plus the synthetic custom
|
||||||
/// entry (`id: 0`, shown as `Custom (W×H mm)`).
|
/// entry (`id: 0`, shown as `Custom (W×H mm)`).
|
||||||
@@ -386,11 +410,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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ final class CupsOptionsFilterTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// #180 — a captured `EPIJ_Qual` (and the other canonical quality
|
/// #180 — a captured `EPIJ_Qual` (and the other canonical quality
|
||||||
/// keys) survives the filter so it wins over the Stage 2 explicit
|
/// keys) survives the filter so it reaches the Stage 2 mirror.
|
||||||
/// quality in `LpArgs`.
|
|
||||||
func testKeepsQualityKeys() {
|
func testKeepsQualityKeys() {
|
||||||
let raw = "EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 "
|
let raw = "EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 "
|
||||||
+ "cupsPrintQuality=High Quality=Best "
|
+ "cupsPrintQuality=High Quality=Best "
|
||||||
|
|||||||
@@ -455,4 +455,175 @@ final class CupsParserTests: XCTestCase {
|
|||||||
processManager: ProcessManager(),
|
processManager: ProcessManager(),
|
||||||
binaryDir: bin, ppdDir: ppdDir), root)
|
binaryDir: bin, ppdDir: ppdDir), root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - #202 AirPrint detection
|
||||||
|
|
||||||
|
/// `lpstat -v` — `device for <name>: <uri>` lines; a `network`
|
||||||
|
/// remote stub carries no URI and is skipped.
|
||||||
|
private let lpstatV = """
|
||||||
|
device for Canon_Pro9500_II_series_XPS: usb://Canon/PRO-9500%20II%20series?serial=1234AB
|
||||||
|
device for Epson_XP_55_LPD: lpd://192.168.1.50/queue
|
||||||
|
device for EPSON_XP_55_Series: ipp://EPSON%20XP-55%20Series._universal._sub._ipp._tcp.local./
|
||||||
|
device for Office_IPPS: ipps://print.example.com/ipp/print
|
||||||
|
network Remote_Queue
|
||||||
|
"""
|
||||||
|
|
||||||
|
func testLpstatDeviceURIs() {
|
||||||
|
let uris = CupsParsers.lpstatDeviceURIs(output: lpstatV)
|
||||||
|
XCTAssertEqual(uris.count, 4)
|
||||||
|
XCTAssertEqual(uris["Canon_Pro9500_II_series_XPS"],
|
||||||
|
"usb://Canon/PRO-9500%20II%20series?serial=1234AB")
|
||||||
|
XCTAssertEqual(uris["Epson_XP_55_LPD"], "lpd://192.168.1.50/queue")
|
||||||
|
XCTAssertEqual(uris["EPSON_XP_55_Series"],
|
||||||
|
"ipp://EPSON%20XP-55%20Series._universal._sub._ipp._tcp.local./")
|
||||||
|
XCTAssertEqual(uris["Office_IPPS"],
|
||||||
|
"ipps://print.example.com/ipp/print")
|
||||||
|
XCTAssertNil(uris["Remote_Queue"])
|
||||||
|
XCTAssertEqual(CupsParsers.lpstatDeviceURIs(output: ""), [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLpoptionsMakeAndModel() {
|
||||||
|
XCTAssertEqual(CupsParsers.lpoptionsMakeAndModel(
|
||||||
|
output: lpoptionsP), "EPSON EPSON XP-55 Series")
|
||||||
|
XCTAssertNil(CupsParsers.lpoptionsMakeAndModel(
|
||||||
|
output: "printer-type=42\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 1 — an `apple-airprint://` device URI is AirPrint on its
|
||||||
|
/// own; PPD and make-and-model are irrelevant.
|
||||||
|
func testAirPrintRuleAppleAirPrintScheme() {
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "apple-airprint://DeskJet._ipps._tcp.local./",
|
||||||
|
makeAndModel: nil, ppd: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 2 — the PPD declares `*APAirPrint: True`.
|
||||||
|
func testAirPrintRulePPDFlag() {
|
||||||
|
let ppd = """
|
||||||
|
*PPD-Adobe: "4.3"
|
||||||
|
*APAirPrint: True
|
||||||
|
*OpenUI *PageSize/Media Size: PickOne
|
||||||
|
"""
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "socket://10.0.0.9/", makeAndModel: nil, ppd: ppd))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 3 — make-and-model contains "Apple" and "AirPrint".
|
||||||
|
func testAirPrintRuleMakeAndModel() {
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "socket://10.0.0.9/",
|
||||||
|
makeAndModel: "Apple AirPrint", ppd: ""))
|
||||||
|
// Both tokens are required.
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "socket://10.0.0.9/",
|
||||||
|
makeAndModel: "Apple LaserWriter", ppd: ""))
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "socket://10.0.0.9/",
|
||||||
|
makeAndModel: "HP AirPrint-Ready", ppd: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 4 — `ipps://` URI **and** the PPD text mentions "airprint"
|
||||||
|
/// case-insensitively. `ipps://` alone is not enough.
|
||||||
|
func testAirPrintRuleIPPSWithPPDMention() {
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipps://print.example.com/ipp/print",
|
||||||
|
makeAndModel: nil,
|
||||||
|
ppd: "*Foo: \"AIRPRINT enabled\"\n"))
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipps://print.example.com/ipp/print",
|
||||||
|
makeAndModel: nil, ppd: "*PPD-Adobe: \"4.3\"\n"))
|
||||||
|
// An unencrypted ipp:// URI does not satisfy rule 4.
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipp://print.example.com/ipp/print",
|
||||||
|
makeAndModel: nil,
|
||||||
|
ppd: "*Foo: \"airprint enabled\"\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 5 — unencrypted `ipp://` resolved via the AirPrint mDNS
|
||||||
|
/// subtype `_universal._sub._ipp._tcp`. A plain `ipp://` mDNS name
|
||||||
|
/// without the subtype is not AirPrint.
|
||||||
|
func testAirPrintRuleMDNSSubtype() {
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipp://EPSON%20XP-55._universal._sub._ipp._tcp.local./",
|
||||||
|
makeAndModel: nil, ppd: ""))
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipp://EPSON%20XP-55._ipp._tcp.local./",
|
||||||
|
makeAndModel: nil, ppd: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 6 — a `*cupsFilter2` rule whose destination MIME is
|
||||||
|
/// `image/urf` (the AirPrint-only raster). A PWG-raster filter is
|
||||||
|
/// not AirPrint.
|
||||||
|
func testAirPrintRuleCupsFilter2URF() {
|
||||||
|
let ppd = """
|
||||||
|
*cupsFilter2: "application/pdf image/urf 0 -"
|
||||||
|
*cupsFilter2: "image/urf image/urf 100 -"
|
||||||
|
"""
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: nil, makeAndModel: nil, ppd: ppd))
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: nil, makeAndModel: nil,
|
||||||
|
ppd: "*cupsFilter2: \"application/pdf image/pwg-raster 0 -\"\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Negative — a standard USB raster-driver queue (Epson XP-55)
|
||||||
|
/// matches none of the six rules.
|
||||||
|
func testAirPrintNegativeUSBRaster() {
|
||||||
|
let ppd = """
|
||||||
|
*PPD-Adobe: "4.3"
|
||||||
|
*EPIJ_Qual 303/Normal: ""
|
||||||
|
*cupsFilter: "application/vnd.cups-raster 0 rastertoepson"
|
||||||
|
"""
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "usb://EPSON/XP-55%20Series?serial=ABC123",
|
||||||
|
makeAndModel: "EPSON XP-55 Series", ppd: ppd))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `listPrinters` survives a failing `lpstat -v` — the failure is
|
||||||
|
/// tolerated, enumeration proceeds, every queue reports
|
||||||
|
/// `isAirPrint == false`, and there is **no** fallback respawn
|
||||||
|
/// (exactly one `-v` invocation, #202).
|
||||||
|
func testListPrintersToleratesLpstatVFailure() async throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-airprint-\(UUID().uuidString)")
|
||||||
|
let bin = root.appendingPathComponent("bin")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: bin, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let log = root.appendingPathComponent("lpstat.log")
|
||||||
|
|
||||||
|
let lpstat = """
|
||||||
|
#!/bin/sh
|
||||||
|
printf '%s\\n' "$1" >> '\(log.path)'
|
||||||
|
case "$1" in
|
||||||
|
-e) printf 'Mock_Epson\\n' ;;
|
||||||
|
-p) printf 'printer Mock_Epson is idle.\\n' ;;
|
||||||
|
-d) printf 'no system default destination\\n' ;;
|
||||||
|
-v) exit 1 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
"""
|
||||||
|
let lpoptions = """
|
||||||
|
#!/bin/sh
|
||||||
|
printf "printer-info='Mock'\\n"
|
||||||
|
"""
|
||||||
|
for (name, body) in [("lpstat", lpstat), ("lpoptions", lpoptions)] {
|
||||||
|
let url = bin.appendingPathComponent(name)
|
||||||
|
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
let service = CupsService(
|
||||||
|
processManager: ProcessManager(), binaryDir: bin,
|
||||||
|
ppdDir: root.appendingPathComponent("ppd"))
|
||||||
|
let printers = try await service.listPrinters()
|
||||||
|
XCTAssertEqual(printers.map(\.name), ["Mock_Epson"])
|
||||||
|
XCTAssertFalse(printers[0].isAirPrint)
|
||||||
|
|
||||||
|
let calls = ((try? String(contentsOf: log, encoding: .utf8)) ?? "")
|
||||||
|
.split(separator: "\n")
|
||||||
|
XCTAssertEqual(calls.filter { $0 == "-v" }.count, 1,
|
||||||
|
"lpstat -v must be spawned exactly once: \(calls)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
import Foundation
|
|
||||||
@testable import ICCeryCore
|
|
||||||
|
|
||||||
/// Issue 15 — `lp` argv goldens (docs/11 `build_lp_args`).
|
|
||||||
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
|
|
||||||
/// flag order, captured-option precedence, and sanitisation.
|
|
||||||
final class LpArgsTests: XCTestCase {
|
|
||||||
|
|
||||||
private let tiff = "/tmp/work/target_001.tif"
|
|
||||||
private let queue = "EPSON_XP_55_Series"
|
|
||||||
|
|
||||||
private func build(
|
|
||||||
options: PrintOptions = PrintOptions(),
|
|
||||||
optionKeys: Set<String> = []
|
|
||||||
) throws -> [String] {
|
|
||||||
try LpArgs.build(
|
|
||||||
queue: queue, tiffPath: tiff,
|
|
||||||
options: options, optionKeys: optionKeys)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testHeader() throws {
|
|
||||||
let argv = try build()
|
|
||||||
XCTAssertEqual(Array(argv[0...1]), ["-d", queue])
|
|
||||||
XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"])
|
|
||||||
XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
|
|
||||||
XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
|
|
||||||
XCTAssertEqual(argv.last, tiff)
|
|
||||||
XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" })
|
|
||||||
}
|
|
||||||
|
|
||||||
func testNeverRaw() throws {
|
|
||||||
let argv = try build(options: PrintOptions(
|
|
||||||
cupsOptions: "raw=true MediaType=Photo"))
|
|
||||||
for (i, arg) in argv.enumerated() where arg == "-o" {
|
|
||||||
XCTAssertNotEqual(argv[i + 1], "raw")
|
|
||||||
XCTAssertNotEqual(argv[i + 1], "raw=true")
|
|
||||||
}
|
|
||||||
XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") })
|
|
||||||
XCTAssertTrue(argv.contains("MediaType=Photo"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCapturedReplay() throws {
|
|
||||||
let argv = try build(options: PrintOptions(
|
|
||||||
cupsOptions: "InputSlot=Rear MediaType=Photo"))
|
|
||||||
let rear = argv.firstIndex(of: "InputSlot=Rear")!
|
|
||||||
let apFirst = argv.firstIndex(of:
|
|
||||||
"AP_ColorMatchingMode=AP_ApplicationColorMatching")!
|
|
||||||
XCTAssertTrue(rear > apFirst)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCapturedWinsMedia() throws {
|
|
||||||
let argv = try build(
|
|
||||||
options: PrintOptions(
|
|
||||||
mediaType: "Plain",
|
|
||||||
cupsOptions: "MediaType=Glossy"),
|
|
||||||
optionKeys: ["MediaType"])
|
|
||||||
XCTAssertTrue(argv.contains("MediaType=Glossy"))
|
|
||||||
XCTAssertFalse(argv.contains("MediaType=Plain"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMediaDerived() throws {
|
|
||||||
let argv = try build(
|
|
||||||
options: PrintOptions(mediaType: "SemiGloss"),
|
|
||||||
optionKeys: ["CNIJMediaType", "MediaType"])
|
|
||||||
// CNIJMediaType wins over MediaType in detection order.
|
|
||||||
XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss"))
|
|
||||||
XCTAssertFalse(argv.contains("MediaType=SemiGloss"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testBypassRules() throws {
|
|
||||||
let withBypass = try build(
|
|
||||||
optionKeys: ["EPIJ_CMat"])
|
|
||||||
XCTAssertTrue(withBypass.contains("EPIJ_CMat=3"))
|
|
||||||
|
|
||||||
let captured = try build(
|
|
||||||
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
|
|
||||||
optionKeys: ["EPIJ_CMat"])
|
|
||||||
// Captured value kept, detection not re-applied.
|
|
||||||
XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func testOrientation() throws {
|
|
||||||
XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait"))
|
|
||||||
.contains("orientation-requested=3"))
|
|
||||||
XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape"))
|
|
||||||
.contains("orientation-requested=4"))
|
|
||||||
let capturedOrients = try build(options: PrintOptions(
|
|
||||||
orientation: "landscape",
|
|
||||||
cupsOptions: "orientation-requested=5"))
|
|
||||||
XCTAssertFalse(capturedOrients.contains("orientation-requested=4"))
|
|
||||||
XCTAssertTrue(capturedOrients.contains("orientation-requested=5"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPageSize() throws {
|
|
||||||
XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4"))
|
|
||||||
.contains("PageSize=A4"))
|
|
||||||
let capturedSize = try build(options: PrintOptions(
|
|
||||||
paperSize: "A4", cupsOptions: "PageSize=Letter"))
|
|
||||||
XCTAssertFalse(capturedSize.contains("PageSize=A4"))
|
|
||||||
XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - #183 print quality
|
|
||||||
|
|
||||||
func testQualityDerived() throws {
|
|
||||||
let argv = try build(
|
|
||||||
options: PrintOptions(
|
|
||||||
orientation: "portrait", mediaType: "Photo", quality: "305"),
|
|
||||||
optionKeys: ["EPIJ_Qual", "MediaType"])
|
|
||||||
XCTAssertTrue(argv.contains("EPIJ_Qual=305"))
|
|
||||||
// Emit order: after the media option, before orientation.
|
|
||||||
let media = argv.firstIndex(of: "MediaType=Photo")!
|
|
||||||
let quality = argv.firstIndex(of: "EPIJ_Qual=305")!
|
|
||||||
let orient = argv.firstIndex(of: "orientation-requested=3")!
|
|
||||||
XCTAssertTrue(media < quality && quality < orient)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testQualityDetectedKey() throws {
|
|
||||||
// The detected queue key is used, not a hardcoded one.
|
|
||||||
let argv = try build(
|
|
||||||
options: PrintOptions(quality: "High"),
|
|
||||||
optionKeys: ["cupsPrintQuality"])
|
|
||||||
XCTAssertTrue(argv.contains("cupsPrintQuality=High"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCapturedWinsQuality() throws {
|
|
||||||
let argv = try build(
|
|
||||||
options: PrintOptions(
|
|
||||||
quality: "303",
|
|
||||||
cupsOptions: "EPIJ_Qual=308"),
|
|
||||||
optionKeys: ["EPIJ_Qual"])
|
|
||||||
XCTAssertTrue(argv.contains("EPIJ_Qual=308"))
|
|
||||||
XCTAssertFalse(argv.contains("EPIJ_Qual=303"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCapturedQualityCaseInsensitiveDedup() throws {
|
|
||||||
let argv = try build(
|
|
||||||
options: PrintOptions(
|
|
||||||
quality: "303",
|
|
||||||
cupsOptions: "epij_qual=308"),
|
|
||||||
optionKeys: ["EPIJ_Qual"])
|
|
||||||
XCTAssertFalse(argv.contains("EPIJ_Qual=303"))
|
|
||||||
XCTAssertTrue(argv.contains("epij_qual=308"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testQualityNilNoEmit() throws {
|
|
||||||
let argv = try build(
|
|
||||||
options: PrintOptions(mediaType: "Photo"),
|
|
||||||
optionKeys: ["EPIJ_Qual", "MediaType"])
|
|
||||||
XCTAssertFalse(argv.contains { $0.hasPrefix("EPIJ_Qual=") })
|
|
||||||
// No quality key on the queue → no emit either.
|
|
||||||
let noKey = try build(
|
|
||||||
options: PrintOptions(quality: "303"),
|
|
||||||
optionKeys: ["MediaType"])
|
|
||||||
XCTAssertFalse(noKey.contains { $0.hasPrefix("EPIJ_Qual=") })
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSanitise() throws {
|
|
||||||
XCTAssertThrowsError(try build(options: PrintOptions(
|
|
||||||
cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in
|
|
||||||
XCTAssertTrue(error is LpArgsError)
|
|
||||||
}
|
|
||||||
XCTAssertThrowsError(try build(options: PrintOptions(
|
|
||||||
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in
|
|
||||||
XCTAssertTrue(error is LpArgsError)
|
|
||||||
}
|
|
||||||
XCTAssertThrowsError(try build(options: PrintOptions(
|
|
||||||
cupsOptions: "InputSlot=$(whoami)"))) { error in
|
|
||||||
XCTAssertTrue(error is LpArgsError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
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: - 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import AppKit
|
||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #201 Phase 3 — real `NSPrintOperation` with
|
||||||
|
/// `jobDisposition = .save`: proves the canvas paginates at paper size,
|
||||||
|
/// draws the raster 1:1 at the top-left, and survives end-to-end
|
||||||
|
/// without a printer. Skippable via `ICCERY_SKIP_PRINT_PDF=1` for
|
||||||
|
/// hosts where the print system cannot run (R8).
|
||||||
|
@MainActor
|
||||||
|
final class NativeSpoolPDFTests: XCTestCase {
|
||||||
|
|
||||||
|
private var tempRoot: URL!
|
||||||
|
|
||||||
|
/// A4 in points.
|
||||||
|
private let paperSize = NSSize(width: 595.28, height: 841.89)
|
||||||
|
/// Known top-left patch colour — solid fill across the fixture.
|
||||||
|
private let patchBytes: [UInt8] = [230, 40, 50]
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
tempRoot = try TargetTestFixtures.makeTempDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
try? FileManager.default.removeItem(at: tempRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSavePDFDrawsPagesOneToOne() throws {
|
||||||
|
try XCTSkipIf(
|
||||||
|
ProcessInfo.processInfo.environment["ICCERY_SKIP_PRINT_PDF"]
|
||||||
|
== "1",
|
||||||
|
"ICCERY_SKIP_PRINT_PDF=1 — print system unavailable")
|
||||||
|
|
||||||
|
// 72×72 px @ 72 dpi → a 72×72 pt solid block at the top-left.
|
||||||
|
let tiff = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 72, height: 72), dpi: 72,
|
||||||
|
components: 3, bitsPerComponent: 8,
|
||||||
|
pixelBytes: patchBytes, in: tempRoot)
|
||||||
|
let raster = try TargetRasterLoader.load(tiff: tiff)
|
||||||
|
|
||||||
|
let pdfURL = tempRoot.appendingPathComponent("spool.pdf")
|
||||||
|
let info = makeSaveInfo(pdfURL: pdfURL)
|
||||||
|
|
||||||
|
let canvas = TargetPageCanvasView(
|
||||||
|
pages: [raster, raster], paperSize: paperSize)
|
||||||
|
let operation = NSPrintOperation(view: canvas, printInfo: info)
|
||||||
|
operation.showsPrintPanel = false
|
||||||
|
operation.showsProgressPanel = false
|
||||||
|
operation.canSpawnSeparateThread = false
|
||||||
|
|
||||||
|
XCTAssertTrue(operation.run(),
|
||||||
|
"NSPrintOperation.save failed")
|
||||||
|
XCTAssertTrue(
|
||||||
|
FileManager.default.fileExists(atPath: pdfURL.path))
|
||||||
|
|
||||||
|
let document = try XCTUnwrap(
|
||||||
|
CGPDFDocument(pdfURL as CFURL))
|
||||||
|
XCTAssertEqual(document.numberOfPages, 2)
|
||||||
|
|
||||||
|
let page = try XCTUnwrap(document.page(at: 1))
|
||||||
|
let mediaBox = page.getBoxRect(.mediaBox)
|
||||||
|
XCTAssertEqual(mediaBox.width, paperSize.width, accuracy: 0.5)
|
||||||
|
XCTAssertEqual(mediaBox.height, paperSize.height, accuracy: 0.5)
|
||||||
|
|
||||||
|
try assertTopLeftPatch(on: page, mediaBox: mediaBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the page at 1 px/pt and check the fixture's solid block
|
||||||
|
/// landed at the top-left at its point size — the coarse 1:1 +
|
||||||
|
/// no-scaling check.
|
||||||
|
private func assertTopLeftPatch(
|
||||||
|
on page: CGPDFPage, mediaBox: CGRect
|
||||||
|
) throws {
|
||||||
|
let width = Int(ceil(mediaBox.width))
|
||||||
|
let height = Int(ceil(mediaBox.height))
|
||||||
|
let rep = try renderPageToBitmap(page, mediaBox: mediaBox)
|
||||||
|
|
||||||
|
func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) {
|
||||||
|
let color = try XCTUnwrap(
|
||||||
|
rep.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB))
|
||||||
|
return (color.redComponent,
|
||||||
|
color.greenComponent,
|
||||||
|
color.blueComponent)
|
||||||
|
}
|
||||||
|
func isPatch(_ p: (Double, Double, Double)) -> Bool {
|
||||||
|
abs(p.0 - Double(patchBytes[0]) / 255) < 0.04
|
||||||
|
&& abs(p.1 - Double(patchBytes[1]) / 255) < 0.04
|
||||||
|
&& abs(p.2 - Double(patchBytes[2]) / 255) < 0.04
|
||||||
|
}
|
||||||
|
|
||||||
|
// The print system insets the view by the queue's unprintable
|
||||||
|
// margin (R10) — host-dependent, ~18 pt on this machine — so
|
||||||
|
// the block's anchor is the painted region's top-left, not
|
||||||
|
// (0,0). Find the painted origin by the first opaque pixel.
|
||||||
|
var originX = width, originY = height
|
||||||
|
for y in 0..<height {
|
||||||
|
for x in 0..<width
|
||||||
|
where (rep.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.5 {
|
||||||
|
originX = min(originX, x)
|
||||||
|
originY = min(originY, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertLessThan(originX, width, "PDF page rendered empty")
|
||||||
|
XCTAssertLessThan(originY, height, "PDF page rendered empty")
|
||||||
|
|
||||||
|
// 10 px inside the 72 pt block on each axis → patch colour.
|
||||||
|
XCTAssertTrue(try isPatch(pixel(originX + 10, originY + 10)),
|
||||||
|
"top-left block is not the fixture colour")
|
||||||
|
|
||||||
|
// Measure the block's painted width on a row through it:
|
||||||
|
// 72 pt at 1 px/pt — the 1:1 / no-scaling assertion.
|
||||||
|
var blockWidth = 0
|
||||||
|
var x = originX
|
||||||
|
while x < width,
|
||||||
|
isPatch(try pixel(x, originY + 10)) {
|
||||||
|
blockWidth += 1
|
||||||
|
x += 1
|
||||||
|
}
|
||||||
|
XCTAssertEqual(blockWidth, 72, accuracy: 2)
|
||||||
|
|
||||||
|
// Past the block edge the canvas's white page fill shows.
|
||||||
|
let outside = try pixel(originX + 80, originY + 80)
|
||||||
|
XCTAssertEqual(outside.0, 1, accuracy: 0.04)
|
||||||
|
XCTAssertEqual(outside.1, 1, accuracy: 0.04)
|
||||||
|
XCTAssertEqual(outside.2, 1, accuracy: 0.04)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #211 — a top/bottom banded block proves the page is drawn
|
||||||
|
/// upright AND anchored at the paper's top edge: a mirrored draw
|
||||||
|
/// swaps the bands; a bottom-anchored draw leaves white above the
|
||||||
|
/// block instead of below.
|
||||||
|
func testSavePDFDrawsPageUprightTopAnchored() throws {
|
||||||
|
try XCTSkipIf(
|
||||||
|
ProcessInfo.processInfo.environment["ICCERY_SKIP_PRINT_PDF"]
|
||||||
|
== "1",
|
||||||
|
"ICCERY_SKIP_PRINT_PDF=1 — print system unavailable")
|
||||||
|
|
||||||
|
// 144×144 px @ 72 dpi → a 144×144 pt block, top half red,
|
||||||
|
// bottom half blue.
|
||||||
|
let topBytes: [UInt8] = [230, 40, 50]
|
||||||
|
let bottomBytes: [UInt8] = [40, 50, 230]
|
||||||
|
let tiff = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 144, height: 144), dpi: 72,
|
||||||
|
components: 3, bitsPerComponent: 8,
|
||||||
|
pixelBytes: topBytes,
|
||||||
|
bottomHalfPixelBytes: bottomBytes, in: tempRoot)
|
||||||
|
let raster = try TargetRasterLoader.load(tiff: tiff)
|
||||||
|
|
||||||
|
let pdfURL = tempRoot.appendingPathComponent("bands.pdf")
|
||||||
|
let info = makeSaveInfo(pdfURL: pdfURL)
|
||||||
|
let canvas = TargetPageCanvasView(
|
||||||
|
pages: [raster], paperSize: paperSize)
|
||||||
|
let operation = NSPrintOperation(view: canvas, printInfo: info)
|
||||||
|
operation.showsPrintPanel = false
|
||||||
|
operation.showsProgressPanel = false
|
||||||
|
operation.canSpawnSeparateThread = false
|
||||||
|
XCTAssertTrue(operation.run(),
|
||||||
|
"NSPrintOperation.save failed")
|
||||||
|
|
||||||
|
let document = try XCTUnwrap(
|
||||||
|
CGPDFDocument(pdfURL as CFURL))
|
||||||
|
let page = try XCTUnwrap(document.page(at: 1))
|
||||||
|
let mediaBox = page.getBoxRect(.mediaBox)
|
||||||
|
let rep = try renderPageToBitmap(page, mediaBox: mediaBox)
|
||||||
|
|
||||||
|
func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) {
|
||||||
|
let color = try XCTUnwrap(
|
||||||
|
rep.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB))
|
||||||
|
return (color.redComponent,
|
||||||
|
color.greenComponent,
|
||||||
|
color.blueComponent)
|
||||||
|
}
|
||||||
|
func matches(
|
||||||
|
_ p: (Double, Double, Double), _ rgb: [UInt8]
|
||||||
|
) -> Bool {
|
||||||
|
abs(p.0 - Double(rgb[0]) / 255) < 0.04
|
||||||
|
&& abs(p.1 - Double(rgb[1]) / 255) < 0.04
|
||||||
|
&& abs(p.2 - Double(rgb[2]) / 255) < 0.04
|
||||||
|
}
|
||||||
|
|
||||||
|
// Painted region = the page fill inset by the queue's
|
||||||
|
// unprintable margin (R10); minimum opaque y is its top edge
|
||||||
|
// in the rendered bitmap (same convention as the 1:1 test).
|
||||||
|
let width = Int(ceil(mediaBox.width))
|
||||||
|
let height = Int(ceil(mediaBox.height))
|
||||||
|
var topX = width, topY = height
|
||||||
|
for y in 0..<height {
|
||||||
|
for x in 0..<width
|
||||||
|
where (rep.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.5 {
|
||||||
|
topX = min(topX, x)
|
||||||
|
topY = min(topY, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertLessThan(topY, height, "PDF page rendered empty")
|
||||||
|
|
||||||
|
// The 144 pt block hangs from the page's top edge: top-band
|
||||||
|
// colour for the first 72 pt, bottom-band colour for the
|
||||||
|
// next 72 pt, then the white page fill.
|
||||||
|
XCTAssertTrue(
|
||||||
|
try matches(pixel(topX + 10, topY + 10), topBytes),
|
||||||
|
"block top is not the top-band colour — mirrored draw?")
|
||||||
|
XCTAssertTrue(
|
||||||
|
try matches(pixel(topX + 10, topY + 82), bottomBytes),
|
||||||
|
"block bottom is not the bottom-band colour — mirrored draw?")
|
||||||
|
XCTAssertTrue(
|
||||||
|
try matches(pixel(topX + 10, topY + 134), bottomBytes),
|
||||||
|
"block does not reach 144 pt below the painted top edge")
|
||||||
|
let below = try pixel(topX + 10, topY + 160)
|
||||||
|
XCTAssertEqual(below.0, 1, accuracy: 0.04)
|
||||||
|
XCTAssertEqual(below.1, 1, accuracy: 0.04)
|
||||||
|
XCTAssertEqual(below.2, 1, accuracy: 0.04)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print settings shared by the save-PDF harness tests: A4, zero
|
||||||
|
/// margins, `.clip` pagination, 1:1, no centring, `.save` to
|
||||||
|
/// `pdfURL`.
|
||||||
|
private func makeSaveInfo(pdfURL: URL) -> NSPrintInfo {
|
||||||
|
let info = NSPrintInfo()
|
||||||
|
info.paperSize = paperSize
|
||||||
|
info.orientation = .portrait
|
||||||
|
info.topMargin = 0
|
||||||
|
info.bottomMargin = 0
|
||||||
|
info.leftMargin = 0
|
||||||
|
info.rightMargin = 0
|
||||||
|
info.horizontalPagination = .clip
|
||||||
|
info.verticalPagination = .clip
|
||||||
|
info.scalingFactor = 1.0
|
||||||
|
info.isHorizontallyCentered = false
|
||||||
|
info.isVerticallyCentered = false
|
||||||
|
info.jobDisposition = .save
|
||||||
|
info.dictionary()[
|
||||||
|
NSPrintInfo.AttributeKey.jobSavingURL] = pdfURL
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a saved-PDF page into a bitmap at 1 px/pt.
|
||||||
|
private func renderPageToBitmap(
|
||||||
|
_ page: CGPDFPage, mediaBox: CGRect
|
||||||
|
) throws -> NSBitmapImageRep {
|
||||||
|
let width = Int(ceil(mediaBox.width))
|
||||||
|
let height = Int(ceil(mediaBox.height))
|
||||||
|
let rep = try XCTUnwrap(NSBitmapImageRep(
|
||||||
|
bitmapDataPlanes: nil,
|
||||||
|
pixelsWide: width,
|
||||||
|
pixelsHigh: height,
|
||||||
|
bitsPerSample: 8,
|
||||||
|
samplesPerPixel: 4,
|
||||||
|
hasAlpha: true,
|
||||||
|
isPlanar: false,
|
||||||
|
colorSpaceName: .deviceRGB,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
bitsPerPixel: 0))
|
||||||
|
let context = try XCTUnwrap(
|
||||||
|
NSGraphicsContext(bitmapImageRep: rep))
|
||||||
|
let cg = context.cgContext
|
||||||
|
let transform = page.getDrawingTransform(
|
||||||
|
.mediaBox,
|
||||||
|
rect: CGRect(x: 0, y: 0, width: width, height: height),
|
||||||
|
rotate: 0, preserveAspectRatio: true)
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
NSGraphicsContext.current = context
|
||||||
|
cg.concatenate(transform)
|
||||||
|
cg.drawPDFPage(page)
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,33 +5,35 @@ import XCTest
|
|||||||
|
|
||||||
/// Issue #183 — Stage 2 paper-size / quality selection: seeding from
|
/// Issue #183 — Stage 2 paper-size / quality selection: seeding from
|
||||||
/// Stage 1 `pageSize`, the synthetic `Custom.<pt>x<pt>` entry, re-mirror
|
/// Stage 1 `pageSize`, the synthetic `Custom.<pt>x<pt>` entry, re-mirror
|
||||||
/// triggers, and `PrintOptions` wiring into `lp` argv.
|
/// triggers, and `TargetPrintOverrides` wiring into the resolved
|
||||||
/// `lpoptions`/`lp` are mock scripts in the test env's `cups-bin` — no
|
/// ticket writes (#201 — the `RecordingTargetSpooler` seam replaces
|
||||||
|
/// the deleted `lp` argv log).
|
||||||
|
/// `lpoptions` is a mock script in the test env's `cups-bin` — no
|
||||||
/// live CUPS is touched.
|
/// live CUPS is touched.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PrintSessionViewModelTests: XCTestCase {
|
final class PrintSessionViewModelTests: XCTestCase {
|
||||||
|
|
||||||
private var env: TestAppEnvironment!
|
private var env: TestAppEnvironment!
|
||||||
private var lpArgvURL: URL!
|
private var spoolLogURL: URL!
|
||||||
|
|
||||||
override func setUp() async throws {
|
override func setUp() async throws {
|
||||||
env = try TestAppEnvironment.make()
|
env = try TestAppEnvironment.make()
|
||||||
lpArgvURL = env.root.appendingPathComponent("lp-argv.log")
|
spoolLogURL = env.root.appendingPathComponent("spool.log")
|
||||||
try writeCupsFixtures()
|
try writeCupsFixtures()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() async throws {
|
override func tearDown() async throws {
|
||||||
env?.cleanup()
|
env?.cleanup()
|
||||||
env = nil
|
env = nil
|
||||||
lpArgvURL = nil
|
spoolLogURL = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private var binDir: URL {
|
private var binDir: URL {
|
||||||
env.root.appendingPathComponent("cups-bin")
|
env.root.appendingPathComponent("cups-bin")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mock `lpoptions -l` advertises paper sizes + a quality key;
|
/// Mock `lpoptions -l` advertises paper sizes + a quality key —
|
||||||
/// mock `lp` appends its argv to `lpArgvURL` for assertions.
|
/// the queue's option-key roster feeds media/quality detection.
|
||||||
private func writeCupsFixtures() throws {
|
private func writeCupsFixtures() throws {
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: binDir, withIntermediateDirectories: true)
|
at: binDir, withIntermediateDirectories: true)
|
||||||
@@ -56,17 +58,10 @@ final class PrintSessionViewModelTests: XCTestCase {
|
|||||||
printf "printer-info='Mock %s' printer-type=42\\n" "$queue"
|
printf "printer-info='Mock %s' printer-type=42\\n" "$queue"
|
||||||
exit 0
|
exit 0
|
||||||
"""
|
"""
|
||||||
let lp = """
|
let url = binDir.appendingPathComponent("lpoptions")
|
||||||
#!/bin/sh
|
try lpoptions.write(to: url, atomically: true, encoding: .utf8)
|
||||||
printf '%s\\n' "$*" >> "\(lpArgvURL.path)"
|
try FileManager.default.setAttributes(
|
||||||
exit 0
|
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||||
"""
|
|
||||||
for (name, body) in [("lpoptions", lpoptions), ("lp", lp)] {
|
|
||||||
let url = binDir.appendingPathComponent(name)
|
|
||||||
try body.write(to: url, atomically: true, encoding: .utf8)
|
|
||||||
try FileManager.default.setAttributes(
|
|
||||||
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeWorkflow() -> TargetWorkflowViewModel {
|
private func makeWorkflow() -> TargetWorkflowViewModel {
|
||||||
@@ -185,14 +180,17 @@ final class PrintSessionViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: - Spool wiring
|
// MARK: - Spool wiring
|
||||||
|
|
||||||
/// `spool` emits the Stage 2 paper token and quality through
|
/// `spool` resolves the Stage 2 paper token and quality into the
|
||||||
/// `PrintOptions` → `lp` argv (`-o PageSize=`, `-o <qualityKey>=`).
|
/// ticket writes (`PageSize=`, `<qualityKey>=`) recorded by the
|
||||||
|
/// DEBUG spool seam (#201 D8).
|
||||||
func testSpoolPassesPaperTokenAndQuality() async throws {
|
func testSpoolPassesPaperTokenAndQuality() async throws {
|
||||||
let workflow = makeWorkflow()
|
let workflow = makeWorkflow()
|
||||||
workflow.pageSize = .a4
|
workflow.pageSize = .a4
|
||||||
await loadCaps(workflow.print)
|
await loadCaps(workflow.print)
|
||||||
workflow.print.selectedPaperSize = 4 // Letter
|
workflow.print.selectedPaperSize = 4 // Letter
|
||||||
workflow.print.selectedQuality = "301"
|
workflow.print.selectedQuality = "301"
|
||||||
|
workflow.print.spooler = RecordingTargetSpooler(
|
||||||
|
logURL: spoolLogURL)
|
||||||
|
|
||||||
let tiff = env.root.appendingPathComponent("page1.tif")
|
let tiff = env.root.appendingPathComponent("page1.tif")
|
||||||
try Data([0x49, 0x49]).write(to: tiff)
|
try Data([0x49, 0x49]).write(to: tiff)
|
||||||
@@ -208,9 +206,46 @@ final class PrintSessionViewModelTests: XCTestCase {
|
|||||||
pages: [page])
|
pages: [page])
|
||||||
workflow.print.printAllPages(from: result)
|
workflow.print.printAllPages(from: result)
|
||||||
|
|
||||||
let argv = await waitForFile(lpArgvURL)
|
let log = await waitForFile(spoolLogURL)
|
||||||
XCTAssertTrue(argv.contains("PageSize=Letter"), argv)
|
XCTAssertTrue(log.contains("PageSize=Letter"), log)
|
||||||
XCTAssertTrue(argv.contains("EPIJ_Qual=301"), argv)
|
XCTAssertTrue(log.contains("EPIJ_Qual=301"), log)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #201 — the stubbed panel never produces a `PrintTicket`, so a
|
||||||
|
/// spool with `capturedTickets` empty must still resolve every
|
||||||
|
/// Stage 2 write (the default UI-test path).
|
||||||
|
func testSpoolWithoutTicketResolvesStage2Writes() async throws {
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
workflow.pageSize = .a4
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
workflow.print.spooler = RecordingTargetSpooler(
|
||||||
|
logURL: spoolLogURL)
|
||||||
|
XCTAssertTrue(workflow.print.capturedTickets.isEmpty)
|
||||||
|
|
||||||
|
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)
|
||||||
|
let result = PrinttargResult(
|
||||||
|
ti2URL: env.root.appendingPathComponent("target.ti2"),
|
||||||
|
manifest: PrinttargManifest(pages: [page.page]),
|
||||||
|
pages: [page])
|
||||||
|
workflow.print.printAllPages(from: result)
|
||||||
|
|
||||||
|
let log = await waitForFile(spoolLogURL)
|
||||||
|
XCTAssertTrue(log.contains("pages=1"), log)
|
||||||
|
XCTAssertTrue(log.contains("PageSize=A4"), log)
|
||||||
|
XCTAssertTrue(log.contains("EPIJ_Qual=303"), log)
|
||||||
|
XCTAssertTrue(log.contains("MediaType=Stationery"), log)
|
||||||
|
XCTAssertTrue(log.contains("orientation-requested=3"), log)
|
||||||
|
XCTAssertTrue(log.contains(
|
||||||
|
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), log)
|
||||||
|
XCTAssertTrue(log.contains(
|
||||||
|
"PMColorMatchingMode=APCustomColorMatching"), log)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Panel apply-back (stubbed NSPrintPanel)
|
// MARK: - Panel apply-back (stubbed NSPrintPanel)
|
||||||
@@ -373,6 +408,169 @@ 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #201 Phase 2 — `PMTicketBridge.serialise`/`restore` byte
|
||||||
|
/// round-trip, cross-queue refusal (R3), plist fallback, and the
|
||||||
|
/// `takeRetainedValue` ownership canary.
|
||||||
|
@MainActor
|
||||||
|
final class PrintTicketTests: XCTestCase {
|
||||||
|
|
||||||
|
/// The queue a destination `NSPrintInfo` will report as bound —
|
||||||
|
/// the session's current printer, else the Cocoa printer name,
|
||||||
|
/// else a synthetic token for a queue-less environment.
|
||||||
|
private func boundQueue(of printInfo: NSPrintInfo) -> String {
|
||||||
|
PMTicketBridge.currentPrinterID(
|
||||||
|
session: PMTicketBridge.session(printInfo),
|
||||||
|
fallback: "UnboundQueue")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoundTripRetainsVendorValue() throws {
|
||||||
|
let source = NSPrintInfo()
|
||||||
|
XCTAssertTrue(
|
||||||
|
PMTicketBridge.setValue(
|
||||||
|
"305", forKey: "EPIJ_Qual", locked: false,
|
||||||
|
in: PMTicketBridge.settings(source),
|
||||||
|
context: "PrintTicketTests"))
|
||||||
|
|
||||||
|
let target = NSPrintInfo()
|
||||||
|
let ticket = try PMTicketBridge.serialise(
|
||||||
|
source, queue: boundQueue(of: target))
|
||||||
|
try PMTicketBridge.restore(ticket, into: target)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(
|
||||||
|
forKey: "EPIJ_Qual",
|
||||||
|
in: PMTicketBridge.settings(target)),
|
||||||
|
"305")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrintSettingsDataIsXmlPlist() throws {
|
||||||
|
let ticket = try PMTicketBridge.serialise(
|
||||||
|
NSPrintInfo(), queue: "AnyQueue")
|
||||||
|
XCTAssertFalse(ticket.printSettings.isEmpty)
|
||||||
|
XCTAssertFalse(ticket.pageFormat.isEmpty)
|
||||||
|
XCTAssertEqual(
|
||||||
|
String(decoding: ticket.printSettings.prefix(5),
|
||||||
|
as: UTF8.self),
|
||||||
|
"<?xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCrossQueueReplayIsRefused() throws {
|
||||||
|
let target = NSPrintInfo()
|
||||||
|
let bound = boundQueue(of: target)
|
||||||
|
let foreign = "DefinitelyNot_\(bound)"
|
||||||
|
try XCTSkipIf(
|
||||||
|
bound == foreign,
|
||||||
|
"destination resolved to the foreign queue")
|
||||||
|
|
||||||
|
// Sentinel on the destination — a refused restore must leave
|
||||||
|
// the target's settings untouched.
|
||||||
|
XCTAssertTrue(
|
||||||
|
PMTicketBridge.setValue(
|
||||||
|
"999", forKey: "EPIJ_Qual", locked: false,
|
||||||
|
in: PMTicketBridge.settings(target),
|
||||||
|
context: "PrintTicketTests"))
|
||||||
|
|
||||||
|
let source = NSPrintInfo()
|
||||||
|
XCTAssertTrue(
|
||||||
|
PMTicketBridge.setValue(
|
||||||
|
"305", forKey: "EPIJ_Qual", locked: false,
|
||||||
|
in: PMTicketBridge.settings(source),
|
||||||
|
context: "PrintTicketTests"))
|
||||||
|
let ticket = try PMTicketBridge.serialise(
|
||||||
|
source, queue: foreign)
|
||||||
|
|
||||||
|
try PMTicketBridge.restore(ticket, into: target)
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(
|
||||||
|
forKey: "EPIJ_Qual",
|
||||||
|
in: PMTicketBridge.settings(target)),
|
||||||
|
"999")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrintInfoPlistFallbackRoundTrips() throws {
|
||||||
|
let source = NSPrintInfo()
|
||||||
|
// The `com.apple.print.PrintSettings` entry only exists once
|
||||||
|
// something writes into the attribute dictionary — the
|
||||||
|
// ColorSync mirror does this on the real panel path.
|
||||||
|
source.dictionary()[
|
||||||
|
NSPrintInfo.AttributeKey("com.apple.print.PrintSettings")] =
|
||||||
|
["PMColorMatchingMode": "APCustomColorMatching"]
|
||||||
|
as NSDictionary
|
||||||
|
let ticket = try PMTicketBridge.serialise(
|
||||||
|
source, queue: "AnyQueue")
|
||||||
|
let data = try XCTUnwrap(ticket.printInfoPlist)
|
||||||
|
let object = try PropertyListSerialization.propertyList(
|
||||||
|
from: data, options: [], format: nil)
|
||||||
|
let dictionary = try XCTUnwrap(object as? [String: Any])
|
||||||
|
XCTAssertNotNil(
|
||||||
|
dictionary["com.apple.print.PrintSettings"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 200× serialise loop — a `takeRetainedValue` over/under-release
|
||||||
|
/// canary. Byte counts must stay stable; ASan + Malloc Scribble
|
||||||
|
/// catches an actual imbalance.
|
||||||
|
func testSerialiseIsStableAcrossIterations() throws {
|
||||||
|
let source = NSPrintInfo()
|
||||||
|
var firstCount: Int?
|
||||||
|
for _ in 0..<200 {
|
||||||
|
let ticket = try PMTicketBridge.serialise(
|
||||||
|
source, queue: "AnyQueue")
|
||||||
|
if let first = firstCount {
|
||||||
|
XCTAssertEqual(ticket.printSettings.count, first)
|
||||||
|
} else {
|
||||||
|
firstCount = ticket.printSettings.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertNotNil(firstCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import AppKit
|
||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #201 Phase 3 — `TargetPageCanvasView` bottom-up page stacking,
|
||||||
|
/// snapped top-left anchoring, and the draw-time interpolation /
|
||||||
|
/// antialias contract asserted via `drawProbe`.
|
||||||
|
@MainActor
|
||||||
|
final class TargetCanvasGeometryTests: XCTestCase {
|
||||||
|
|
||||||
|
/// A4-ish paper, points.
|
||||||
|
private let paperSize = NSSize(width: 595.28, height: 841.89)
|
||||||
|
|
||||||
|
private func raster(
|
||||||
|
pixelWidth: Int = 2480,
|
||||||
|
pixelHeight: Int = 3508,
|
||||||
|
dpi: Double = 300
|
||||||
|
) -> TargetPageRaster {
|
||||||
|
let image = TargetTestFixtures.makeImage(
|
||||||
|
px: CGSize(width: 8, height: 8),
|
||||||
|
components: 3, bitsPerComponent: 8)!
|
||||||
|
return TargetPageRaster(
|
||||||
|
sourceURL: URL(fileURLWithPath: "/fixture.tiff"),
|
||||||
|
cgImage: image,
|
||||||
|
pixelWidth: pixelWidth,
|
||||||
|
pixelHeight: pixelHeight,
|
||||||
|
dpiX: dpi, dpiY: dpi,
|
||||||
|
componentCount: 3,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
expectedWidthMm: nil,
|
||||||
|
expectedHeightMm: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page stacking
|
||||||
|
|
||||||
|
/// Bottom-up space: page 1 is the BOTTOM band of the frame — the
|
||||||
|
/// non-flipped pagination convention (#211).
|
||||||
|
func testRectForPageStacksBottomUp() {
|
||||||
|
let view = TargetPageCanvasView(
|
||||||
|
pages: [raster(), raster(), raster()],
|
||||||
|
paperSize: paperSize)
|
||||||
|
XCTAssertFalse(view.isFlipped)
|
||||||
|
|
||||||
|
let page1 = view.rectForPage(1)
|
||||||
|
let page3 = view.rectForPage(3)
|
||||||
|
XCTAssertEqual(page1.minY, 0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(page3.minY, 2 * paperSize.height, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(page1.size, paperSize)
|
||||||
|
XCTAssertEqual(page3.size, paperSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKnowsPageRangeAndFrame() {
|
||||||
|
let view = TargetPageCanvasView(
|
||||||
|
pages: [raster(), raster()],
|
||||||
|
paperSize: paperSize)
|
||||||
|
var range = NSRange()
|
||||||
|
XCTAssertTrue(view.knowsPageRange(&range))
|
||||||
|
XCTAssertEqual(range, NSRange(location: 1, length: 2))
|
||||||
|
XCTAssertEqual(
|
||||||
|
view.frame.height, 2 * paperSize.height, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(view.frame.width, paperSize.width, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Destination rect
|
||||||
|
|
||||||
|
func testDestinationRectAnchorsTopLeftAndSnaps() {
|
||||||
|
let view = TargetPageCanvasView(
|
||||||
|
pages: [raster(), raster()],
|
||||||
|
paperSize: paperSize)
|
||||||
|
|
||||||
|
// Near-full-page raster: in bottom-up space the page's top
|
||||||
|
// edge is `maxY`, so the top-anchored origin is ~0.03 pt
|
||||||
|
// negative — the overflow clips at the BOTTOM of the sheet.
|
||||||
|
let first = view.destinationRect(forPage: 1)
|
||||||
|
XCTAssertEqual(first.origin.x, 0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(first.origin.y, -0.03, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(first.width, 595.2, accuracy: 0.01)
|
||||||
|
XCTAssertEqual(first.height, 841.92, accuracy: 0.01)
|
||||||
|
|
||||||
|
let second = view.destinationRect(forPage: 2)
|
||||||
|
XCTAssertEqual(
|
||||||
|
second.origin.y,
|
||||||
|
2 * paperSize.height - first.height, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(second.size, first.size)
|
||||||
|
|
||||||
|
// A raster smaller than the paper hangs from the page's TOP
|
||||||
|
// edge — minY would anchor it at the bottom (#211 follow-up).
|
||||||
|
let small = TargetPageCanvasView(
|
||||||
|
pages: [raster(pixelWidth: 72, pixelHeight: 72, dpi: 72)],
|
||||||
|
paperSize: paperSize)
|
||||||
|
let smallRect = small.destinationRect(forPage: 1)
|
||||||
|
XCTAssertEqual(smallRect.origin.x, 0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(
|
||||||
|
smallRect.origin.y, paperSize.height - 72, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(smallRect.size, CGSize(width: 72, height: 72))
|
||||||
|
|
||||||
|
for rect in [first, second, smallRect] {
|
||||||
|
XCTAssertEqual(
|
||||||
|
rect.origin.x * 1000,
|
||||||
|
(rect.origin.x * 1000).rounded(),
|
||||||
|
accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(
|
||||||
|
rect.origin.y * 1000,
|
||||||
|
(rect.origin.y * 1000).rounded(),
|
||||||
|
accuracy: 1e-9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Draw flags
|
||||||
|
|
||||||
|
/// Render the canvas into an offscreen bitmap context and assert —
|
||||||
|
/// from inside `drawProbe` — that interpolation and antialiasing
|
||||||
|
/// are off and the drawn rect is `destinationRect(forPage:)`.
|
||||||
|
func testDrawDisablesInterpolationAndAntialias() throws {
|
||||||
|
let pages = [raster(), raster()]
|
||||||
|
let view = TargetPageCanvasView(
|
||||||
|
pages: pages, paperSize: paperSize)
|
||||||
|
|
||||||
|
let rep = try XCTUnwrap(NSBitmapImageRep(
|
||||||
|
bitmapDataPlanes: nil,
|
||||||
|
pixelsWide: Int(ceil(view.frame.width)),
|
||||||
|
pixelsHigh: Int(ceil(view.frame.height)),
|
||||||
|
bitsPerSample: 8,
|
||||||
|
samplesPerPixel: 4,
|
||||||
|
hasAlpha: true,
|
||||||
|
isPlanar: false,
|
||||||
|
colorSpaceName: .deviceRGB,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
bitsPerPixel: 0))
|
||||||
|
let context = try XCTUnwrap(
|
||||||
|
NSGraphicsContext(bitmapImageRep: rep))
|
||||||
|
|
||||||
|
var probed: [(page: Int, rect: NSRect)] = []
|
||||||
|
view.drawProbe = { ctx, page, rect in
|
||||||
|
XCTAssertEqual(ctx.imageInterpolation, .none,
|
||||||
|
"page \(page): interpolation not .none")
|
||||||
|
XCTAssertFalse(ctx.shouldAntialias,
|
||||||
|
"page \(page): antialiasing still on")
|
||||||
|
probed.append((page, rect))
|
||||||
|
}
|
||||||
|
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
NSGraphicsContext.current = context
|
||||||
|
view.draw(view.bounds)
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
|
||||||
|
XCTAssertEqual(probed.count, pages.count)
|
||||||
|
for (page, rect) in probed {
|
||||||
|
XCTAssertEqual(
|
||||||
|
rect, view.destinationRect(forPage: page),
|
||||||
|
"page \(page): probe rect mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dirty rect covering only page 2's band must draw only page 2.
|
||||||
|
func testDrawSkipsPagesOutsideDirtyRect() throws {
|
||||||
|
let view = TargetPageCanvasView(
|
||||||
|
pages: [raster(), raster(), raster()],
|
||||||
|
paperSize: paperSize)
|
||||||
|
|
||||||
|
let rep = try XCTUnwrap(NSBitmapImageRep(
|
||||||
|
bitmapDataPlanes: nil,
|
||||||
|
pixelsWide: Int(ceil(view.frame.width)),
|
||||||
|
pixelsHigh: Int(ceil(view.frame.height)),
|
||||||
|
bitsPerSample: 8,
|
||||||
|
samplesPerPixel: 4,
|
||||||
|
hasAlpha: true,
|
||||||
|
isPlanar: false,
|
||||||
|
colorSpaceName: .deviceRGB,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
bitsPerPixel: 0))
|
||||||
|
let context = try XCTUnwrap(
|
||||||
|
NSGraphicsContext(bitmapImageRep: rep))
|
||||||
|
|
||||||
|
var probedPages: [Int] = []
|
||||||
|
view.drawProbe = { _, page, _ in probedPages.append(page) }
|
||||||
|
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
NSGraphicsContext.current = context
|
||||||
|
view.draw(view.rectForPage(2))
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
|
||||||
|
XCTAssertEqual(probedPages, [2])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// Shared TIFF fixture builder for the #201 raster / canvas / PDF
|
||||||
|
/// harness tests. Writes real TIFFs via `CGImageDestination` so the
|
||||||
|
/// loader exercises the same decode path as production.
|
||||||
|
enum TargetTestFixtures {
|
||||||
|
|
||||||
|
enum FixtureError: Error {
|
||||||
|
case imageNotCreated
|
||||||
|
case destinationNotCreated
|
||||||
|
case finalizeFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fresh per-test directory under the temp root.
|
||||||
|
static func makeTempDirectory() throws -> URL {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-raster-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url, withIntermediateDirectories: true)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A solid-fill (or repeating-pattern) `CGImage` with a device
|
||||||
|
/// colour space for 1/3/4 components at 8 or 16 bpc. `alpha: true`
|
||||||
|
/// appends one alpha sample per pixel (`last`). `pixelBytes` is the
|
||||||
|
/// repeating per-pixel pattern (big-endian for 16 bpc).
|
||||||
|
/// `bottomHalfPixelBytes`, when set, is the pattern for the bottom
|
||||||
|
/// half of the rows — a vertical asymmetry that lets tests catch a
|
||||||
|
/// mirrored draw (#211).
|
||||||
|
static func makeImage(
|
||||||
|
px: CGSize,
|
||||||
|
components: Int,
|
||||||
|
bitsPerComponent: Int,
|
||||||
|
alpha: Bool = false,
|
||||||
|
pixelBytes: [UInt8]? = nil,
|
||||||
|
bottomHalfPixelBytes: [UInt8]? = nil
|
||||||
|
) -> CGImage? {
|
||||||
|
let width = Int(px.width)
|
||||||
|
let height = Int(px.height)
|
||||||
|
let bytesPerComponent = bitsPerComponent / 8
|
||||||
|
let samples = components + (alpha ? 1 : 0)
|
||||||
|
let bytesPerPixel = samples * bytesPerComponent
|
||||||
|
let bytesPerRow = width * bytesPerPixel
|
||||||
|
func buildRow(_ pattern: [UInt8]) -> [UInt8] {
|
||||||
|
var row = [UInt8](repeating: 0, count: bytesPerRow)
|
||||||
|
for offset in stride(
|
||||||
|
from: 0, to: bytesPerRow, by: pattern.count) {
|
||||||
|
for (index, byte) in pattern.enumerated()
|
||||||
|
where offset + index < bytesPerRow {
|
||||||
|
row[offset + index] = byte
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
let topRow = buildRow(
|
||||||
|
pixelBytes
|
||||||
|
?? Array(0..<bytesPerPixel).map {
|
||||||
|
UInt8(($0 * 37 + 11) & 0xff)
|
||||||
|
})
|
||||||
|
let bottomRow = buildRow(bottomHalfPixelBytes ?? pixelBytes
|
||||||
|
?? Array(0..<bytesPerPixel).map {
|
||||||
|
UInt8(($0 * 37 + 11) & 0xff)
|
||||||
|
})
|
||||||
|
var bytes = [UInt8]()
|
||||||
|
bytes.reserveCapacity(bytesPerRow * height)
|
||||||
|
for rowIndex in 0..<height {
|
||||||
|
bytes.append(
|
||||||
|
contentsOf: rowIndex < height / 2 ? topRow : bottomRow)
|
||||||
|
}
|
||||||
|
let data = Data(bytes)
|
||||||
|
|
||||||
|
let space: CGColorSpace
|
||||||
|
switch components {
|
||||||
|
case 1: space = CGColorSpaceCreateDeviceGray()
|
||||||
|
case 3: space = CGColorSpaceCreateDeviceRGB()
|
||||||
|
case 4: space = CGColorSpaceCreateDeviceCMYK()
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
var info = CGBitmapInfo()
|
||||||
|
if bitsPerComponent == 16 {
|
||||||
|
info.insert(.byteOrder16Big)
|
||||||
|
}
|
||||||
|
if alpha {
|
||||||
|
info = CGBitmapInfo(
|
||||||
|
rawValue: info.rawValue | CGImageAlphaInfo.last.rawValue)
|
||||||
|
}
|
||||||
|
guard let provider = CGDataProvider(data: data as CFData)
|
||||||
|
else { return nil }
|
||||||
|
return CGImage(
|
||||||
|
width: width, height: height,
|
||||||
|
bitsPerComponent: bitsPerComponent,
|
||||||
|
bitsPerPixel: bitsPerComponent * samples,
|
||||||
|
bytesPerRow: bytesPerRow,
|
||||||
|
space: space,
|
||||||
|
bitmapInfo: info,
|
||||||
|
provider: provider,
|
||||||
|
decode: nil,
|
||||||
|
shouldInterpolate: false,
|
||||||
|
intent: .defaultIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `makeImage` output to a single-page TIFF. `dpi` nil
|
||||||
|
/// produces a TIFF with no resolution tags (the 72-fallback path).
|
||||||
|
/// `bottomHalfPixelBytes` passes through to `makeImage` (#211).
|
||||||
|
static func makeTIFF(
|
||||||
|
px: CGSize,
|
||||||
|
dpi: Double?,
|
||||||
|
components: Int = 3,
|
||||||
|
bitsPerComponent: Int = 8,
|
||||||
|
alpha: Bool = false,
|
||||||
|
pixelBytes: [UInt8]? = nil,
|
||||||
|
bottomHalfPixelBytes: [UInt8]? = nil,
|
||||||
|
in directory: URL
|
||||||
|
) throws -> URL {
|
||||||
|
guard let image = makeImage(
|
||||||
|
px: px, components: components,
|
||||||
|
bitsPerComponent: bitsPerComponent,
|
||||||
|
alpha: alpha, pixelBytes: pixelBytes,
|
||||||
|
bottomHalfPixelBytes: bottomHalfPixelBytes)
|
||||||
|
else { throw FixtureError.imageNotCreated }
|
||||||
|
let url = directory.appendingPathComponent(
|
||||||
|
"fixture-\(UUID().uuidString).tiff")
|
||||||
|
guard let destination = CGImageDestinationCreateWithURL(
|
||||||
|
url as CFURL, UTType.tiff.identifier as CFString, 1, nil)
|
||||||
|
else { throw FixtureError.destinationNotCreated }
|
||||||
|
var properties: [CFString: Any] = [:]
|
||||||
|
if let dpi {
|
||||||
|
properties[kCGImagePropertyDPIWidth] = dpi
|
||||||
|
properties[kCGImagePropertyDPIHeight] = dpi
|
||||||
|
properties[kCGImagePropertyTIFFDictionary] = [
|
||||||
|
kCGImagePropertyTIFFXResolution: dpi,
|
||||||
|
kCGImagePropertyTIFFYResolution: dpi,
|
||||||
|
kCGImagePropertyTIFFResolutionUnit: 2, // inches
|
||||||
|
]
|
||||||
|
}
|
||||||
|
CGImageDestinationAddImage(
|
||||||
|
destination, image, properties as CFDictionary)
|
||||||
|
guard CGImageDestinationFinalize(destination)
|
||||||
|
else { throw FixtureError.finalizeFailed }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #201 Phase 3 — `TargetRasterLoader` DPI handling, device
|
||||||
|
/// re-tag (D10), unsupported-layout rejection, and manifest drift (D9).
|
||||||
|
final class TargetRasterTests: XCTestCase {
|
||||||
|
|
||||||
|
private var tempRoot: URL!
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
tempRoot = try TargetTestFixtures.makeTempDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
try? FileManager.default.removeItem(at: tempRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load(_ url: URL) throws -> TargetPageRaster {
|
||||||
|
try TargetRasterLoader.load(tiff: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode index 0 the same way the loader does, for provider-byte
|
||||||
|
/// comparisons that prove the re-tag did not resample.
|
||||||
|
private func decoded(_ url: URL) throws -> CGImage {
|
||||||
|
let source = try XCTUnwrap(
|
||||||
|
CGImageSourceCreateWithURL(url as CFURL, nil))
|
||||||
|
return try XCTUnwrap(
|
||||||
|
CGImageSourceCreateImageAtIndex(source, 0, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - pointSize / physicalMm
|
||||||
|
|
||||||
|
func testPointSizeFromDPI() throws {
|
||||||
|
let url = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 2480, height: 3508), dpi: 300,
|
||||||
|
in: tempRoot)
|
||||||
|
let raster = try load(url)
|
||||||
|
XCTAssertEqual(raster.pointSize.width, 595.2, accuracy: 0.01)
|
||||||
|
XCTAssertEqual(raster.pointSize.height, 841.92, accuracy: 0.01)
|
||||||
|
XCTAssertEqual(raster.physicalMm.width, 209.97, accuracy: 0.01)
|
||||||
|
XCTAssertEqual(raster.physicalMm.height, 297.01, accuracy: 0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPointSizeAt600dpi() throws {
|
||||||
|
let url = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 1200, height: 1800), dpi: 600,
|
||||||
|
in: tempRoot)
|
||||||
|
let raster = try load(url)
|
||||||
|
XCTAssertEqual(raster.pointSize.width, 144, accuracy: 0.01)
|
||||||
|
XCTAssertEqual(raster.pointSize.height, 216, accuracy: 0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingDPIFallsBackTo72() throws {
|
||||||
|
let url = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 100, height: 80), dpi: nil,
|
||||||
|
in: tempRoot)
|
||||||
|
let raster = try load(url)
|
||||||
|
XCTAssertEqual(raster.dpiX, TargetRasterLoader.fallbackDPI)
|
||||||
|
XCTAssertEqual(raster.dpiY, TargetRasterLoader.fallbackDPI)
|
||||||
|
XCTAssertEqual(raster.pointSize.width, 100, accuracy: 0.01)
|
||||||
|
XCTAssertEqual(raster.pointSize.height, 80, accuracy: 0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingFileThrows() {
|
||||||
|
let url = tempRoot.appendingPathComponent("nope.tiff")
|
||||||
|
XCTAssertThrowsError(try load(url)) { error in
|
||||||
|
XCTAssertEqual(
|
||||||
|
error as? TargetRasterError,
|
||||||
|
.tiffMissing(url.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGarbageFileThrowsUndecodable() throws {
|
||||||
|
let url = tempRoot.appendingPathComponent("junk.tiff")
|
||||||
|
try Data([0x00, 0x01, 0x02, 0x03]).write(to: url)
|
||||||
|
XCTAssertThrowsError(try load(url)) { error in
|
||||||
|
XCTAssertEqual(
|
||||||
|
error as? TargetRasterError,
|
||||||
|
.undecodable(url.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - deviceTagged (D10)
|
||||||
|
|
||||||
|
func testDeviceTaggedRGB8() throws {
|
||||||
|
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
|
||||||
|
px: CGSize(width: 16, height: 16),
|
||||||
|
components: 3, bitsPerComponent: 8))
|
||||||
|
let tagged = try XCTUnwrap(
|
||||||
|
TargetRasterLoader.deviceTagged(source))
|
||||||
|
XCTAssertEqual(tagged.colorSpace?.model, .rgb)
|
||||||
|
XCTAssertEqual(tagged.bitsPerComponent, 8)
|
||||||
|
XCTAssertFalse(tagged.shouldInterpolate)
|
||||||
|
XCTAssertEqual(
|
||||||
|
tagged.dataProvider?.data as? Data,
|
||||||
|
source.dataProvider?.data as? Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeviceTaggedCMYK8() throws {
|
||||||
|
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
|
||||||
|
px: CGSize(width: 16, height: 16),
|
||||||
|
components: 4, bitsPerComponent: 8))
|
||||||
|
let tagged = try XCTUnwrap(
|
||||||
|
TargetRasterLoader.deviceTagged(source))
|
||||||
|
XCTAssertEqual(tagged.colorSpace?.model, .cmyk)
|
||||||
|
XCTAssertEqual(tagged.bitsPerComponent, 8)
|
||||||
|
XCTAssertEqual(
|
||||||
|
tagged.dataProvider?.data as? Data,
|
||||||
|
source.dataProvider?.data as? Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeviceTaggedGray8() throws {
|
||||||
|
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
|
||||||
|
px: CGSize(width: 16, height: 16),
|
||||||
|
components: 1, bitsPerComponent: 8))
|
||||||
|
let tagged = try XCTUnwrap(
|
||||||
|
TargetRasterLoader.deviceTagged(source))
|
||||||
|
XCTAssertEqual(tagged.colorSpace?.model, .monochrome)
|
||||||
|
XCTAssertEqual(tagged.bitsPerComponent, 8)
|
||||||
|
XCTAssertEqual(
|
||||||
|
tagged.dataProvider?.data as? Data,
|
||||||
|
source.dataProvider?.data as? Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeviceTaggedRGB16KeepsBitDepth() throws {
|
||||||
|
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
|
||||||
|
px: CGSize(width: 16, height: 16),
|
||||||
|
components: 3, bitsPerComponent: 16))
|
||||||
|
let tagged = try XCTUnwrap(
|
||||||
|
TargetRasterLoader.deviceTagged(source))
|
||||||
|
XCTAssertEqual(tagged.colorSpace?.model, .rgb)
|
||||||
|
XCTAssertEqual(tagged.bitsPerComponent, 16)
|
||||||
|
XCTAssertEqual(
|
||||||
|
tagged.dataProvider?.data as? Data,
|
||||||
|
source.dataProvider?.data as? Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The loaded raster reuses the decoder's provider verbatim — byte
|
||||||
|
/// equality is the no-resample guarantee (R5).
|
||||||
|
func testLoadedRasterSharesDecodedBytes() throws {
|
||||||
|
let url = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 32, height: 24), dpi: 300,
|
||||||
|
in: tempRoot)
|
||||||
|
let raster = try load(url)
|
||||||
|
let decoded = try decoded(url)
|
||||||
|
XCTAssertEqual(
|
||||||
|
raster.cgImage.dataProvider?.data as? Data,
|
||||||
|
decoded.dataProvider?.data as? Data)
|
||||||
|
XCTAssertEqual(raster.cgImage.colorSpace?.model, .rgb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rejections
|
||||||
|
|
||||||
|
func testAlphaTIFFThrowsUnsupportedLayout() throws {
|
||||||
|
let url = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 16, height: 16), dpi: 300,
|
||||||
|
components: 3, alpha: true, in: tempRoot)
|
||||||
|
XCTAssertThrowsError(try load(url)) { error in
|
||||||
|
guard case .unsupportedLayout(_, _, _, let hasAlpha) =
|
||||||
|
error as? TargetRasterError
|
||||||
|
else {
|
||||||
|
return XCTFail("expected unsupportedLayout, got \(error)")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(hasAlpha)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeviceTaggedRejectsFloat() throws {
|
||||||
|
var info = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
|
||||||
|
info.insert(.floatComponents)
|
||||||
|
info.insert(.byteOrder32Little)
|
||||||
|
var bytes = [Float](repeating: 0.5, count: 4 * 4 * 3)
|
||||||
|
let floatImage = bytes.withUnsafeMutableBytes { buffer -> CGImage? in
|
||||||
|
CGImage(
|
||||||
|
width: 4, height: 4,
|
||||||
|
bitsPerComponent: 32, bitsPerPixel: 96,
|
||||||
|
bytesPerRow: 4 * 3 * 4,
|
||||||
|
space: CGColorSpaceCreateDeviceRGB(),
|
||||||
|
bitmapInfo: info,
|
||||||
|
provider: CGDataProvider(data: Data(buffer) as CFData)!,
|
||||||
|
decode: nil, shouldInterpolate: false,
|
||||||
|
intent: .defaultIntent)
|
||||||
|
}
|
||||||
|
bytes = []
|
||||||
|
let image = try XCTUnwrap(floatImage)
|
||||||
|
XCTAssertNil(TargetRasterLoader.deviceTagged(image))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeviceTaggedRejectsIndexed() throws {
|
||||||
|
var table = [UInt8](repeating: 0, count: 256 * 3)
|
||||||
|
let indexed = try XCTUnwrap(CGColorSpace(
|
||||||
|
indexedBaseSpace: CGColorSpaceCreateDeviceRGB(),
|
||||||
|
last: 255, colorTable: &table))
|
||||||
|
let image = try XCTUnwrap(CGImage(
|
||||||
|
width: 8, height: 8,
|
||||||
|
bitsPerComponent: 8, bitsPerPixel: 8,
|
||||||
|
bytesPerRow: 8,
|
||||||
|
space: indexed,
|
||||||
|
bitmapInfo: CGBitmapInfo(),
|
||||||
|
provider: CGDataProvider(
|
||||||
|
data: Data(repeating: 7, count: 64) as CFData)!,
|
||||||
|
decode: nil, shouldInterpolate: false,
|
||||||
|
intent: .defaultIntent))
|
||||||
|
XCTAssertNil(TargetRasterLoader.deviceTagged(image))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No 2-component `CGImage` is fabricatable through public API —
|
||||||
|
/// the rejection lives in `deviceSpace(for:)`, asserted here.
|
||||||
|
func testDeviceSpaceRejectsTwoAndFiveComponents() {
|
||||||
|
XCTAssertNil(TargetRasterLoader.deviceSpace(for: 2))
|
||||||
|
XCTAssertNil(TargetRasterLoader.deviceSpace(for: 0))
|
||||||
|
XCTAssertNil(TargetRasterLoader.deviceSpace(for: 5))
|
||||||
|
XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 1))
|
||||||
|
XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 3))
|
||||||
|
XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manifest drift (D9)
|
||||||
|
|
||||||
|
func testManifestDriftWhenMismatch() throws {
|
||||||
|
let url = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 2480, height: 3508), dpi: 300,
|
||||||
|
in: tempRoot)
|
||||||
|
let raster = try TargetRasterLoader.load(
|
||||||
|
tiff: url, expectedWidthMm: 200, expectedHeightMm: 297)
|
||||||
|
XCTAssertNotNil(raster.manifestDrift)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testManifestDriftNilWhenMatching() throws {
|
||||||
|
let url = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 2480, height: 3508), dpi: 300,
|
||||||
|
in: tempRoot)
|
||||||
|
// 2480 px @300 dpi = 209.97 mm — inside the 0.5 mm band.
|
||||||
|
let raster = try TargetRasterLoader.load(
|
||||||
|
tiff: url, expectedWidthMm: 210, expectedHeightMm: 297)
|
||||||
|
XCTAssertNil(raster.manifestDrift)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #201 Phase 4 — the resolved ticket-write list that replaces
|
||||||
|
/// the `lp` argv goldens: locked order 1–10, D6 (Stage 2 always
|
||||||
|
/// wins), `raw` can never appear.
|
||||||
|
final class TicketWriteResolverTests: XCTestCase {
|
||||||
|
|
||||||
|
private func resolve(
|
||||||
|
overrides: TargetPrintOverrides = TargetPrintOverrides(),
|
||||||
|
optionKeys: Set<String> = []
|
||||||
|
) -> ResolvedTicketWrites {
|
||||||
|
TicketWriteResolver.resolve(
|
||||||
|
overrides: overrides, optionKeys: optionKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func keys(_ r: ResolvedTicketWrites) -> [String] {
|
||||||
|
r.writes.map(\.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func value(
|
||||||
|
_ key: String, in r: ResolvedTicketWrites
|
||||||
|
) -> String? {
|
||||||
|
r.writes.first { $0.key == key }?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both AP_* spellings are always present and locked; all three
|
||||||
|
/// Quartz keys are always present (D2 dual vocabulary).
|
||||||
|
func testColourKeysAlwaysPresent() {
|
||||||
|
let r = resolve()
|
||||||
|
let ap = r.writes.filter {
|
||||||
|
ColorMatchingAttempts.printSettingsKeys.contains($0.key)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(ap.count, 2)
|
||||||
|
XCTAssertTrue(ap.allSatisfy {
|
||||||
|
$0.locked
|
||||||
|
&& $0.value == "AP_ApplicationColorMatching"
|
||||||
|
})
|
||||||
|
XCTAssertEqual(value("PMColorMatchingMode", in: r),
|
||||||
|
"APCustomColorMatching")
|
||||||
|
XCTAssertEqual(value("PMCustomColorMatchingProfile", in: r), "")
|
||||||
|
XCTAssertEqual(
|
||||||
|
value("com.apple.print.PrintSettings.PMColorMatchingMode",
|
||||||
|
in: r),
|
||||||
|
"APCustomColorMatching")
|
||||||
|
// The Quartz mode key is locked; profile + legacy key are not.
|
||||||
|
XCTAssertTrue(r.writes.first { $0.key == "PMColorMatchingMode" }!
|
||||||
|
.locked)
|
||||||
|
XCTAssertFalse(r.writes.first {
|
||||||
|
$0.key == "PMCustomColorMatchingProfile" }!.locked)
|
||||||
|
XCTAssertFalse(r.writes.first {
|
||||||
|
$0.key == "com.apple.print.PrintSettings.PMColorMatchingMode"
|
||||||
|
}!.locked)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `raw` can never appear — there is no `lp` and the resolver has
|
||||||
|
/// no captured-options input that could smuggle it in (#92).
|
||||||
|
func testRawNeverAppears() {
|
||||||
|
for optionKeys in [Set<String>(), ["raw"],
|
||||||
|
["MediaType", "raw"]] {
|
||||||
|
let r = resolve(
|
||||||
|
overrides: TargetPrintOverrides(
|
||||||
|
paperSize: "raw", mediaType: "raw",
|
||||||
|
qualityKey: "raw", quality: "raw",
|
||||||
|
orientation: "raw"),
|
||||||
|
optionKeys: optionKeys)
|
||||||
|
XCTAssertFalse(r.writes.contains {
|
||||||
|
$0.key.lowercased() == "raw"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// D6 inversion — the deleted argv builder suppressed the Stage 2
|
||||||
|
/// value when the captured string already carried the key (the old
|
||||||
|
/// media branch at `build`'s line 81). There is no captured-wins
|
||||||
|
/// path any more:
|
||||||
|
/// the explicit Stage 2 value is emitted unconditionally.
|
||||||
|
func testStage2MediaTypeAlwaysWins() {
|
||||||
|
let r = resolve(
|
||||||
|
overrides: TargetPrintOverrides(mediaType: "Matte"),
|
||||||
|
optionKeys: ["MediaType"])
|
||||||
|
XCTAssertEqual(value("MediaType", in: r), "Matte")
|
||||||
|
// …and likewise for a vendor key — nothing consults captured
|
||||||
|
// options before writing.
|
||||||
|
let epson = resolve(
|
||||||
|
overrides: TargetPrintOverrides(mediaType: "Matte"),
|
||||||
|
optionKeys: ["EPIJ_Medi"])
|
||||||
|
XCTAssertEqual(value("EPIJ_Medi", in: epson), "Matte")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vendor media-key selection follows `CupsParsers.mediaTypeKeys`:
|
||||||
|
/// `EPIJ_Medi` is picked when present; `CNIJMediaType` beats
|
||||||
|
/// `MediaType`.
|
||||||
|
func testVendorMediaKeySelection() {
|
||||||
|
let epson = resolve(
|
||||||
|
overrides: TargetPrintOverrides(mediaType: "Photo"),
|
||||||
|
optionKeys: ["EPIJ_Medi", "MediaType"])
|
||||||
|
XCTAssertEqual(value("EPIJ_Medi", in: epson), "Photo")
|
||||||
|
XCTAssertNil(value("MediaType", in: epson))
|
||||||
|
|
||||||
|
let canon = resolve(
|
||||||
|
overrides: TargetPrintOverrides(mediaType: "Photo"),
|
||||||
|
optionKeys: ["CNIJMediaType", "MediaType"])
|
||||||
|
XCTAssertEqual(value("CNIJMediaType", in: canon), "Photo")
|
||||||
|
XCTAssertNil(value("MediaType", in: canon))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Driver "no colour adjustment" bypass — Canon, Epson (CCor
|
||||||
|
/// preferred over CMat), Gutenprint — always unlocked and never
|
||||||
|
/// gated on `ppdUncorrectedPassthrough`.
|
||||||
|
func testDriverBypass() {
|
||||||
|
let canon = resolve(optionKeys: ["CNIJIntent2"])
|
||||||
|
XCTAssertEqual(value("CNIJIntent2", in: canon), "4")
|
||||||
|
XCTAssertFalse(canon.writes.first { $0.key == "CNIJIntent2" }!
|
||||||
|
.locked)
|
||||||
|
|
||||||
|
let epson = resolve(optionKeys: ["EPIJ_CCor", "EPIJ_CMat"])
|
||||||
|
XCTAssertEqual(value("EPIJ_CCor", in: epson), "0")
|
||||||
|
XCTAssertNil(value("EPIJ_CMat", in: epson))
|
||||||
|
|
||||||
|
let epsonMat = resolve(optionKeys: ["EPIJ_CMat"])
|
||||||
|
XCTAssertEqual(value("EPIJ_CMat", in: epsonMat), "3")
|
||||||
|
|
||||||
|
let gutenprint = resolve(optionKeys: ["StpColorCorrection"])
|
||||||
|
XCTAssertEqual(value("StpColorCorrection", in: gutenprint),
|
||||||
|
"Uncorrected")
|
||||||
|
|
||||||
|
XCTAssertNil(value("EPIJ_CMat", in: resolve()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orientation: landscape → 4, portrait → 3, nil → no write.
|
||||||
|
func testOrientation() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
value("orientation-requested", in: resolve(
|
||||||
|
overrides: TargetPrintOverrides(
|
||||||
|
orientation: "landscape"))),
|
||||||
|
"4")
|
||||||
|
XCTAssertEqual(
|
||||||
|
value("orientation-requested", in: resolve(
|
||||||
|
overrides: TargetPrintOverrides(
|
||||||
|
orientation: "portrait"))),
|
||||||
|
"3")
|
||||||
|
XCTAssertNil(value("orientation-requested", in: resolve()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `nil` override emits no write for that key; quality needs
|
||||||
|
/// **both** the detected key and the value.
|
||||||
|
func testNilOverridesEmitNoWrite() {
|
||||||
|
let r = resolve(optionKeys: ["MediaType", "EPIJ_Qual"])
|
||||||
|
XCTAssertNil(value("PageSize", in: r))
|
||||||
|
XCTAssertNil(value("MediaType", in: r))
|
||||||
|
XCTAssertNil(value("EPIJ_Qual", in: r))
|
||||||
|
XCTAssertNil(value("orientation-requested", in: r))
|
||||||
|
XCTAssertNil(r.paperToken)
|
||||||
|
XCTAssertNil(r.orientation)
|
||||||
|
|
||||||
|
let keyOnly = resolve(
|
||||||
|
overrides: TargetPrintOverrides(qualityKey: "EPIJ_Qual"),
|
||||||
|
optionKeys: ["EPIJ_Qual"])
|
||||||
|
XCTAssertNil(value("EPIJ_Qual", in: keyOnly))
|
||||||
|
let valueOnly = resolve(
|
||||||
|
overrides: TargetPrintOverrides(quality: "305"),
|
||||||
|
optionKeys: ["EPIJ_Qual"])
|
||||||
|
XCTAssertNil(value("EPIJ_Qual", in: valueOnly))
|
||||||
|
// An empty paper token emits no write and no paperToken.
|
||||||
|
let emptyPaper = resolve(
|
||||||
|
overrides: TargetPrintOverrides(paperSize: ""))
|
||||||
|
XCTAssertNil(value("PageSize", in: emptyPaper))
|
||||||
|
XCTAssertNil(emptyPaper.paperToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The write list equals the locked 1–10 order, exactly.
|
||||||
|
func testLockedWriteOrder() {
|
||||||
|
let r = resolve(
|
||||||
|
overrides: TargetPrintOverrides(
|
||||||
|
paperSize: "A4", mediaType: "Photo",
|
||||||
|
qualityKey: "EPIJ_Qual", quality: "305",
|
||||||
|
orientation: "landscape"),
|
||||||
|
optionKeys: ["MediaType", "EPIJ_Qual", "EPIJ_CMat"])
|
||||||
|
XCTAssertEqual(keys(r), [
|
||||||
|
"AP_ColorMatchingMode",
|
||||||
|
"AP.ColorMatchingMode",
|
||||||
|
"PMColorMatchingMode",
|
||||||
|
"PMCustomColorMatchingProfile",
|
||||||
|
"com.apple.print.PrintSettings.PMColorMatchingMode",
|
||||||
|
"PageSize",
|
||||||
|
"MediaType",
|
||||||
|
"EPIJ_Qual",
|
||||||
|
"EPIJ_CMat",
|
||||||
|
"orientation-requested",
|
||||||
|
])
|
||||||
|
XCTAssertEqual(r.paperToken, "A4")
|
||||||
|
XCTAssertEqual(r.orientation, "landscape")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Mock lp for Milestone3UITests. Appends its full argv to
|
|
||||||
# ICCERY_TEST_LP_ARGV so the test can assert flag order and option
|
|
||||||
# replay, then exits 0 (or ICCERY_MOCK_LP_EXIT for failure injection).
|
|
||||||
{
|
|
||||||
printf 'lp'
|
|
||||||
for arg in "$@"; do printf ' %s' "$arg"; done
|
|
||||||
printf '\n'
|
|
||||||
} >> "${ICCERY_TEST_LP_ARGV:-/dev/null}"
|
|
||||||
if [ "${ICCERY_MOCK_LP_EXIT:-0}" -ne 0 ]; then
|
|
||||||
echo "mock lp failure" >&2
|
|
||||||
exit "$ICCERY_MOCK_LP_EXIT"
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
@@ -22,5 +22,5 @@ if [ "$list" = "1" ]; then
|
|||||||
printf 'ColorModel/Output Mode: *RGB Gray\n'
|
printf 'ColorModel/Output Mode: *RGB Gray\n'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
printf "printer-info='Mock %s' printer-type=42\n" "$queue"
|
printf "printer-info='Mock %s' printer-make-and-model='EPSON XP-55 Series' printer-type=42\n" "$queue"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ case "$1" in
|
|||||||
-d)
|
-d)
|
||||||
printf 'system default destination: Mock_Epson_7450\n'
|
printf 'system default destination: Mock_Epson_7450\n'
|
||||||
;;
|
;;
|
||||||
|
-v)
|
||||||
|
# Mock_Canon_Pro is the AirPrint fixture queue (#202): local
|
||||||
|
# unencrypted ipp:// resolved via the _universal mDNS subtype.
|
||||||
|
printf 'device for Mock_Epson_7450: usb://EPSON/XP-55%%20Series?serial=MOCK123\n'
|
||||||
|
printf 'device for Mock_Canon_Pro: ipp://Mock%%20Canon%%20Pro._universal._sub._ipp._tcp.local./\n'
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Mock printtarg for Milestone2UITests. Writes one 2x2 TIFF, a pretty
|
# Mock printtarg for Milestone2UITests. Writes N 2x2 TIFFs, a pretty
|
||||||
# manifest on stdout, and a fake .ti2 next to the basename (last argv).
|
# manifest on stdout, and a fake .ti2 next to the basename (last argv).
|
||||||
|
# N defaults to 1; ICCERY_MOCK_PRINTTARG_PAGES raises it for the
|
||||||
|
# single-spool-job granularity test (#201 D5).
|
||||||
# Exit code is overridable via ICCERY_MOCK_PRINTTARG_EXIT.
|
# Exit code is overridable via ICCERY_MOCK_PRINTTARG_EXIT.
|
||||||
last=""
|
last=""
|
||||||
for arg in "$@"; do last="$arg"; done
|
for arg in "$@"; do last="$arg"; done
|
||||||
@@ -8,7 +10,16 @@ if [ "${ICCERY_MOCK_PRINTTARG_EXIT:-0}" -ne 0 ]; then
|
|||||||
echo "mock printtarg failure" >&2
|
echo "mock printtarg failure" >&2
|
||||||
exit "$ICCERY_MOCK_PRINTTARG_EXIT"
|
exit "$ICCERY_MOCK_PRINTTARG_EXIT"
|
||||||
fi
|
fi
|
||||||
echo 'SUkqAAgAAAAKAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwABAAAACAAAAAMBAwABAAAAAQAAAAYBAwABAAAAAQAAABEBBAABAAAAhgAAABUBAwABAAAAAQAAABYBAwABAAAAAgAAABcBBAABAAAABAAAABwBAwABAAAAAQAAAAAAAAA8eLTw' | /usr/bin/base64 -D > "page1.tif"
|
pages="${ICCERY_MOCK_PRINTTARG_PAGES:-1}"
|
||||||
printf '{\n "event": "manifest",\n "pages": [\n {"filename": "page1.tif", "patches": 4, "width_mm": 210, "height_mm": 297}\n ]\n}\n'
|
i=1
|
||||||
|
printf '{\n "event": "manifest",\n "pages": [\n'
|
||||||
|
while [ "$i" -le "$pages" ]; do
|
||||||
|
echo 'SUkqAAgAAAAKAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwABAAAACAAAAAMBAwABAAAAAQAAAAYBAwABAAAAAQAAABEBBAABAAAAhgAAABUBAwABAAAAAQAAABYBAwABAAAAAgAAABcBBAABAAAABAAAABwBAwABAAAAAQAAAAAAAAA8eLTw' | /usr/bin/base64 -D > "page$i.tif"
|
||||||
|
sep=","
|
||||||
|
if [ "$i" -eq "$pages" ]; then sep=""; fi
|
||||||
|
printf ' {"filename": "page%s.tif", "patches": 4, "width_mm": 210, "height_mm": 297}%s\n' "$i" "$sep"
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
printf ' ]\n}\n'
|
||||||
touch "$last.ti2"
|
touch "$last.ti2"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
|
|||||||
private var testRoot: URL!
|
private var testRoot: URL!
|
||||||
private var binDir: URL!
|
private var binDir: URL!
|
||||||
private var workDir: URL!
|
private var workDir: URL!
|
||||||
private var lpArgvURL: URL!
|
private var spoolLogURL: URL!
|
||||||
|
|
||||||
override func setUp() async throws {
|
override func setUp() async throws {
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
@@ -21,7 +21,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
|
|||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.appendingPathComponent("Fixtures/bin")
|
.appendingPathComponent("Fixtures/bin")
|
||||||
workDir = testRoot.appendingPathComponent("work")
|
workDir = testRoot.appendingPathComponent("work")
|
||||||
lpArgvURL = testRoot.appendingPathComponent("lp-argv.log")
|
spoolLogURL = testRoot.appendingPathComponent("spool.log")
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: workDir, withIntermediateDirectories: true)
|
at: workDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
|
|||||||
"ICCERY_TEST_SAVE_TARGET":
|
"ICCERY_TEST_SAVE_TARGET":
|
||||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||||
"ICCERY_TEST_LP_ARGV": lpArgvURL.path,
|
"ICCERY_TEST_SPOOL_LOG": spoolLogURL.path,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,14 +75,33 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
|
|||||||
_ = waitFor("galleryPage-0", timeout: 25)
|
_ = waitFor("galleryPage-0", timeout: 25)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func waitForLpLine(_ timeout: TimeInterval = 10) -> String {
|
private func spoolLogLines() -> [String] {
|
||||||
|
((try? String(contentsOf: spoolLogURL, encoding: .utf8)) ?? "")
|
||||||
|
.split(separator: "\n").map(String.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForSpoolLine(_ timeout: TimeInterval = 10) -> String {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
let out = (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? ""
|
let lines = spoolLogLines()
|
||||||
if !out.isEmpty { return out }
|
if !lines.isEmpty { return lines.joined(separator: "\n") }
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
}
|
}
|
||||||
return (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? ""
|
return spoolLogLines().joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll until the spool log holds at least `count` lines.
|
||||||
|
@discardableResult
|
||||||
|
private func waitForSpoolLineCount(
|
||||||
|
_ count: Int, timeout: TimeInterval = 15
|
||||||
|
) -> [String] {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let lines = spoolLogLines()
|
||||||
|
if lines.count >= count { return lines }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
return spoolLogLines()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Tests
|
// MARK: - Tests
|
||||||
@@ -163,8 +182,8 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The stubbed panel result's captured `PageSize=`/`EPIJ_Qual=`
|
/// The stubbed panel result's captured `PageSize=`/`EPIJ_Qual=`
|
||||||
/// apply back into the Stage 2 pickers and reach the `lp` argv
|
/// apply back into the Stage 2 pickers and reach the recorded
|
||||||
/// (R15 — the real modal is never driven).
|
/// ticket writes (#201 — the real modal is never driven).
|
||||||
func testPanelResultAppliesBackToPickers() throws {
|
func testPanelResultAppliesBackToPickers() 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"] =
|
||||||
@@ -183,14 +202,14 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
|
|||||||
XCTAssertEqual(selection(of: "printerQualitySelect"), "305")
|
XCTAssertEqual(selection(of: "printerQualitySelect"), "305")
|
||||||
|
|
||||||
app.buttons["btnPrintAll"].click()
|
app.buttons["btnPrintAll"].click()
|
||||||
let argv = waitForLpLine()
|
let log = waitForSpoolLine()
|
||||||
XCTAssertTrue(argv.contains("PageSize=Letter"), argv)
|
XCTAssertTrue(log.contains("PageSize=Letter"), log)
|
||||||
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv)
|
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #186 — the stubbed panel result's `orientation-requested=` /
|
/// #186 — the stubbed panel result's `orientation-requested=` /
|
||||||
/// `MediaType=` apply back to the Stage 2 selections and reach the
|
/// `MediaType=` apply back to the Stage 2 selections and reach the
|
||||||
/// `lp` argv through the captured `cupsOptions` replay.
|
/// recorded ticket writes via the Stage 2 overrides (#201 D6).
|
||||||
func testPanelResultAppliesBackOrientationAndMedia() throws {
|
func testPanelResultAppliesBackOrientationAndMedia() 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"] =
|
||||||
@@ -225,11 +244,119 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
|
|||||||
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter")
|
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter")
|
||||||
|
|
||||||
app.buttons["btnPrintAll"].click()
|
app.buttons["btnPrintAll"].click()
|
||||||
let argv = waitForLpLine()
|
let log = waitForSpoolLine()
|
||||||
XCTAssertTrue(argv.contains("orientation-requested=4"), argv)
|
XCTAssertTrue(log.contains("orientation-requested=4"), log)
|
||||||
XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv)
|
XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log)
|
||||||
XCTAssertTrue(argv.contains("PageSize=Letter"), argv)
|
XCTAssertTrue(log.contains("PageSize=Letter"), log)
|
||||||
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv)
|
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
|
||||||
|
/// `chkSingleSpoolJob` toggle collapses the job into a single
|
||||||
|
/// request logged once with `pages=N`.
|
||||||
|
func testSingleSpoolJobTogglesGranularity() throws {
|
||||||
|
app.launchEnvironment["ICCERY_MOCK_PRINTTARG_PAGES"] = "2"
|
||||||
|
launchAppWithDefaults()
|
||||||
|
reachPrintPanel()
|
||||||
|
_ = waitFor("printerStatusBadge")
|
||||||
|
XCTAssertTrue(element("chkSingleSpoolJob").exists)
|
||||||
|
|
||||||
|
// Default off → one request per page → one line per page.
|
||||||
|
app.buttons["btnPrintAll"].click()
|
||||||
|
var lines = waitForSpoolLineCount(2)
|
||||||
|
XCTAssertEqual(lines.count, 2)
|
||||||
|
XCTAssertTrue(lines.allSatisfy { $0.contains("pages=1") },
|
||||||
|
lines.joined(separator: "\n"))
|
||||||
|
|
||||||
|
// Toggle on → one request for all pages → a single line.
|
||||||
|
element("chkSingleSpoolJob").click()
|
||||||
|
app.buttons["btnPrintAll"].click()
|
||||||
|
lines = waitForSpoolLineCount(3)
|
||||||
|
XCTAssertEqual(lines.count, 3)
|
||||||
|
XCTAssertEqual(
|
||||||
|
lines.filter { $0.contains("pages=2") }.count, 1,
|
||||||
|
lines.joined(separator: "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func launchAppWithDefaults() {
|
private func launchAppWithDefaults() {
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Milestone 12 UI tests — #202 AirPrint queue detection. The mock
|
||||||
|
/// `lpstat -v` fixture reports `Mock_Canon_Pro` as a local unencrypted
|
||||||
|
/// `ipp://` queue resolved via the `_universal._sub._ipp._tcp` mDNS
|
||||||
|
/// subtype (rule 5), while `Mock_Epson_7450` is a plain USB raster
|
||||||
|
/// queue — so the badge must track the picker selection.
|
||||||
|
@MainActor
|
||||||
|
final class Milestone12AirPrintUITests: XCTestCase {
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private var testRoot: URL!
|
||||||
|
private var binDir: URL!
|
||||||
|
private var workDir: URL!
|
||||||
|
private var spoolLogURL: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
testRoot = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ui12-\(UUID().uuidString)")
|
||||||
|
binDir = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Fixtures/bin")
|
||||||
|
workDir = testRoot.appendingPathComponent("work")
|
||||||
|
spoolLogURL = testRoot.appendingPathComponent("spool.log")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: workDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchEnvironment = [
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_ROOT": testRoot.path,
|
||||||
|
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||||
|
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||||
|
"ICCERY_TEST_SAVE_TARGET":
|
||||||
|
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||||
|
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||||
|
"ICCERY_TEST_SPOOL_LOG": spoolLogURL.path,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
if let testRoot {
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
testRoot = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func element(_ id: String) -> XCUIElement {
|
||||||
|
let inApp = app.descendants(matching: .any)[id]
|
||||||
|
if inApp.exists { return inApp }
|
||||||
|
return app.sheets.firstMatch.descendants(matching: .any)[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll until `id` no longer resolves — the badge is conditionally
|
||||||
|
/// rendered, so absence is only meaningful after a settle window.
|
||||||
|
private func waitForAbsence(_ id: String, timeout: TimeInterval = 5) {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if !element(id).exists { return }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(element(id).exists, "Element \(id) should be absent")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the app through targen + printtarg so the print panel is
|
||||||
|
/// live with a manifest.
|
||||||
|
private func reachPrintPanel() {
|
||||||
|
app.buttons["btnBrowse"].click()
|
||||||
|
app.buttons["btnGenerate"].click()
|
||||||
|
_ = waitFor("btnCreateLayout", timeout: 25)
|
||||||
|
app.buttons["btnCreateLayout"].click()
|
||||||
|
_ = waitFor("galleryPage-0", timeout: 25)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the printer-picker menu item whose title contains
|
||||||
|
/// `needle` (display names come from the `lpoptions` fixture).
|
||||||
|
private func selectPrinter(containing needle: String) {
|
||||||
|
let picker = app.popUpButtons["printerSelect"]
|
||||||
|
XCTAssertTrue(picker.waitForExistence(timeout: 10))
|
||||||
|
picker.click()
|
||||||
|
let item = app.menuItems
|
||||||
|
.matching(NSPredicate(format: "title CONTAINS %@", needle))
|
||||||
|
.firstMatch
|
||||||
|
XCTAssertTrue(item.waitForExistence(timeout: 5),
|
||||||
|
"No printer menu item containing \(needle)")
|
||||||
|
item.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
/// The badge appears while the AirPrint fixture queue is selected
|
||||||
|
/// and is absent for the USB Epson — in both directions.
|
||||||
|
func testAirPrintBadgeTracksSelectedQueue() throws {
|
||||||
|
app.launch()
|
||||||
|
app.activate()
|
||||||
|
reachPrintPanel()
|
||||||
|
_ = waitFor("printerStatusBadge")
|
||||||
|
|
||||||
|
// Default queue is the USB Epson — no badge.
|
||||||
|
waitForAbsence("airPrintWarningBadge")
|
||||||
|
|
||||||
|
selectPrinter(containing: "Canon")
|
||||||
|
let badge = element("airPrintWarningBadge")
|
||||||
|
XCTAssertTrue(badge.waitForExistence(timeout: 10))
|
||||||
|
// StaticText exposes its content via AXValue, not the label.
|
||||||
|
let badgeText = [badge.value as? String, badge.label, badge.title]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.first { !$0.isEmpty } ?? ""
|
||||||
|
XCTAssertTrue(
|
||||||
|
badgeText.contains(
|
||||||
|
"AirPrint queue — unmanaged colour cannot be guaranteed."),
|
||||||
|
"Unexpected badge text: \(badgeText)")
|
||||||
|
|
||||||
|
selectPrinter(containing: "Epson")
|
||||||
|
waitForAbsence("airPrintWarningBadge")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@ import XCTest
|
|||||||
/// Milestone 3 UI tests — issue #17 print panel end-to-end with mock
|
/// Milestone 3 UI tests — issue #17 print panel end-to-end with mock
|
||||||
/// CUPS binaries and a stubbed `NSPrintPanel`. The real panel is a
|
/// CUPS binaries and a stubbed `NSPrintPanel`. The real panel is a
|
||||||
/// system modal XCUITest cannot drive; `ICCERY_TEST_PRINT_PANEL`
|
/// system modal XCUITest cannot drive; `ICCERY_TEST_PRINT_PANEL`
|
||||||
/// returns a canned `PrintPropertiesResult` instead. Mock `lp` appends
|
/// returns a canned `PrintPropertiesResult` instead. The DEBUG
|
||||||
/// its argv to `ICCERY_TEST_LP_ARGV` for assertions — that file is the
|
/// `RecordingTargetSpooler` appends one resolved-ticket line per
|
||||||
/// evidence that captured options are replayed (docs/11 §tests).
|
/// request to `ICCERY_TEST_SPOOL_LOG` (#201 D8) — that file is the
|
||||||
|
/// evidence that the Stage 2 selections reach the spool.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class Milestone3UITests: XCTestCase {
|
final class Milestone3UITests: XCTestCase {
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
private var testRoot: URL!
|
private var testRoot: URL!
|
||||||
private var binDir: URL!
|
private var binDir: URL!
|
||||||
private var workDir: URL!
|
private var workDir: URL!
|
||||||
private var lpArgvURL: URL!
|
private var spoolLogURL: URL!
|
||||||
|
|
||||||
override func setUp() async throws {
|
override func setUp() async throws {
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
@@ -23,7 +24,7 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.appendingPathComponent("Fixtures/bin")
|
.appendingPathComponent("Fixtures/bin")
|
||||||
workDir = testRoot.appendingPathComponent("work")
|
workDir = testRoot.appendingPathComponent("work")
|
||||||
lpArgvURL = testRoot.appendingPathComponent("lp-argv.log")
|
spoolLogURL = testRoot.appendingPathComponent("spool.log")
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: workDir, withIntermediateDirectories: true)
|
at: workDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
"ICCERY_TEST_SAVE_TARGET":
|
"ICCERY_TEST_SAVE_TARGET":
|
||||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||||
"ICCERY_TEST_LP_ARGV": lpArgvURL.path,
|
"ICCERY_TEST_SPOOL_LOG": spoolLogURL.path,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,32 +100,32 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
_ = waitFor("galleryPage-0", timeout: 25)
|
_ = waitFor("galleryPage-0", timeout: 25)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func recordedLpArgv() -> String {
|
private func recordedSpoolLog() -> String {
|
||||||
(try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? ""
|
(try? String(contentsOf: spoolLogURL, encoding: .utf8)) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private func waitForLpLine(_ timeout: TimeInterval = 10) -> String {
|
private func waitForSpoolLine(_ timeout: TimeInterval = 10) -> String {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
let out = recordedLpArgv()
|
let out = recordedSpoolLog()
|
||||||
if !out.isEmpty { return out }
|
if !out.isEmpty { return out }
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
}
|
}
|
||||||
return recordedLpArgv()
|
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,
|
||||||
@@ -133,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 {
|
||||||
@@ -141,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]
|
||||||
}
|
}
|
||||||
@@ -203,10 +193,13 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
XCTAssertEqual(element("printNotificationIcon").value as? String, "info")
|
XCTAssertEqual(element("printNotificationIcon").value as? String, "info")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preferences OK → captured options are replayed verbatim in the
|
/// Preferences OK → the captured media selection applies back to
|
||||||
/// `lp` argv alongside the two mandatory AP_* headers (issue 17's
|
/// Stage 2 and reaches the recorded ticket writes alongside the
|
||||||
/// acceptance test: "captured options replayed in argv").
|
/// mandatory colour keys (issue 17's acceptance test, ported to
|
||||||
func testCapturedOptionsReplayedInLpArgv() throws {
|
/// the native spool seam in #201). The captured `InputSlot` is no
|
||||||
|
/// longer replayed — captured vendor state lives inside the
|
||||||
|
/// `PrintTicket`, which the stub deliberately does not produce.
|
||||||
|
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"
|
"InputSlot=Rear MediaType=PhotographicGlossy"
|
||||||
@@ -221,20 +214,17 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
.contains("Settings captured"))
|
.contains("Settings captured"))
|
||||||
|
|
||||||
app.buttons["btnPrintAll"].click()
|
app.buttons["btnPrintAll"].click()
|
||||||
let argv = waitForLpLine()
|
let log = waitForSpoolLine()
|
||||||
XCTAssertTrue(argv.contains(
|
XCTAssertTrue(log.contains(
|
||||||
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), argv)
|
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), log)
|
||||||
XCTAssertTrue(argv.contains(
|
XCTAssertTrue(log.contains(
|
||||||
"AP.ColorMatchingMode=AP_ApplicationColorMatching"), argv)
|
"AP.ColorMatchingMode=AP_ApplicationColorMatching"), log)
|
||||||
XCTAssertTrue(argv.contains("InputSlot=Rear"), argv)
|
XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log)
|
||||||
XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv)
|
|
||||||
// Detected bypass for the mock queue (EPIJ_CMat present in
|
// Detected bypass for the mock queue (EPIJ_CMat present in
|
||||||
// lpoptions -l) is appended when not captured.
|
// lpoptions -l) is always written.
|
||||||
XCTAssertTrue(argv.contains("EPIJ_CMat=3"), argv)
|
XCTAssertTrue(log.contains("EPIJ_CMat=3"), log)
|
||||||
XCTAssertTrue(argv.contains("orientation-requested=3"), argv)
|
XCTAssertTrue(log.contains("orientation-requested=3"), log)
|
||||||
// Last token is the TIFF.
|
XCTAssertTrue(log.contains("page=page1.tif"), log)
|
||||||
XCTAssertTrue(argv.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.hasSuffix("page1.tif"), argv)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-page print uses the same spool path (btnPrintPage-N).
|
/// Per-page print uses the same spool path (btnPrintPage-N).
|
||||||
@@ -262,14 +252,16 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
printPage.click()
|
printPage.click()
|
||||||
let argv = waitForLpLine()
|
let log = waitForSpoolLine()
|
||||||
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
XCTAssertTrue(log.contains("AP_ColorMatchingMode"), log)
|
||||||
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
XCTAssertTrue(log.contains("page=page1.tif"), log)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// lp failure surfaces in the in-panel notice, not the wizard banner.
|
/// A spool failure surfaces in the in-panel notice, not the
|
||||||
func testLpFailureShowsPrintNotice() throws {
|
/// wizard banner (`ICCERY_TEST_SPOOL_FAIL` makes the recording
|
||||||
app.launchEnvironment["ICCERY_MOCK_LP_EXIT"] = "1"
|
/// spooler throw `TargetSpoolError.operationFailed`).
|
||||||
|
func testSpoolFailureShowsPrintNotice() throws {
|
||||||
|
app.launchEnvironment["ICCERY_TEST_SPOOL_FAIL"] = "1"
|
||||||
launchApp()
|
launchApp()
|
||||||
reachPrintPanel()
|
reachPrintPanel()
|
||||||
_ = waitFor("printerStatusBadge")
|
_ = waitFor("printerStatusBadge")
|
||||||
@@ -290,7 +282,7 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
_ = waitFor("printerStatusBadge")
|
_ = waitFor("printerStatusBadge")
|
||||||
|
|
||||||
app.buttons["btnPrintAll"].click()
|
app.buttons["btnPrintAll"].click()
|
||||||
_ = waitForLpLine()
|
_ = waitForSpoolLine()
|
||||||
let stateURL = testRoot
|
let stateURL = testRoot
|
||||||
.appendingPathComponent("AppData")
|
.appendingPathComponent("AppData")
|
||||||
.appendingPathComponent("wizard_state.json")
|
.appendingPathComponent("wizard_state.json")
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor
|
|||||||
| `media_types` | `Vec<PrinterMediaType>` | `#[serde(default)]`. Frontend fills `#printerMediaTypeSelect`. |
|
| `media_types` | `Vec<PrinterMediaType>` | `#[serde(default)]`. Frontend fills `#printerMediaTypeSelect`. |
|
||||||
| `supports_orientation` | `bool` | Always `true` on both Windows and Unix. |
|
| `supports_orientation` | `bool` | Always `true` on both Windows and Unix. |
|
||||||
|
|
||||||
### `PrintOptions` (mod.rs:42–55) — `Default` + `PartialEq + Eq`
|
### `PrintOptions` (mod.rs:42–55) — `Default` + `PartialEq + Eq` — historical v1
|
||||||
|
|
||||||
|
> The `lp`/`-o` columns below describe the v0.8.5 spool contract. v2.0
|
||||||
|
> replaced the macOS `lp` path with a headless `NSPrintOperation` (#201).
|
||||||
|
|
||||||
| Field | Type | Windows | Linux | macOS |
|
| Field | Type | Windows | Linux | macOS |
|
||||||
|-------|------|---------|-------|-------|
|
|-------|------|---------|-------|-------|
|
||||||
|
|||||||
+91
-45
@@ -19,7 +19,7 @@ This is the most implementation-sensitive chapter. A rewrite that opens System S
|
|||||||
- `NSWorkspace` open of the printer
|
- `NSWorkspace` open of the printer
|
||||||
- `lpoptions` GUI
|
- `lpoptions` GUI
|
||||||
|
|
||||||
Linux/Windows do not share this panel. Default button title is **"Use Settings"** (macos.rs:439) — this is a settings-capture dialog, not a print-now dialog. Actual spooling is a later `lp` invocation.
|
Linux/Windows do not share this panel. Default button title is **"Use Settings"** (macos.rs:439) — this is a settings-capture dialog, not a print-now dialog. Actual spooling is a later, separate step — in v2.0 a headless `NSPrintOperation` (#201; v1 used `lp`).
|
||||||
|
|
||||||
Must run on the Cocoa main thread. `show_printer_properties` (macos.rs:689-710):
|
Must run on the Cocoa main thread. `show_printer_properties` (macos.rs:689-710):
|
||||||
|
|
||||||
@@ -68,34 +68,31 @@ if pm_printer was created from ID:
|
|||||||
|
|
||||||
`PMPrinter` from `PMPrinterCreateFromPrinterID` is released with `PMRelease` on all exit paths (cancel, error, success). The NSPrinter fallback path leaves `pm_printer` null so no release.
|
`PMPrinter` from `PMPrinterCreateFromPrinterID` is released with `PMRelease` on all exit paths (cancel, error, success). The NSPrinter fallback path leaves `pm_printer` null so no release.
|
||||||
|
|
||||||
### ColorSync suppression strategy — UI click to `lp`
|
### ColorSync suppression strategy — UI click to `NSPrintOperation`
|
||||||
|
|
||||||
End-to-end, **six independent layers**. All of them exist because no single Apple API is sufficient across Epson PDE / Canon PDE / `cgpdftoraster` / CUPS.
|
End-to-end, **seven independent layers**. All of them exist because no single Apple API is sufficient across Epson PDE / Canon PDE / `cgpdftoraster` / CUPS. v2.0 (#201) replaced the `lp` tail with a headless `NSPrintOperation` that replays a captured `PMPrintSettings`/`PMPageFormat` ticket — layer ⑦ is new.
|
||||||
|
|
||||||
```
|
```
|
||||||
[Preferences click]
|
[Preferences click]
|
||||||
show_printer_properties
|
PrintSessionViewModel.openPrinterPreferences
|
||||||
run_on_main_thread
|
PrintPanelService.showProperties
|
||||||
run_native_print_panel
|
runNativePanel
|
||||||
① PMSessionSetCurrentPMPrinter bind queue
|
① PMPrinterCreateFromPrinterID + PMSessionSetCurrentPMPrinter bind queue
|
||||||
② set_session_color_matching_mode SPI gray out PDE Color Matching
|
② set_session_color_matching_mode SPI gray out PDE Color Matching
|
||||||
③ PMPrintSettingsSetValue AP_ColorMatchingMode + dotted
|
③ PMPrintSettingsSetValue AP_ColorMatchingMode + dotted
|
||||||
④ detect_driver_color_bypass → SetValue pre-select Canon/Epson/Gutenprint "off"
|
④ detect_driver_color_bypass → SetValue pre-select Canon/Epson/Gutenprint "off"
|
||||||
⑤ NSPrintInfo.printSettings dictionary same keys for AppKit PDEs
|
⑤ NSPrintInfo.printSettings dictionary same keys for AppKit PDEs
|
||||||
NSPrintPanel.runModalWithPrintInfo
|
⑤′ ColorSyncSuppressor.applyQuartzMode PMColorMatchingMode + legacy + nested mirror
|
||||||
|
NSPrintPanel.runModal("Use Settings")
|
||||||
user picks media / quality (color locked)
|
user picks media / quality (color locked)
|
||||||
⑥ PMPrintSettingsToOptions → filter → PrintPropertiesResult
|
⑥ PMPrintSettingsToOptions → filter → Stage 2 mirror
|
||||||
|
⑦ PMTicketBridge.serialise(printInfo) → PrintTicket ticket capture
|
||||||
[frontend]
|
[frontend]
|
||||||
capturedCupsOptions[printer] = cups_options
|
capturedCupsOptions[queue] = mirror + capturedTickets[queue] = ticket
|
||||||
[Print Target]
|
[Print Target]
|
||||||
print_target_native → macos::print_target → build_lp_args
|
PrintSessionViewModel → TargetPrintRequest → NativeTargetSpooler.spool
|
||||||
ALWAYS -o AP_ColorMatchingMode=AP_ApplicationColorMatching
|
restore ticket → Stage 2 writes → NSPrintOperation.runOperation
|
||||||
ALWAYS -o AP.ColorMatchingMode=AP_ApplicationColorMatching
|
(S1–S14 below — 1:1, device colour space, panels off)
|
||||||
THEN captured cups_options as -o k=v
|
|
||||||
THEN media_type if not already present (detected key)
|
|
||||||
THEN detect_driver_color_bypass if no color-bypass key yet
|
|
||||||
THEN orientation / PageSize if not already present
|
|
||||||
lp -d <queue> -t "ICCery Target - …" … <tiff>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux uses `-o raw` instead of AP_* flags. macOS **does not** use `-o raw`: a raw queue would skip the raster filter that actually understands `AP_ColorMatchingMode`. The macOS strategy is "tell the filter the application already matched color", not "skip the filter".
|
Linux uses `-o raw` instead of AP_* flags. macOS **does not** use `-o raw`: a raw queue would skip the raster filter that actually understands `AP_ColorMatchingMode`. The macOS strategy is "tell the filter the application already matched color", not "skip the filter".
|
||||||
@@ -161,7 +158,7 @@ AGENTS.md:118, macos.rs:82-88:
|
|||||||
| `AP_ColorSyncMatching` | **Avoided.** ColorSync applies the printer/display profile. Patches become color-managed. |
|
| `AP_ColorSyncMatching` | **Avoided.** ColorSync applies the printer/display profile. Patches become color-managed. |
|
||||||
| `AP_VendorColorMatching` | **Avoided.** Epson/Canon driver color engine (ICM inside the PDE). Same corruption. |
|
| `AP_VendorColorMatching` | **Avoided.** Epson/Canon driver color engine (ICM inside the PDE). Same corruption. |
|
||||||
|
|
||||||
ICCery-CPU uses a **different** vocabulary (`APCustomColorMatching` / `APColorSync` / `APPrinterExtension` on `PMColorMatchingMode`). See the CPU section. Do not mix the two dictionaries.
|
ICCery-CPU uses a **different** vocabulary (`APCustomColorMatching` / `APColorSync` / `APPrinterExtension` on `PMColorMatchingMode`). See the CPU section. v1 kept the two dictionaries on separate paths (`lp` vs Quartz); v2.0 (#201, D2) has a single native path that writes **both** vocabularies — layer ⑤′ below.
|
||||||
|
|
||||||
### Layer ③ — `PMPrintSettingsSetValue` (macos.rs:357-375)
|
### Layer ③ — `PMPrintSettingsSetValue` (macos.rs:357-375)
|
||||||
|
|
||||||
@@ -204,6 +201,18 @@ print_settings.insert(<bypass_key>, <bypass_val>)
|
|||||||
|
|
||||||
`NSString` is transmuted to `&AnyObject` for the dictionary (`macos.rs:414-416`).
|
`NSString` is transmuted to `&AnyObject` for the dictionary (`macos.rs:414-416`).
|
||||||
|
|
||||||
|
### Layer ⑤′ — Quartz vocabulary (v2.0, #201 D2)
|
||||||
|
|
||||||
|
With `lp` gone there is a single native path, and it carries **both**
|
||||||
|
dictionaries. `ColorSyncSuppressor.applyQuartzMode` additionally writes the
|
||||||
|
Quartz/`NSPrintOperation` vocabulary (docs/14 §7):
|
||||||
|
|
||||||
|
- `PMColorMatchingMode` = `APCustomColorMatching`
|
||||||
|
- `PMCustomColorMatchingProfile` = `""`
|
||||||
|
- `com.apple.print.PrintSettings.PMColorMatchingMode` (legacy)
|
||||||
|
- the same keys inside the nested `com.apple.print.printSettings`
|
||||||
|
sub-dictionary of `printInfo.dictionary()`
|
||||||
|
|
||||||
### Panel options (macos.rs:434-449)
|
### Panel options (macos.rs:434-449)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -231,9 +240,27 @@ After OK:
|
|||||||
|
|
||||||
Failure of `PMPrintSettingsToOptions` is a hard `Err`.
|
Failure of `PMPrintSettingsToOptions` is a hard `Err`.
|
||||||
|
|
||||||
|
### Layer ⑦ — ticket serialise/restore (v2.0, #201 D3)
|
||||||
|
|
||||||
|
The layer-⑥ flattening is what lost the ticket (#201 root cause 1): it kept
|
||||||
|
only a `key=value` string and `CupsOptionsFilter` drops every `com.apple.*`
|
||||||
|
key, so `com.apple.print.PrintSettings` never survived. After the layer-⑥
|
||||||
|
capture, `PMTicketBridge.serialise(printInfo, queue:)` now snapshots the
|
||||||
|
whole ticket:
|
||||||
|
|
||||||
|
1. `PMPrintSettingsCreateDataRepresentation(settings, &data, kPMDataFormatXMLDefault)` → `PrintTicket.printSettings`.
|
||||||
|
2. `PMPageFormatCreateDataRepresentation` → `PrintTicket.pageFormat`.
|
||||||
|
3. Binary-plist snapshot of `NSPrintInfo.dictionary()`, plist-filtered → `PrintTicket.printInfoPlist` — fallback only, never the primary restore path.
|
||||||
|
|
||||||
|
`PrintTicket` is in-memory, session-only, keyed by queue. On spool,
|
||||||
|
`PMTicketBridge.restore` replays it into the job's `NSPrintInfo`:
|
||||||
|
`PM*CreateWithDataRepresentation` → `PMCopy*` → `PMSessionValidate*` →
|
||||||
|
`updateFromPM*`. A ticket captured for queue A is never replayed onto
|
||||||
|
queue B.
|
||||||
|
|
||||||
### `RELEVANT_CUPS_OPTION_KEYS` (macos.rs:142-174)
|
### `RELEVANT_CUPS_OPTION_KEYS` (macos.rs:142-174)
|
||||||
|
|
||||||
Forwarded from the panel to `lp`:
|
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
|
||||||
@@ -255,31 +282,38 @@ Duplex: Duplex, sides
|
|||||||
- Drops `collate`, `copies`, `pserrorhandler-requested`, `job-sheets`
|
- Drops `collate`, `copies`, `pserrorhandler-requested`, `job-sheets`
|
||||||
- **Keeps unknown non-`com.*` keys** (permissive: unknown driver keys survive)
|
- **Keeps unknown non-`com.*` keys** (permissive: unknown driver keys survive)
|
||||||
|
|
||||||
### `build_lp_args` (macos.rs:518-643)
|
### Native spool — S1–S14 (v2.0, #201)
|
||||||
|
|
||||||
Always, even with `options=None`:
|
`NativeTargetSpooler.spool(_:)` (app target, `Sources/ICCery/Print/` — `ICCeryCore` stays AppKit-free, D1). One `TargetPrintRequest` per page, or one per run when `singleJobForAllPages` is on (D5):
|
||||||
|
|
||||||
```
|
```
|
||||||
lp -d <printer> -t "ICCery Target - <filename>"
|
S1 NSPrintInfo()
|
||||||
-o AP_ColorMatchingMode=AP_ApplicationColorMatching
|
S2 printInfo.printer = NSPrinter(name: queue) ?? NSPrinter(name: displayName) [best effort]
|
||||||
-o AP.ColorMatchingMode=AP_ApplicationColorMatching
|
S3 PMTicketBridge.makePrinter(queue:) → bind(printer:to:) ① + PMSessionDefault*
|
||||||
… captured / detected options …
|
S4 ticket != nil → PMTicketBridge.restore(ticket, into: printInfo) ⑦′
|
||||||
<tiff_path>
|
S5 TicketWriteResolver.resolve(...) → apply to PMPrintSettings + mirror dict ③④⑤+D2
|
||||||
|
S6 paper override → PMTicketBridge.applyPaper(token:…)
|
||||||
|
S7 orientation → printInfo.orientation + orientation-requested
|
||||||
|
S8 suppressor.applySPIMode(session) ②
|
||||||
|
S9 Cocoa geometry: margins 0, pagination .clip, scaling 1.0, centering off,
|
||||||
|
jobDisposition .spool (or .save + jobSavingURL under the PDF harness)
|
||||||
|
S10 TargetRasterLoader.load(each page) → device-tagged CGImage + pointSize
|
||||||
|
S11 TargetPageCanvasView(pages:paperSize: printInfo.paperSize)
|
||||||
|
S12 NSPrintOperation(view:printInfo:) — panels off, jobTitle set
|
||||||
|
S13 operation.runOperation() → false ⇒ throw TargetSpoolError.operationFailed
|
||||||
|
S14 PMRelease the printer on every path (defer)
|
||||||
```
|
```
|
||||||
|
|
||||||
Order after the two AP_* flags:
|
`TicketWriteResolver` produces the resolved write list as a pure value, in a
|
||||||
|
locked order: both `AP_*` keys (locked) → the three Quartz keys (⑤′) →
|
||||||
1. Parse `opts.cups_options` into `-o k=v`, record lowercased keys in `added_keys`.
|
`PageSize` → the detected media key → the detected quality key → the driver
|
||||||
2. If `media_type` set and none of `mediatype` / `cnijmediatype` / `epij_medi` / `stpmediatype` already added: `detect_media_type_key(lpoptions)` and add it.
|
colour bypass → `orientation-requested`. Stage 2 overrides **always win**
|
||||||
3. If no color-bypass key yet (`cnijintent2`, `cnijintent`, `epij_cmat`, `epij_ccor`, `epij_oscolmat`, `colorcorrection`, `stpcolorcorrection`, `epsoncolormode`): `detect_driver_color_bypass` and add. **Not gated on `ppd_uncorrected_passthrough`.**
|
over the rehydrated ticket (D6 — the v1 captured-wins inversion is gone);
|
||||||
4. Orientation → `orientation-requested=4|3` unless already present.
|
`raw` can never appear because there is no `lp`. Panels stay off
|
||||||
5. `PageSize=` unless `pagesize` already present.
|
(`showsPrintPanel` / `showsProgressPanel` false, `canSpawnSeparateThread`
|
||||||
|
false); a multi-page job is one `TargetPageCanvasView` driven by
|
||||||
`ppd_uncorrected_passthrough` is stored from the panel but **does not change macOS lp flags**. There is no `-o raw` on macOS.
|
`knowsPageRange` / `rectForPage`, drawing each page 1:1 top-left anchored
|
||||||
|
with interpolation and antialiasing disabled (D9, docs/14 §6).
|
||||||
### `print_target` (macos.rs:646-677)
|
|
||||||
|
|
||||||
Exists-check, `build_lp_args`, `Command::new("lp").args(&args).output()`. Error wrapping same pattern as Unix (`"macOS CUPS print job failed: …"`).
|
|
||||||
|
|
||||||
### Cancellation as `None`
|
### Cancellation as `None`
|
||||||
|
|
||||||
@@ -295,19 +329,26 @@ The rewrite:
|
|||||||
- `display_name` fallback for `NSPrinter::printerWithName`.
|
- `display_name` fallback for `NSPrinter::printerWithName`.
|
||||||
- Private SPI to lock Color Matching.
|
- Private SPI to lock Color Matching.
|
||||||
- Dual AP_* keys (underscore + dotted).
|
- Dual AP_* keys (underscore + dotted).
|
||||||
- Driver-specific PPD bypass pre-selected and re-applied on `lp`.
|
- Driver-specific PPD bypass pre-selected in the panel and re-applied on the spool ticket (v1 re-applied it on `lp`).
|
||||||
- Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions`.
|
- Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions` (Stage 2 mirror) plus the `PrintTicket` serialise (layer ⑦, v2.0).
|
||||||
|
|
||||||
`PMPrinter` lifetime is explicit `PMRelease` on every path. SPI is `dlsym`'d so missing symbols on old OS X do not prevent launch. Panel **must** be main-thread (`MainThreadMarker::new().ok_or("Print panel must be invoked on the main thread")`).
|
`PMPrinter` lifetime is explicit `PMRelease` on every path. SPI is `dlsym`'d so missing symbols on old OS X do not prevent launch. Panel **must** be main-thread (`MainThreadMarker::new().ok_or("Print panel must be invoked on the main thread")`).
|
||||||
|
|
||||||
### macOS tests (macos.rs:712-863 + tests.rs:278-318)
|
### macOS tests — historical v1 (macos.rs:712-863 + tests.rs:278-318)
|
||||||
|
|
||||||
- Filter drops `com.apple.*`, `collate`, `copies`, `AP_ColorMatchingMode`, empty `AP_D_InputSlot`; keeps `MediaType`, `EPIJ_CMat`, `PageSize`, `CNIJIntent2`, `ColorCorrection`.
|
- Filter drops `com.apple.*`, `collate`, `copies`, `AP_ColorMatchingMode`, empty `AP_D_InputSlot`; keeps `MediaType`, `EPIJ_CMat`, `PageSize`, `CNIJIntent2`, `ColorCorrection`.
|
||||||
- `extract_media_type_from_options` prefers `MediaType` then `EPIJ_Medi`.
|
- `extract_media_type_from_options` prefers `MediaType` then `EPIJ_Medi`.
|
||||||
- `build_lp_args` always contains both AP_* flags; captured options win over explicit `media_type` / orientation / auto color-bypass; last arg is the TIFF path.
|
- `build_lp_args` always contains both AP_* flags; captured options win over explicit `media_type` / orientation / auto color-bypass; last arg is the TIFF path. (**Historical v1** — the captured-wins behaviour recorded here is #201 root cause 2; v2.0 inverts it: Stage 2 always wins, D6.)
|
||||||
- `detect_driver_color_bypass` Canon `4`, Epson `3`, Gutenprint `Uncorrected`.
|
- `detect_driver_color_bypass` Canon `4`, Epson `3`, Gutenprint `Uncorrected`.
|
||||||
- Missing TIFF errors.
|
- Missing TIFF errors.
|
||||||
|
|
||||||
|
v2.0 replacements (`TicketWriteResolverTests`, `PrintTicketTests`,
|
||||||
|
`TargetRasterTests`, `TargetCanvasGeometryTests`, `NativeSpoolPDFTests`):
|
||||||
|
both AP_* keys locked + all three Quartz keys always present; Stage 2
|
||||||
|
always wins (D6); `raw` never emitted; ticket serialise/restore
|
||||||
|
round-trips bytes; 1:1 geometry and draw flags asserted against a real
|
||||||
|
`.save`-to-PDF `NSPrintOperation`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ColorSync suppression — complete key/SPI/flag roster
|
## ColorSync suppression — complete key/SPI/flag roster
|
||||||
@@ -338,7 +379,12 @@ PMRelease
|
|||||||
|
|
||||||
AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPanelOptions::all` + `ShowsPageSetupAccessory`.
|
AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPanelOptions::all` + `ShowsPageSetupAccessory`.
|
||||||
|
|
||||||
### CUPS / lp flags
|
### CUPS / lp flags — historical v1
|
||||||
|
|
||||||
|
> No `lp` invocation exists on the v2.0 target-print path (#201 — `LpArgs`,
|
||||||
|
> `CupsService.printTarget` and the `lp` fixture are deleted). This table
|
||||||
|
> records the v1 `lp -o` contract for reference only; the live write list is
|
||||||
|
> `TicketWriteResolver`'s locked order (§native spool above).
|
||||||
|
|
||||||
| Flag | Platform | When |
|
| Flag | Platform | When |
|
||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ First-match order:
|
|||||||
|
|
||||||
`CNIJMediaType` > `EPIJ_Medi` > `StpMediaType` > `MediaType`.
|
`CNIJMediaType` > `EPIJ_Medi` > `StpMediaType` > `MediaType`.
|
||||||
|
|
||||||
### Linux `build_lp_args` / `print_target` (unix.rs:362-457)
|
### Linux `build_lp_args` / `print_target` (unix.rs:362-457) — historical v1
|
||||||
|
|
||||||
|
> v0.8.5 Linux spool contract, kept for reference. ICCery v2 is macOS-only
|
||||||
|
> and its native path spools via `NSPrintOperation`, not `lp` (#201).
|
||||||
|
|
||||||
```
|
```
|
||||||
lp -d <printer> -t "ICCery Target - <filename>"
|
lp -d <printer> -t "ICCery Target - <filename>"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Separate native macOS AppKit app. Spec: `/tmp/ICCery-CPU/SPEC.md`. Binary name `
|
|||||||
|
|
||||||
### Why it exists
|
### Why it exists
|
||||||
|
|
||||||
ICCery's Tauri path spools TIFF via `lp` and never goes through Quartz. That is correct for "don't let ColorSync touch the file", but:
|
ICCery's Tauri path spooled TIFF via `lp` and never went through Quartz (v1; v2.0 spools via a headless `NSPrintOperation` in ICCery proper — #201). That is correct for "don't let ColorSync touch the file", but:
|
||||||
|
|
||||||
- No 1:1 physical-size preview
|
- No 1:1 physical-size preview
|
||||||
- Windows-style `StretchDIBits` scaler (macOS `lp` may still scale inside the filter)
|
- Windows-style `StretchDIBits` scaler (macOS `lp` may still scale inside the filter)
|
||||||
@@ -100,7 +100,7 @@ Injected into:
|
|||||||
- `printInfo.dictionary()["com.apple.print.PrintSettings.PMColorMatchingMode"]` (legacy)
|
- `printInfo.dictionary()["com.apple.print.PrintSettings.PMColorMatchingMode"]` (legacy)
|
||||||
- nested `com.apple.print.printSettings` dictionary, same keys
|
- nested `com.apple.print.printSettings` dictionary, same keys
|
||||||
|
|
||||||
**This is not `AP_ApplicationColorMatching`.** TargetPrint talks to Quartz/`NSPrintOperation`. ICCery talks to the CUPS `lp` ticket / `cgpdftoraster`. A rewrite that unifies them must keep both vocabularies or prove one is honored on both paths.
|
**This is not `AP_ApplicationColorMatching`.** TargetPrint talks to Quartz/`NSPrintOperation`. v1 ICCery talked to the CUPS `lp` ticket / `cgpdftoraster`; a rewrite that unifies them must keep both vocabularies or prove one is honored on both paths. **v2.0 (#201, D2) resolved this:** the Quartz vocabulary in this section is now live in ICCery proper — `ColorSyncSuppressor.applyQuartzMode` / `TicketWriteResolver` write `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, the legacy `com.apple.print.PrintSettings.PMColorMatchingMode` and the nested `com.apple.print.printSettings` mirror **alongside** the locked AP_* keys on the single native spool path.
|
||||||
|
|
||||||
Panel policy (`ColorMatching.configurePanel`):
|
Panel policy (`ColorMatching.configurePanel`):
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ static inline const char *TPCupsGetPPD(const char *name) {
|
|||||||
- AirPrint (SPEC §10.2) if any of: URI `apple-airprint://`; PPD `*APAirPrint: True`; make Apple + model contains AirPrint; `ipps://` **and** PPD text contains `airprint`. Persistent warning badge; unmanaged color cannot be trusted. Tests in `AirPrintTests.swift`.
|
- AirPrint (SPEC §10.2) if any of: URI `apple-airprint://`; PPD `*APAirPrint: True`; make Apple + model contains AirPrint; `ipps://` **and** PPD text contains `airprint`. Persistent warning badge; unmanaged color cannot be trusted. Tests in `AirPrintTests.swift`.
|
||||||
- Vendor bypass (SPEC §10.3) — **different keys from ICCery's lpoptions detector:**
|
- Vendor bypass (SPEC §10.3) — **different keys from ICCery's lpoptions detector:**
|
||||||
|
|
||||||
| Vendor | TargetPrint keys | ICCery macOS `lp` keys |
|
| Vendor | TargetPrint keys | ICCery macOS `lpoptions`-detected keys (v1: `lp -o`) |
|
||||||
|--------|------------------|------------------------|
|
|--------|------------------|------------------------|
|
||||||
| Epson | `ColorModel=RGB`, `EPSONColorControls=Off` | `EPIJ_CMat=3` / `EPIJ_CCor=0` / `EpsonColorMode=Off` |
|
| Epson | `ColorModel=RGB`, `EPSONColorControls=Off` | `EPIJ_CMat=3` / `EPIJ_CCor=0` / `EpsonColorMode=Off` |
|
||||||
| Canon | `CNColorMatching=None` | `CNIJIntent2=4` / `CNIJIntent=4` |
|
| Canon | `CNColorMatching=None` | `CNIJIntent2=4` / `CNIJIntent=4` |
|
||||||
@@ -176,9 +176,9 @@ Command::new("/Applications/TargetPrint.app/Contents/MacOS/TargetPrint")
|
|||||||
|
|
||||||
CI publishes `vendor-iccery.zip` with `macos-x86_64` / `macos-aarch64` / `macos-universal` app bundles to drop into `src-tauri/targetprint/`.
|
CI publishes `vendor-iccery.zip` with `macos-x86_64` / `macos-aarch64` / `macos-universal` app bundles to drop into `src-tauri/targetprint/`.
|
||||||
|
|
||||||
Suggested ICCery integration:
|
Suggested ICCery integration (**superseded** — v2.0 took option 2's rendering model in-process instead; #201 adopted Quartz/`NSPrintOperation` spooling inside ICCery proper and removed `lp` entirely. The `--job` companion-app contract remains the v2.1 plan for `ICCeryPrintKit`, #16):
|
||||||
|
|
||||||
1. Keep current `lp` path as the headless/fast path (and the only path on Linux).
|
1. ~~Keep current `lp` path as the headless/fast path (and the only path on Linux).~~
|
||||||
2. On macOS, Preferences / Print can spawn TargetPrint with a `TargetJob` built from `PrintOptions` + TIFF list + `forceUnmanagedColor: true` + `lockColorManagement: true`.
|
2. On macOS, Preferences / Print can spawn TargetPrint with a `TargetJob` built from `PrintOptions` + TIFF list + `forceUnmanagedColor: true` + `lockColorManagement: true`.
|
||||||
3. Do not `CREATE_NO_WINDOW` (macOS); do not `wait()`. Cleanup of the JSON is ICCery's job after process exit, or leave in `/tmp` as an audit trail (SPEC §13).
|
3. Do not `CREATE_NO_WINDOW` (macOS); do not `wait()`. Cleanup of the JSON is ICCery's job after process exit, or leave in `/tmp` as an audit trail (SPEC §13).
|
||||||
|
|
||||||
@@ -186,14 +186,14 @@ Suggested ICCery integration:
|
|||||||
|
|
||||||
| Concern | ICCery `macos.rs` | TargetPrint | Rewrite recommendation |
|
| Concern | ICCery `macos.rs` | TargetPrint | Rewrite recommendation |
|
||||||
|---------|-------------------|-------------|------------------------|
|
|---------|-------------------|-------------|------------------------|
|
||||||
| Spool | `lp` TIFF | Quartz `NSPrintOperation` | Keep `lp` for unattended; TargetPrint for preview+panel |
|
| Spool | `lp` TIFF | Quartz `NSPrintOperation` | Quartz `NSPrintOperation` — **adopted in v2.0 via #201** (`NativeTargetSpooler`; `lp` removed from the target-print path) |
|
||||||
| ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** if using NSPrintOperation; keep AP_* on `lp` |
|
| ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** — **adopted in v2.0 via #201** (D2: the single native path carries AP_* and the Quartz §7 vocabulary) |
|
||||||
| Lock PDE UI | private `PMSessionSetColorMatchingMode*` SPI | strip Color Matching accessories | Use SPI **and** strip; accessories API misses driver PDEs (the #188 failure mode) |
|
| Lock PDE UI | private `PMSessionSetColorMatchingMode*` SPI | strip Color Matching accessories | Use SPI **and** strip; accessories API misses driver PDEs (the #188 failure mode) |
|
||||||
| Canon off | `CNIJIntent2=4` | `CNColorMatching=None` | Apply both |
|
| Canon off | `CNIJIntent2=4` | `CNColorMatching=None` | Apply both |
|
||||||
| Epson off | `EPIJ_CMat=3` / `EPIJ_CCor=0` | `EPSONColorControls=Off` + `ColorModel=RGB` | Apply both; prefer captured panel values |
|
| Epson off | `EPIJ_CMat=3` / `EPIJ_CCor=0` | `EPSONColorControls=Off` + `ColorModel=RGB` | Apply both; prefer captured panel values |
|
||||||
| Geometry | none (filter decides) | 1:1 pt from DPI | TargetPrint (or do not scale in GDI/`lp`) |
|
| Geometry | none (filter decides) | 1:1 pt from DPI | 1:1 pt from DPI — **adopted in v2.0 via #201** (`TargetRasterLoader`/`TargetPageCanvasView`, docs/14 §6) |
|
||||||
| Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges |
|
| Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges — **adopted in v2.0 via #201** (interpolation/antialias off in `TargetPageCanvasView.draw`) |
|
||||||
| AirPrint | none | detected + warned | Port detector into ICCery printer list |
|
| AirPrint | none | detected + warned | Ported into ICCery — **adopted in v2.0 via #201/#202** (`lpstat -v` + PPD §10.2 rules, Stage 2 `airPrintWarningBadge`) |
|
||||||
| Linux | `-o raw` | n/a (macOS only) | Keep raw + PPD fallback |
|
| Linux | `-o raw` | n/a (macOS only) | Keep raw + PPD fallback |
|
||||||
| Windows | GDI ICM_OFF | n/a | Keep GDI; do not route through TargetPrint |
|
| Windows | GDI ICM_OFF | n/a | Keep GDI; do not route through TargetPrint |
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ Lock these before rewriting UI:
|
|||||||
4. `parseProfcheckReport` JSON + legacy + empty→warning
|
4. `parseProfcheckReport` JSON + legacy + empty→warning
|
||||||
5. Color bypass detector: Canon/Epson/Gutenprint samples
|
5. Color bypass detector: Canon/Epson/Gutenprint samples
|
||||||
6. `filter_cups_options_string` drops `com.apple.*`, keeps `EPIJ_CMat`
|
6. `filter_cups_options_string` drops `com.apple.*`, keeps `EPIJ_CMat`
|
||||||
7. `build_lp_args` always emits both AP_* keys
|
7. `build_lp_args` always emits both AP_* keys (**historical v1** — superseded by `TicketWriteResolverTests`, #201)
|
||||||
8. Threshold validation `good < warning`
|
8. Threshold validation `good < warning`
|
||||||
9. `snapshot_ti3` 1-based and removes canonical
|
9. `snapshot_ti3` 1-based and removes canonical
|
||||||
10. DEVMODE round-trip size
|
10. DEVMODE round-trip size
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
|
|
||||||
## Locked product decisions
|
## Locked product decisions
|
||||||
|
|
||||||
|
> **Errata (M12, #201):** item 4's `lp` spool was replaced in v2.0 by a
|
||||||
|
> headless `NSPrintOperation` replaying the captured `PMPrintSettings`
|
||||||
|
> ticket, and the Quartz vocabulary of item 5 is now written **alongside**
|
||||||
|
> AP_* on the single native path (D2). "Never mix" no longer applies inside
|
||||||
|
> ICCery proper; it still governs the future `ICCeryPrintKit` boundary.
|
||||||
|
|
||||||
1. **Stack:** SwiftUI (`@Observable`, `@MainActor` view models) + AppKit for printing/panels. No Tauri, no Rust, no WebView.
|
1. **Stack:** SwiftUI (`@Observable`, `@MainActor` view models) + AppKit for printing/panels. No Tauri, no Rust, no WebView.
|
||||||
2. **Floor:** macOS 14.0, universal `arm64` + `x86_64`.
|
2. **Floor:** macOS 14.0, universal `arm64` + `x86_64`.
|
||||||
3. **AGPL:** never link Argyll. Spawn with piped stdio + `ARGYLL_NOT_INTERACTIVE=1` on **every** child (streaming and captured).
|
3. **AGPL:** never link Argyll. Spawn with piped stdio + `ARGYLL_NOT_INTERACTIVE=1` on **every** child (streaming and captured).
|
||||||
@@ -120,7 +126,7 @@ Issues 1–6.
|
|||||||
**Hardware:** none.
|
**Hardware:** none.
|
||||||
Issues 7–11.
|
Issues 7–11.
|
||||||
|
|
||||||
### M3 — macOS unmanaged printing (`lp` path)
|
### M3 — macOS unmanaged printing (`lp` path) — historical v1, superseded by #201
|
||||||
|
|
||||||
**CI/mock:** `lpstat`/`lpoptions` parsers; `build_lp_args` golden vectors (both `AP_*` always present); option filter; cancel → nil.
|
**CI/mock:** `lpstat`/`lpoptions` parsers; `build_lp_args` golden vectors (both `AP_*` always present); option filter; cancel → nil.
|
||||||
**Hardware:** Preferences opens **driver PDE** on a real Epson or Canon queue; colour matching off/grayed; printed TIFF measures unmanaged (no ColorSync transform).
|
**Hardware:** Preferences opens **driver PDE** on a real Epson or Canon queue; colour matching off/grayed; printed TIFF measures unmanaged (no ColorSync transform).
|
||||||
@@ -376,7 +382,7 @@ Six layers (spec [11](11-print-macos.md) roster):
|
|||||||
- Deps: 12, 13.
|
- Deps: 12, 13.
|
||||||
- Test CI: injectable dlsym order; filter fixtures. Hardware: PDE colour grayed/off on Epson **and** Canon.
|
- Test CI: injectable dlsym order; filter fixtures. Hardware: PDE colour grayed/off on Epson **and** Canon.
|
||||||
|
|
||||||
**Issue 15 — `lp` spool path**
|
**Issue 15 — `lp` spool path — historical v1, superseded by #201 native spool**
|
||||||
Labels: `Feature/Backend`, `Priority/High`
|
Labels: `Feature/Backend`, `Priority/High`
|
||||||
Milestone: M3
|
Milestone: M3
|
||||||
|
|
||||||
@@ -583,7 +589,7 @@ Milestone: **Later**
|
|||||||
|
|
||||||
- Separate Swift package `ICCeryPrintKit`. **Zero** deps on wizard types.
|
- Separate Swift package `ICCeryPrintKit`. **Zero** deps on wizard types.
|
||||||
- Public API: `TargetJob` v1 JSON + `--job` CLI (fire-and-forget) **and** in-process `NSPrintOperation`. Preserve extractability to a standalone app.
|
- Public API: `TargetJob` v1 JSON + `--job` CLI (fire-and-forget) **and** in-process `NSPrintOperation`. Preserve extractability to a standalone app.
|
||||||
- ColorSync vocabulary is **not** the `lp` path: `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, legacy `com.apple.print.PrintSettings.PMColorMatchingMode`. **Never mix with `AP_ApplicationColorMatching`.**
|
- ColorSync vocabulary is **not** the `lp` path: `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, legacy `com.apple.print.PrintSettings.PMColorMatchingMode`. **Never mix with `AP_ApplicationColorMatching`** — amended by #201 (D2): with `lp` gone, ICCery's single native path writes **both** vocabularies; this constraint now governs only the future `ICCeryPrintKit` boundary.
|
||||||
- Vendor keys (separate table from issue 14): Epson `ColorModel=RGB` + `EPSONColorControls=Off`; Canon `CNColorMatching=None`; HP `ColorModel=RGB` + `HPColorControl=Off`.
|
- Vendor keys (separate table from issue 14): Epson `ColorModel=RGB` + `EPSONColorControls=Off`; Canon `CNColorMatching=None`; HP `ColorModel=RGB` + `HPColorControl=Off`.
|
||||||
- Geometry: 72pt=1in, no `backingScaleFactor`, interpolation `.none`, antialias off, pixel-integrity seam test. Resolve SPEC contradiction: job JSON `"centered": true` vs draw “no centering” — **lock “no centering, scale 1.0” for profiling targets.**
|
- Geometry: 72pt=1in, no `backingScaleFactor`, interpolation `.none`, antialias off, pixel-integrity seam test. Resolve SPEC contradiction: job JSON `"centered": true` vs draw “no centering” — **lock “no centering, scale 1.0” for profiling targets.**
|
||||||
- AirPrint detection → persistent warning.
|
- AirPrint detection → persistent warning.
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Issue #201 — Printer quality and media type ignored when printing target
|
||||||
|
|
||||||
|
> Analysis doc referenced by issue **[Bug/Critical] Printer quality and media
|
||||||
|
> type settings ignored when printing target — lp path missing PMPrintSettings
|
||||||
|
> ticket** (#201). Line numbers refer to `develop` @ `0513b27` (pre-M12 code,
|
||||||
|
> verified 2026-09-17). The fix landed on `milestone/m12-native-spool` — see
|
||||||
|
> "Resolution" below.
|
||||||
|
|
||||||
|
## 0. Lifecycle trace — what the code did (pre-#201, historical v1)
|
||||||
|
|
||||||
|
```
|
||||||
|
Stage2View "btnPrinterProperties"
|
||||||
|
→ PrintSessionViewModel.openPrinterPreferences() :141
|
||||||
|
→ PrintPanelService.showProperties() :59
|
||||||
|
→ runNativePanel() :85
|
||||||
|
① PMPrinterCreateFromPrinterID + PMSessionSetCurrentPMPrinter :96–113
|
||||||
|
▸ applyInitialSelections (PMPrintSettingsSetValue ×4) :216
|
||||||
|
▸ applyPaperPageFormat (PMPaper match → PMCopyPageFormat) :258
|
||||||
|
②–⑤ ColorSyncSuppressor SPI / AP_* / bypass / mirror :152–163
|
||||||
|
NSPrintPanel.runModal("Use Settings") :173
|
||||||
|
⑥ PMPrintSettingsToOptions → CupsOptionsFilter → String :185–191
|
||||||
|
✗ THE TICKET IS DISCARDED HERE — only the flattened `k=v` string survives
|
||||||
|
→ capturedCupsOptions[queue] = String :174
|
||||||
|
|
||||||
|
Stage2View "btnPrintAll"
|
||||||
|
→ PrintSessionViewModel.printAllPages(from:) :205
|
||||||
|
→ spool(page, index:) :263
|
||||||
|
→ CupsService.printTarget() CupsService.swift:170
|
||||||
|
→ LpArgs.build() LpArgs.swift:42
|
||||||
|
→ ProcessManager.runCaptured("/usr/bin/lp", argv)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Root causes (pre-#201, historical v1)
|
||||||
|
|
||||||
|
Two defects, both confirmed by reading the code:
|
||||||
|
|
||||||
|
### 1. Ticket loss
|
||||||
|
|
||||||
|
`ColorSyncSuppressor.captureOptions` (:133) was the only capture path. It
|
||||||
|
funneled the whole `PMPrintSettings` object through `PMPrintSettingsToOptions`
|
||||||
|
→ a space-separated `key=value` string, then `CupsOptionsFilter.filter`
|
||||||
|
**dropped every `com.apple.*` key** (`CupsOptionsFilter.swift:49`) — i.e. it
|
||||||
|
deliberately discarded `com.apple.print.PrintSettings`, the ticket the
|
||||||
|
Epson/Canon raster filter reads. `lp` cannot reconstruct it, so the driver
|
||||||
|
fell back to plain paper / normal quality.
|
||||||
|
|
||||||
|
### 2. Override inversion
|
||||||
|
|
||||||
|
`LpArgs.build` recorded every captured key in `addedKeys` (:67–73) and then
|
||||||
|
*suppressed* the explicit Stage 2 value when the key was already present —
|
||||||
|
media (:81), quality (:90), bypass (:102), orientation (:109), `PageSize`
|
||||||
|
(:117). A Stage 2 dropdown change after a panel capture was silently ignored.
|
||||||
|
|
||||||
|
### Dismissed hypothesis
|
||||||
|
|
||||||
|
**argv tokenisation is safe** — `ProcessManager.runCaptured` hands an argv
|
||||||
|
array to `Process`, never a shell. Space-containing `-o` values were
|
||||||
|
preserved; this was checked and dismissed in the issue investigation and
|
||||||
|
re-verified during the M12 audit.
|
||||||
|
|
||||||
|
## Locked decisions (D1–D13)
|
||||||
|
|
||||||
|
Decisions taken by the M12 megaplan; **locked — do not re-derive.**
|
||||||
|
(`lp`/`LpArgs` mentions in this table name the deleted historical v1 path.)
|
||||||
|
|
||||||
|
| # | Decision | Consequence |
|
||||||
|
|---|----------|-------------|
|
||||||
|
| D1 | Spooler lives in the **app target** (`Sources/ICCery/Print/`), not in `CupsService` | `ICCeryCore` stays AppKit-free (AGENTS §Package layout). `CupsService.printTarget` is **deleted**; `CupsService` keeps enumeration/capabilities/PPD only. The issue body's "in `CupsService`" is superseded — posted as errata on #201 |
|
||||||
|
| D2 | Write **both** ColorSync vocabularies on the native path | AP_* (locked) **and** Quartz `PMColorMatchingMode=APCustomColorMatching` + `PMCustomColorMatchingProfile=""` + legacy `com.apple.print.PrintSettings.PMColorMatchingMode` + the nested `com.apple.print.printSettings` mirror. AGENTS §"Private ColorSync SPI — never mix" is **amended**: with `lp` gone there is one path and it carries both dictionaries (docs/14 decision table, "Set both if using NSPrintOperation") |
|
||||||
|
| D3 | Ticket = `PMPrintSettings` **Data** + `PMPageFormat` **Data** + an `NSPrintInfo.dictionary()` plist fallback | `kPMDataFormatXMLDefault`. Restored via `PM*CreateWithDataRepresentation` → `PMCopy*` → `PMSessionValidate*` → `updateFromPM*`. Byte round-trip is unit-testable |
|
||||||
|
| D4 | Delete `lp`; **keep** `CupsParsers` + `CupsOptionsFilter` | Parsers still drive capabilities (#183/#180/#181), media/quality key detection, driver-bypass detection, and the panel→Stage-2 apply-back mirror (#186). Only `LpArgs` dies |
|
||||||
|
| D5 | Job granularity is **user-selectable**, default **one job per page** | New session-only `@Published var singleJobForAllPages = false`; Stage 2 checkbox `chkSingleSpoolJob`. Default preserves today's per-page notices and error attribution |
|
||||||
|
| D6 | **Stage 2 always wins** over the rehydrated ticket | Unconditional overwrite of paper / media / quality / orientation. Mitigation for vendor companion-key desync: only write when the value differs from the ticket's current value, and log both (R4) |
|
||||||
|
| D7 | Spool **silently** | `showsPrintPanel = false`, `showsProgressPanel = false`, `canSpawnSeparateThread = false`. Feedback stays on the existing `isPrinting` + `Notice`. No new system modal → XCUITest unaffected |
|
||||||
|
| D8 | Verification = **recorder seam + PDF harness** | DEBUG `RecordingTargetSpooler` writes resolved ticket lines to `ICCERY_TEST_SPOOL_LOG` (replaces `ICCERY_TEST_LP_ARGV`); a real `NSPrintOperation` with `jobDisposition = .save` produces a PDF for geometry assertions |
|
||||||
|
| D9 | Never scale; **warn + spool** | 1:1 always, anchored at the paper's top-left. `warn` when the DPI-derived size disagrees with the manifest `width_mm`/`height_mm` by >0.5 mm; warning `Notice` when the image exceeds the paper |
|
||||||
|
| D10 | Device colour spaces implemented **and verified** for 1/3/4 channels at 8 and 16 bpc | Re-tag the decoded `CGImage` into `DeviceGray`/`DeviceRGB`/`DeviceCMYK` reusing the source `dataProvider` — **no resample, no bit-depth change**. Hardware gate covers RGB-8, RGB-16, DeviceGray-8 and CMYK-16 |
|
||||||
|
| D11 | AirPrint detection + Stage 2 banner **in scope**, as its own issue | docs/14 §10.2 rules. Blocked-by #201 → filed as #202 |
|
||||||
|
| D12 | Extract a **shared** `PMTicketBridge` | Single `@MainActor enum` owning every `PM*` call, with one documented `PMRelease` rule. `PrintPanelService` migrates onto it |
|
||||||
|
| D13 | New milestone **M12 — Native print spool** | `milestone/m12-native-spool` from `develop`; #201 moves off the shipped M11 (id 34) |
|
||||||
|
|
||||||
|
## Resolution
|
||||||
|
|
||||||
|
Implemented on `milestone/m12-native-spool` (Phases 1–5, PRs #203–#207).
|
||||||
|
(`LpArgs`/`lp` references below name deleted historical v1 artefacts.)
|
||||||
|
|
||||||
|
- `PMTicketBridge` owns every `PM*` call; `PrintPanelService.showProperties`
|
||||||
|
returns `PanelCaptureResult` carrying a `PrintTicket` (layer ⑦ serialise).
|
||||||
|
- `NativeTargetSpooler` rehydrates the ticket into a fresh `NSPrintInfo`
|
||||||
|
(S1–S14, docs/11 §native spool) and spools a headless `NSPrintOperation`
|
||||||
|
over `TargetPageCanvasView` — 1:1, top-left anchored, interpolation off.
|
||||||
|
- `TicketWriteResolver` replaces `LpArgs.build`: Stage 2 always wins (D6),
|
||||||
|
both ColorSync vocabularies written (D2), no `raw` can ever appear.
|
||||||
|
- `LpArgs`, `CupsService.printTarget`, the `lp` fixture and
|
||||||
|
`ICCERY_TEST_LP_ARGV` are deleted; `RecordingTargetSpooler` writes to
|
||||||
|
`ICCERY_TEST_SPOOL_LOG` for tests (D8).
|
||||||
|
- AirPrint detection + Stage 2 warning badge shipped as #202 (D11).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `Sources/ICCery/Print/{PMTicketBridge,PrintTicket,NativeTargetSpooler,TicketWriteResolver,TargetRaster,TargetPageCanvasView}.swift`
|
||||||
|
- `Packages/ICCeryCore/Sources/ICCeryCore/Print/{CupsService,CupsParsers,CupsOptionsFilter,PrinterModels,ColorMatchingAttempts}.swift`
|
||||||
|
- `docs/11-print-macos.md` §ColorSync suppression — UI click to `NSPrintOperation`
|
||||||
|
- `docs/14-iccery-cpu-targetprint.md` §6–§7 (geometry + Quartz vocabulary)
|
||||||
|
- M12 megaplan (`plan-be303e6f3f5e89da`)
|
||||||
+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.1"
|
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