Compare commits

...
Author SHA1 Message Date
gronod 60f3aa3991 Merge pull request 'docs(print): native spool documentation update + issue #201 analysis (#201 Phase 6)' (#208) from feat/201-docs into milestone/m12-native-spool
macOS CI / build-and-test (push) Skipped
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 32m12s
2026-09-17 18:02:12 +01:00
gronod b89d9777ad docs(print): native spool documentation update + issue #201 analysis (#201 Phase 6)
macOS CI / build-and-test (push) Skipped
- Create docs/issue-printer-quality-ignored.md: lifecycle trace, ticket-loss and override-inversion root causes with file:line evidence, dismissed argv tokenisation hypothesis, locked D1-D13 table.
- docs/11: retitle walkthrough to UI click to NSPrintOperation; replace build_lp_args/print_target with the S1-S14 native-spool trace; add layers 5' (Quartz vocabulary) and 7 (ticket serialise/restore); mark lp flag table and macOS test list historical v1.
- docs/14: decision table rows (Spool, ColorSync ticket, Geometry, Interpolation, AirPrint) marked adopted in v2.0 via #201; note section 7 Quartz vocabulary now live in ICCery proper.
- AGENTS.md: spooler documented in app target (D1); new Print spool section (lp eradicated, parsers retained per D4, ICCERY_TEST_SPOOL_LOG seam); ColorSync SPI rule replaced by the D2 single-path dual-vocabulary rule.
- Eradication sweep: label remaining lp/LpArgs references historical v1 in docs/10, docs/13, docs/25, docs/26; drop deleted LpArgs from README.
2026-09-17 17:40:25 +01:00
gronod 7a3a0886be Merge pull request 'feat(print): AirPrint queue detection + Stage 2 warning badge (#202 Phase 5)' (#207) from feat/202-airprint-detection into milestone/m12-native-spool
macOS CI / build-and-test (push) Skipped
2026-09-17 17:31:09 +01:00
gronod 5389e4c2bd feat(print): AirPrint queue detection + Stage 2 warning badge (#202 Phase 5)
macOS CI / build-and-test (push) Skipped
- CupsParsers: lpstatDeviceURIs / lpoptionsMakeAndModel /
  detectAirPrint — the six docs/14 §10.2 rules (apple-airprint:// URI,
  *APAirPrint: True, Apple+AirPrint make-and-model, ipps://+PPD
  mention, ipp:// + _universal mDNS subtype, cupsFilter2→image/urf).
- CupsService.listPrinters: one tolerated lpstat -v spawn feeds
  detection; lpoptions -p yields displayName + makeAndModel in a
  single spawn; Printer.isAirPrint populated per queue.
- Printer.isAirPrint defaults false with a tolerant decoder.
- Stage2View: live airPrintWarningBadge — "AirPrint queue —
  unmanaged colour cannot be guaranteed."
- Fixtures: lpstat -v marks Mock_Canon_Pro via _universal subtype;
  lpoptions emits printer-make-and-model.
- Tests: six rule fixtures + USB negative + a no-fallback-respawn
  listPrinters case; Milestone12AirPrintUITests tracks the badge
  across queue selection.

Gate: full xcodebuild test green (515 unit + 70 UI).
2026-09-17 17:20:25 +01:00
gronod 972368aa85 Merge pull request 'feat(print): native NSPrintOperation spooler, eradicate lp (#201 Phase 4)' (#206) from feat/201-native-print-operation into milestone/m12-native-spool
macOS CI / build-and-test (push) Skipped
2026-09-17 16:35:44 +01:00
gronod 4f3caf335d feat(print): native NSPrintOperation spooler, eradicate lp (#201 Phase 4)
macOS CI / build-and-test (push) Skipped
Replace the LpArgs argv builder with the deterministic
TicketWriteResolver (locked 1-10 write order, Stage 2 overrides win
unconditionally, no raw, nil overrides emit no write) and spool via a
headless NSPrintOperation over TargetPageCanvasView instead of the
CUPS lp binary. RecordingTargetSpooler (DEBUG) logs deterministic
request lines to ICCERY_TEST_SPOOL_LOG for tests. Single spool job
toggle switches printAllPages between per-page requests and one
multi-page job. CupsService keeps lpstat/lpoptions enumeration but
loses printTarget; ProcessID.lp* renamed cupsLp*; LpArgs, its tests
and the lp fixture deleted.
2026-09-17 16:30:27 +01:00
gronod de142a9690 Merge pull request 'feat(print): 1:1 TIFF raster loader and headless page canvas (#201 Phase 3)' (#205) from feat/201-raster-canvas into milestone/m12-native-spool
macOS CI / build-and-test (push) Skipped
2026-09-17 15:08:06 +01:00
gronod a2f994821f feat(print): 1:1 TIFF raster loader and headless page canvas (#201 Phase 3)
macOS CI / build-and-test (push) Skipped
TargetRasterLoader decodes printtarg TIFFs via ImageIO and re-tags
into DeviceGray/DeviceRGB/DeviceCMYK around the decoder's own
dataProvider — no resample, no bit-depth change (D10). Skip-only
alpha padding (noneSkipLast/First, which ImageIO emits for opaque
RGB TIFFs) is accepted; real coverage alpha, indexed, float and
non-1/3/4-component layouts are rejected as unsupportedLayout.
DPI metadata drives pointSize (px/dpi*72) with a 72 dpi fallback +
warn; manifestDrift reports >0.5 mm disagreement with the printtarg
manifest (D9).

TargetPageCanvasView is the headless flipped canvas NSPrintOperation
paginates: rectForPage stacks pages top-down, destinationRect anchors
1:1 at the page's top-left snapped to 0.001 pt, and draw() forces
interpolation/antialias/font-smoothing off per page with a drawProbe
seam for tests.

PDF harness (NativeSpoolPDFTests, skippable via ICCERY_SKIP_PRINT_PDF=1)
ran on this host: run() -> true, 2 pages at A4 media box, and the
fixture's 72 pt patch verified 1:1 at the painted top-left (printer
unprintable-margin offset tolerated per R10).
2026-09-17 14:57:24 +01:00
gronod 1d6032456d Merge pull request 'feat(print): capture native print ticket on panel OK (#201 Phase 2)' (#204) from feat/201-ticket-capture into milestone/m12-native-spool
macOS CI / build-and-test (push) Skipped
2026-09-17 13:50:45 +01:00
gronod fc1530ab2e feat(print): capture native print ticket on panel OK (#201 Phase 2)
macOS CI / build-and-test (push) Skipped
Serialise PMPrintSettings/PMPageFormat to XML data plus a plist-safe
NSPrintInfo dictionary snapshot (PrintTicket), restore via
PM*CreateWithDataRepresentation -> PMCopy* -> PMSessionValidate* ->
updateFromPM*, and refuse cross-queue replay (R3).

PrintPanelService.showProperties now returns PanelCaptureResult; the
view model stores tickets in capturedTickets keyed by queue.

Also fixes a latent over-release: PMSessionGetCurrentPrinter hands back
the session's own printer (borrowed) — the extracted currentPrinterID
was PMRelease-ing it, which dangled the session and crashed AppKit's
_printerInPrintSession path and session teardown.
2026-09-17 13:43:43 +01:00
gronod ffbe56f43a Merge pull request 'refactor(print): extract PMTicketBridge from PrintPanelService (#201 Phase 1)' (#203) from feat/201-pm-ticket-bridge into milestone/m12-native-spool
macOS CI / build-and-test (push) Skipped
2026-09-17 12:16:26 +01:00
gronod ee5da4a637 refactor(print): extract PMTicketBridge from PrintPanelService
macOS CI / build-and-test (push) Skipped
Centralise every Core Printing (PM*) call in one @MainActor bridge so
the upcoming ticket serialise/restore path and native spooler share a
single documented ownership policy. Pure move — no behaviour change.
2026-09-17 12:15:27 +01:00
gronod 0513b272cb docs(agents): add issue ticket style and dependency tracking guidelines
macOS CI / package (push) Skipped
macOS CI / build-and-test (push) Skipped
2026-09-17 10:34:50 +01:00
gronod 555bf8fda4 chore(release): bump MARKETING_VERSION to 2.0.1
macOS CI / build-and-test (push) Successful in 7s
macOS CI / package (push) Failing after 44s
2026-09-16 06:40:31 +01:00
gronod f3c7b19694 docs(m11): mark milestone shipped on develop
macOS CI / build-and-test (push) Failing after 45m36s
macOS CI / package (push) Skipped
Refs #183

Generated with [Devin](https://devin.ai)
2026-09-16 01:49:12 +01:00
gronod 5e696ff3e9 Merge pull request 'Milestone 11 — Printer settings completeness & dialog binding (#183, #180, #181, #186)' (#199) from milestone/m11-print-settings into develop
macOS CI / build-and-test (push) Successful in 43m58s
macOS CI / package (push) Successful in 3m17s
2026-09-16 00:11:59 +01:00
gronod 19f619ad98 Merge branch 'develop' into chore/m11-sync-develop
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Successful in 42m51s
macOS CI / package (pull_request) Skipped
2026-09-15 21:52:15 +01:00
gronod 09f466ae46 Merge pull request 'docs(ui): M11 stage-2 a11y id roster update' (#197) from chore/m11-docs-hygiene into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
2026-09-15 21:52:06 +01:00
gronod d228594bd3 docs(ui): add M11 printer settings a11y ids to stage-2 roster
macOS CI / build-and-test (push) Skipped
Refs #183
2026-09-15 21:51:52 +01:00
gronod 6adbf99bd9 Merge pull request 'fix(print): bind selected printer and settings in NSPrintPanel (#186)' (#196) from feat/186-panel-binding into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
2026-09-15 20:41:12 +01:00
gronodandDevin d8c41444b9 fix(print): bind selected printer and settings in NSPrintPanel (#186)
macOS CI / build-and-test (push) Skipped
Refs #186

Co-Authored-By: Devin <devin@cognition.ai>
2026-09-15 20:40:23 +01:00
gronod 1767792f75 Merge pull request 'fix(print): deterministic PPD locale precedence for media labels (#181)' (#195) from feat/181-canon-media-locale into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
2026-09-15 19:27:47 +01:00
gronod 62a45d9c7d Merge pull request 'Milestone/m11 print settings' (#192) from milestone/m11-print-settings into develop
macOS CI / build-and-test (push) Successful in 37m41s
macOS CI / package (push) Successful in 4m9s
Reviewed-on: #192
2026-09-15 18:11:39 +01:00
47 changed files with 3699 additions and 665 deletions
+44 -3
View File
@@ -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)"
@@ -60,7 +96,12 @@ Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for rel
## 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):
+1 -1
View File
@@ -23,7 +23,7 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
| M11 | Printer settings completeness & dialog binding | 183, 180, 181, 186 | Quality/media/paper caps parse + `LpArgs` emit + picker UI smoke + panel capture-return | Real Epson: 7 `EPIJ_Qual` options; real Canon Pro9500: English media labels; dialog bound to non-default queue with paper/quality/media/orientation preselected; captured settings reach `lp` argv | | M11 | Printer settings completeness & dialog binding | 183, 180, 181, 186 | Quality/media/paper caps parse + `LpArgs` emit + picker UI smoke + panel capture-return | Real Epson: 7 `EPIJ_Qual` options; real Canon Pro9500: English media labels; dialog bound to non-default queue with paper/quality/media/orientation preselected; captured settings reach `lp` argv |
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF | | Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
M8–M10 are merged to `develop`; M11 assembles on `milestone/m11-print-settings` from `736fd08`. M8–M11 are merged to `develop`; M11 shipped via PR #199 (hardware gate passed on real Epson XP-55 / Canon Pro9500 II).
Issue **16 is not an M3, M6, M10, or M11 exit gate.** Issue **16 is not an M3, M6, M10, or M11 exit gate.**
@@ -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`.
@@ -266,13 +296,20 @@ public enum CupsParsers {
} }
/// Media type from a captured `key=value key=value` options string. /// Media type from a captured `key=value key=value` options string.
/// Prefers `MediaType`, then `EPIJ_Medi` (docs/11 §tests). /// Prefers `MediaType` (docs/11 §tests), then the remaining roster
/// keys in detection order — `CNIJMediaType`, `EPIJ_Medi`,
/// `StpMediaType` (#186 capture-return).
public static func extractMediaType(fromOptionsString options: String) -> String? { public static func extractMediaType(fromOptionsString options: String) -> String? {
let pairs = lpoptions(options) let pairs = lpoptions(options)
if let v = pairs.first(where: { $0.key == "MediaType" })?.value { if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
return v return v
} }
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value for key in mediaTypeKeys where key != "MediaType" {
if let v = pairs.first(where: { $0.key == key })?.value {
return v
}
}
return nil
} }
/// Print-quality option key in preference order — vendor-first, /// Print-quality option key in preference order — vendor-first,
@@ -345,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,7 +109,7 @@ 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)
} }
@@ -162,27 +181,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)")
}
}
}
@@ -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
} }
} }
@@ -111,24 +127,24 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
} }
} }
/// 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)" }
} }
+9 -8
View File
@@ -26,7 +26,7 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
| M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped | | M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped |
| M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped | | M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped |
| M10 | Studio workflow: media library (#146), gamut compare (#147), Spot Read (#148), project files (#149) — shipped on `develop` | | M10 | Studio workflow: media library (#146), gamut compare (#147), Spot Read (#148), project files (#149) — shipped on `develop` |
| M11 | Printer settings completeness & dialog binding (#183, #180, #181, #186) — in flight on `milestone/m11-print-settings` | | M11 | Printer settings completeness & dialog binding (#183, #180, #181, #186) — shipped on `develop` (PR #199) |
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 | | Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
## What it does ## What it does
@@ -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).
@@ -299,9 +300,9 @@ fix/<issue>-<slug>
``` ```
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
branch is used only while that milestone is assembling — currently branch is used only while that milestone is assembling; `milestone/m10-studio`
`milestone/m11-print-settings` (`milestone/m10-studio` was merged and and `milestone/m11-print-settings` have been merged (branches retained). Do
deleted). Do not open umbrella “bugfix” branches that mix tickets. not open umbrella “bugfix” branches that mix tickets.
`main` is push-protected and requires status check `main` is push-protected and requires status check
`macOS CI / build-and-test (push)`. Protected **file** patterns on `main` `macOS CI / build-and-test (push)`. Protected **file** patterns on `main`
+5 -3
View File
@@ -106,12 +106,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
+54 -1
View File
@@ -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
+389
View File
@@ -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, &current) == 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)
}
}
+95 -84
View File
@@ -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)'."
} }
@@ -18,18 +15,21 @@ enum PrintPanelError: LocalizedError {
} }
/// Stage 2 selections pre-applied to the bound print panel before it /// Stage 2 selections pre-applied to the bound print panel before it
/// opens (#183). This phase consumes `paperSize` + `qualityKey`/ /// opens (#183/#186). Every write is warn-only — the panel still
/// `quality` only; `mediaType` and `orientation` preselect — and the /// opens when a driver ignores a key.
/// `PMPageFormat`/`PMPaper` half of paper — are #186's scope.
struct PrintPanelInitialSelections { struct PrintPanelInitialSelections {
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. /// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written
/// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1).
var paperSize: String? var paperSize: String?
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`. /// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
var qualityKey: String? var qualityKey: String?
/// The selected quality token. /// The selected quality token.
var quality: String? var quality: String?
var mediaType: String? // #186 consumes /// The selected media token — written to the queue's detected
var orientation: String? // #186 consumes /// vendor key (`CNIJMediaType`/`EPIJ_Medi`/…) (#186).
var mediaType: String?
/// `"portrait"`/`"landscape"` → `orientation-requested` 3|4 (#186).
var orientation: String?
} }
/// Preferences → native `NSPrintPanel` bound to the selected CUPS /// Preferences → native `NSPrintPanel` bound to the selected CUPS
@@ -59,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.
@@ -84,36 +86,43 @@ 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, #183). // ColorSync suppression ②–⑤ (locked write order,
applyInitialSelections(initialSelections, to: settings) // #183/#186). Paper is TWO writes (E1): the `PageSize`
// print-settings value drivers/capture read AND the
// `PMPageFormat` paper the panel's dropdown reflects.
applyInitialSelections(
initialSelections, to: settings, optionKeys: optionKeys)
if let paperToken = initialSelections.paperSize {
PMTicketBridge.applyPaper(
token: paperToken, printer: printer,
session: session, printInfo: printInfo)
}
boundViaPM = true boundViaPM = true
} else { } else {
// Fallback: NSPrinter by display name (docs/11 §binding). // Fallback: NSPrinter by display name (docs/11 §binding).
// Warn — the display name can resolve a *different* queue
// (#186 E2: diagnosable, not a proven defect).
AppLogger.shared.warn(
"Print panel: PM binding unavailable for '\(queue)' — "
+ "falling back to NSPrinter(displayName)")
guard let displayName, guard let displayName,
let nsPrinter = NSPrinter(name: displayName) let nsPrinter = NSPrinter(name: displayName)
else { else {
@@ -125,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) // ⑤
@@ -166,70 +174,73 @@ 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 size and /// Initial-selection `PMPrintSettings` writes — paper, quality,
/// quality only this phase; media type / orientation and the /// media type, orientation. All warn-only: a driver that ignores
/// `PMPageFormat`/`PMPaper` paper half are #186's contract. /// a key must not keep the panel from opening (R12 surfaces via
/// the capture echo instead).
private func applyInitialSelections( private func applyInitialSelections(
_ selections: PrintPanelInitialSelections, _ selections: PrintPanelInitialSelections,
to settings: PMPrintSettings to settings: PMPrintSettings,
optionKeys: Set<String>
) { ) {
if let paperSize = selections.paperSize { if let paperSize = selections.paperSize {
_ = PMPrintSettingsSetValue( PMTicketBridge.setValue(
settings, "PageSize" as CFString, paperSize, forKey: "PageSize", locked: false,
paperSize as CFString, false) in: settings, context: "Print panel")
} }
if let key = selections.qualityKey, let value = selections.quality { if let key = selections.qualityKey, let value = selections.quality {
_ = PMPrintSettingsSetValue( PMTicketBridge.setValue(
settings, key as CFString, value as CFString, false) value, forKey: key, locked: false,
in: settings, context: "Print panel")
}
// Media type via the queue's detected vendor key (#186).
if let mediaType = selections.mediaType,
let mediaKey = CupsParsers.detectMediaTypeKey(
optionKeys: optionKeys) {
PMTicketBridge.setValue(
mediaType, forKey: mediaKey, locked: false,
in: settings, context: "Print panel")
}
// Orientation — portrait=3, landscape=4 (CUPS IPP codes).
if let orientation = selections.orientation {
let code = orientation == "landscape" ? "4" : "3"
PMTicketBridge.setValue(
code, forKey: "orientation-requested", locked: false,
in: settings, context: "Print panel")
} }
} }
// 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, &current) == 0,
let printer = current
else { return fallback }
defer { PMRelease(pmObject(printer)) }
guard let id = PMPrinterGetID(printer)
else { return fallback }
return id.takeUnretainedValue() as String
}
} }
+132 -32
View File
@@ -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,7 +14,15 @@ 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?
@@ -23,13 +32,44 @@ final class PrintSessionViewModel: ObservableObject {
@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>?
@@ -131,7 +171,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() }
@@ -147,8 +188,8 @@ final class PrintSessionViewModel: ObservableObject {
paperSize: selectedPaperSizeToken, paperSize: selectedPaperSizeToken,
qualityKey: printerCaps.qualityKey, qualityKey: printerCaps.qualityKey,
quality: selectedQuality, quality: selectedQuality,
mediaType: nil, mediaType: selectedMediaType,
orientation: nil) orientation: printOrientation)
Task { @MainActor in Task { @MainActor in
do { do {
guard let result = try await PrintPanelService() guard let result = try await PrintPanelService()
@@ -164,29 +205,37 @@ 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): a dialog paper/quality change // Capture-return (#183/#186): a dialog paper/quality/
// updates the Stage 2 selections — never // orientation change updates the Stage 2 selections —
// `workflow.pageSize` (printtarg layout is sacred). // never `workflow.pageSize` (printtarg layout is
if let paper = result.options.paperSize, // sacred).
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 { if let quality = result.properties.options.quality {
selectedQuality = quality selectedQuality = quality
} }
if let orientation = result.properties.options.orientation {
printOrientation = orientation
}
printNotice = Notice( printNotice = Notice(
kind: .info, kind: .info,
text: "Settings captured for \(selectedPrinter).", text: "Settings captured for \(selectedPrinter).",
@@ -205,10 +254,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(
@@ -238,7 +308,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).",
@@ -256,25 +326,55 @@ 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, return TargetPrintRequest(
paperSize: selectedPaperSizeToken, queue: queue,
mediaType: selectedMediaType, displayName: printers.first { $0.name == queue }?.displayName,
quality: selectedQuality, pages: pages.map {
ppdUncorrectedPassthrough: true, TargetPrintPage(
cupsOptions: capturedCupsOptions[selectedPrinter]) url: $0.fileURL,
try await environment.cupsService.printTarget( expectedWidthMm: $0.page.widthMm,
queue: selectedPrinter, expectedHeightMm: $0.page.heightMm)
tiffPath: page.fileURL.path, },
options: options, jobTitle: "ICCery Target - \(title)",
page: index) ticket: capturedTickets[queue],
wizard.printerName = selectedPrinter overrides: TargetPrintOverrides(
paperSize: selectedPaperSizeToken,
mediaType: selectedMediaType,
qualityKey: printerCaps.qualityKey,
quality: selectedQuality,
orientation: printOrientation),
optionKeys: optionKeys)
} }
} }
+30
View File
@@ -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,86 @@
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")
}
/// Flipped: the CoreGraphics coordinate space is correctly oriented
/// top-down, so page 1 is the TOP rect.
override var isFlipped: Bool { true }
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 top-down.
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.minY),
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
}
}
+221
View File
@@ -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)
}
}
+45 -21
View File
@@ -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)`).
@@ -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 "
+207
View File
@@ -232,6 +232,42 @@ final class CupsParserTests: XCTestCase {
fromOptionsString: "PageSize=A4")) fromOptionsString: "PageSize=A4"))
} }
// MARK: - #186 capture-return
/// A captured `k=v` string maps to all four `PrintOptions`
/// fields — `PageSize`, the detected quality key,
/// `orientation-requested`, and a vendor media key (#186).
func testCapturedStringMapsAllFields() {
let captured =
"PageSize=A4 EPIJ_Qual=305 orientation-requested=4 CNIJMediaType=Photo"
XCTAssertEqual(CupsParsers.extractOption(
named: "PageSize", fromOptionsString: captured), "A4")
XCTAssertEqual(CupsParsers.extractQuality(
fromOptionsString: captured), "305")
XCTAssertEqual(CupsParsers.extractOrientation(
fromOptionsString: captured), "landscape")
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: captured), "Photo")
}
/// Vendor media keys beyond `MediaType`/`EPIJ_Medi` extract via
/// the detection roster — `CNIJMediaType`/`StpMediaType` included
/// (#186). `MediaType` still wins when present alongside them.
func testExtractMediaTypeRosterFallback() {
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "CNIJMediaType=PhotoPlus"), "PhotoPlus")
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "StpMediaType=Glossy"), "Glossy")
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "EPIJ_Medi=Photo"), "Photo")
// `MediaType` keeps first precedence (docs/11 §tests).
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "CNIJMediaType=PhotoPlus MediaType=Plain"),
"Plain")
XCTAssertNil(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4"))
}
func testDriverBypass() { func testDriverBypass() {
func pair(_ keys: Set<String>) -> String? { func pair(_ keys: Set<String>) -> String? {
CupsParsers.detectDriverColorBypass(optionKeys: keys) CupsParsers.detectDriverColorBypass(optionKeys: keys)
@@ -419,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)")
}
} }
-173
View File
@@ -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,164 @@
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 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
let pdfURL = tempRoot.appendingPathComponent("spool.pdf")
info.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = 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 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()
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)
}
}
@@ -69,6 +69,24 @@ final class PrintPanelStubTests: XCTestCase {
} }
} }
/// #186 — a vendor media key in the captured string reaches
/// `options.mediaType` through the detection roster
/// (`CNIJMediaType`/`StpMediaType`, not only `MediaType`).
func testOkResultExtractsVendorMediaKey() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS":
"PageSize=A4 orientation-requested=4 CNIJMediaType=Photo",
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(forQueue: "q")
XCTAssertEqual(result?.options.paperSize, "A4")
XCTAssertEqual(result?.options.orientation, "landscape")
XCTAssertEqual(result?.options.mediaType, "Photo")
}
}
func testOkDefaultsPrinter() throws { func testOkDefaultsPrinter() throws {
try withEnv([ try withEnv([
"ICCERY_UI_TESTING": "1", "ICCERY_UI_TESTING": "1",
@@ -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)
@@ -244,4 +279,149 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertEqual(workflow.print.selectedPaperSize, 4) XCTAssertEqual(workflow.print.selectedPaperSize, 4)
XCTAssertEqual(workflow.print.selectedQuality, "305") XCTAssertEqual(workflow.print.selectedQuality, "305")
} }
/// #186 — the captured `orientation-requested`/media token apply
/// back to `printOrientation`/`selectedMediaType`, and a dialog
/// result never mutates `workflow.pageSize` (printtarg layout).
func testPanelResultAppliesBackOrientationAndMedia() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS",
"orientation-requested=4 MediaType=Glossy", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
XCTAssertEqual(workflow.print.printOrientation, "portrait")
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.printOrientation, "landscape")
XCTAssertEqual(workflow.print.selectedMediaType, "Glossy")
XCTAssertEqual(workflow.pageSize, .a4)
}
/// #186 — a captured `PageSize` token with no capability match
/// leaves `selectedPaperSize` unchanged (never a guessed id).
func testPanelResultUnknownPaperLeavesSelection() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "PageSize=Bogus", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
}
/// #186 — a stub result pointing at another queue in `printers`
/// switches `selectedPrinter` and reloads its capabilities.
func testPanelResultSwitchesToKnownQueue() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "PageSize=Letter", 1)
setenv("ICCERY_TEST_PANEL_PRINTER", "Other_Q", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
unsetenv("ICCERY_TEST_PANEL_PRINTER")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
workflow.print.printers = [
Printer(name: "Mock_Q", isDefault: true),
Printer(name: "Other_Q"),
]
await loadCaps(workflow.print)
workflow.print.openPrinterPreferences()
await waitForNotice(
workflow.print, containing: "Settings captured for Other_Q")
XCTAssertEqual(workflow.print.selectedPrinter, "Other_Q")
// Caps reloaded for the new queue: paper re-seeded, then the
// captured PageSize applied back onto the new caps.
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
XCTAssertEqual(workflow.print.capturedCupsOptions["Other_Q"],
"PageSize=Letter")
}
/// #186 — a stub result naming a queue absent from `printers`
/// leaves the selection on the opened queue.
func testPanelResultGhostQueueIgnored() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_PRINTER", "Ghost_Q", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_PRINTER")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
workflow.print.printers = [Printer(name: "Mock_Q", isDefault: true)]
await loadCaps(workflow.print)
workflow.print.openPrinterPreferences()
await waitForNotice(
workflow.print, containing: "Settings captured for Mock_Q")
XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Q")
}
/// #186 — cancel returns `nil`: info notice, no field changes.
func testPanelCancelLeavesSelections() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "cancel", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
workflow.print.printOrientation = "landscape"
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "cancelled")
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
XCTAssertEqual(workflow.print.selectedQuality, "303")
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
XCTAssertEqual(workflow.print.printOrientation, "landscape")
XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty)
}
/// Poll until the panel task posts a notice whose text contains
/// `fragment` (the Task-completion signal for `nil` results too).
private func waitForNotice(
_ vm: PrintSessionViewModel,
containing fragment: String,
timeout: TimeInterval = 10
) async {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let text = vm.printNotice?.text, text.contains(fragment) {
return
}
try? await Task.sleep(nanoseconds: 100_000_000)
}
XCTFail("Timed out waiting for notice containing '\(fragment)'")
}
} }
@@ -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,170 @@
import AppKit
import CoreGraphics
import Foundation
import XCTest
@testable import ICCery
/// Issue #201 Phase 3 — `TargetPageCanvasView` top-down 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
func testRectForPageStacksTopDown() {
let view = TargetPageCanvasView(
pages: [raster(), raster(), raster()],
paperSize: paperSize)
XCTAssertTrue(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)
let first = view.destinationRect(forPage: 1)
XCTAssertEqual(first.origin.x, 0, accuracy: 0.001)
XCTAssertEqual(first.origin.y, 0, 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, paperSize.height, accuracy: 0.001)
XCTAssertEqual(second.size, first.size)
for rect in [first, second] {
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,126 @@
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).
static func makeImage(
px: CGSize,
components: Int,
bitsPerComponent: Int,
alpha: Bool = false,
pixelBytes: [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
let pattern = pixelBytes
?? Array(0..<bytesPerPixel).map { UInt8(($0 * 37 + 11) & 0xff) }
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
}
}
var bytes = [UInt8]()
bytes.reserveCapacity(bytesPerRow * height)
for _ in 0..<height {
bytes.append(contentsOf: row)
}
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).
static func makeTIFF(
px: CGSize,
dpi: Double?,
components: Int = 3,
bitsPerComponent: Int = 8,
alpha: Bool = false,
pixelBytes: [UInt8]? = nil,
in directory: URL
) throws -> URL {
guard let image = makeImage(
px: px, components: components,
bitsPerComponent: bitsPerComponent,
alpha: alpha, pixelBytes: pixelBytes)
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")
}
}
-14
View File
@@ -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
+1 -1
View File
@@ -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
+6
View File
@@ -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
;; ;;
+14 -3
View File
@@ -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,9 +202,80 @@ 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=` /
/// `MediaType=` apply back to the Stage 2 selections and reach the
/// recorded ticket writes via the Stage 2 overrides (#201 D6).
func testPanelResultAppliesBackOrientationAndMedia() throws {
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
"PageSize=Letter EPIJ_Qual=305 orientation-requested=4 MediaType=PhotographicGlossy"
launchAppWithDefaults()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
element("btnPrinterProperties").click()
let notice = element("printNotificationText")
XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertTrue((notice.value as? String ?? "")
.contains("Settings captured"))
// `printerMediaTypeSelect` is the group's id — the popup is a
// descendant (stacked identifiers collapse to the container).
// The popup's AX title lags the binding — poll for the
// apply-back value.
let mediaPopup = element("printerMediaTypeSelect")
.descendants(matching: .popUpButton).firstMatch
XCTAssertTrue(mediaPopup.waitForExistence(timeout: 5))
var mediaSelection = ""
let deadline = Date().addingTimeInterval(10)
while Date() < deadline, mediaSelection != "PhotographicGlossy" {
mediaSelection = [
mediaPopup.title, mediaPopup.label,
mediaPopup.value as? String ?? "",
].first { !$0.isEmpty } ?? ""
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
XCTAssertEqual(mediaSelection, "PhotographicGlossy")
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter")
app.buttons["btnPrintAll"].click()
let log = waitForSpoolLine()
XCTAssertTrue(log.contains("orientation-requested=4"), log)
XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log)
XCTAssertTrue(log.contains("PageSize=Letter"), log)
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
}
/// #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")
}
}
+38 -35
View File
@@ -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,18 +100,18 @@ 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 /// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button
@@ -203,10 +204,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 +225,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 +263,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 +293,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")
+4 -1
View File
@@ -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
View File
@@ -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 |
|------|----------|------| |------|----------|------|
+4 -1
View File
@@ -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>"
+10 -10
View File
@@ -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 |
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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
+9 -3
View File
@@ -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.
+105
View File
@@ -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
View File
@@ -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.0" MARKETING_VERSION: "2.0.1"
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.