Compare commits

...
Author SHA1 Message Date
gronod 12061a03ac fix(print): restore captured ticket when reopening properties dialog (#217)
macOS CI / build-and-test (push) Skipped
The bound NSPrintPanel always opened on queue driver defaults —
capturedTickets[queue] was stored but never passed back, so every
vendor-PDE setting outside the four mirrored fields was discarded
on reopen (RC1), and pre-selection writes were never validated into
the ticket before runModal (RC2).

Extract the pre-modal configuration into a testable
configureBoundPanel seam with the spooler's S4→S7 order: ticket
restore (warn-only, unlike the spooler's fatal try — a stale ticket
must not keep the dialog from opening → Stage 2 selections (D6
parity) → PMSessionValidatePrintSettings + updateFromPMPrintSettings
→ applyPaper → suppression ②–⑤.

ICCERY_DEBUG_PANEL_DUMP=1 logs the raw PMPrintSettingsToOptions
output and serialised ticket sizes pre/post modal for the XP-55
RC3 verification.

Refs #217
EOF
)
2026-09-22 17:47:09 +01:00
gronod f7f6a8bd87 chore: bump MARKETING_VERSION to 2.0.3
macOS CI / package (push) Successful in 3m37s
macOS CI / build-and-test (push) Skipped
2026-09-18 11:06:42 +01:00
gronod ed91ebf807 test(print): scroll stage-2 via synthesized scroll wheel (#215)
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 0s
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 24m1s
press(forDuration:thenDragTo:) is inert on macOS — content-drag
scrolling is iOS-only, so the helper burned its 20s budget with the
gallery frame never moving. The scroll wheel is the mechanism the
platform supports: one scroll(byDeltaX:deltaY:) call on a coordinate
inside app.scrollViews["stage-2"] per poll iteration, negative deltaY
to move content up off the Dock zone.
2026-09-18 10:38:57 +01:00
gronod 77c065f433 docs(agents): add remote test build procedure for CI runner host
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 36m50s
- SSH access: localadmin@192.168.0.172, credentials in ~/Projects/build-host.env
- sshpass usage via SSHPASS env var (never command-line)
- Remote checkout sync: git checkout + scp uncommitted changes
- xcodegen PATH workaround: prepend ~/.local/xcodegen/2.38.0/xcodegen/bin (#109)
- Full xcodebuild build-for-testing + codesign + test-without-building sequence mirroring .gitea/workflows/macos.yml
- Unit tests only (ICCeryCoreTests) —
2026-09-18 09:55:22 +01:00
gronod f7099a9808 fix(print): filter Stage 2 quality picker by selected media type (#214)
macOS CI / build-and-test (push) Successful in 44m15s
macOS CI / package (push) Successful in 3m27s
lpoptions -l only exposes the global quality list, so the picker offered every token for every media — the driver then rejected invalid pairs (e.g. Fast Economy on Premium Semigloss) at print time. Neither the Epson nor the Canon PPD carries standard *UIConstraints, so resolve the media-to-quality map from each driver's own data: Epson InkjetPrinter2 *EPIJUIConstraint forbid-rules in the machine bundle's PDEData.dat (non-media LHS terms evaluated against lpoptions defaults), Canon BJPrinter's binary cnb_<TableID>0.tbl record table via *CNIJNameTblPath/*CNIJTableID, and generic PPD *UIConstraints pairs.

MediaQualityConstraints picks the first non-empty source and fails open — queues without constraint data keep the full list. PrinterCapabilities gains qualityIDsByMediaType plus filtered qualities(forMediaType:)/allowsQuality accessors; the view model exposes availableQualities, clamps the pick on media change / seeding / panel apply-back, and substitutes a stale quality defensively in makeRequest so an invalid pair can never reach the ticket.
2026-09-17 23:08:51 +01:00
gronod ddb9245f44 fix(print): anchor raster at page top in bottom-up canvas space (#211)
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 11m49s
macOS CI / build-and-test (push) Failing after 44m23s
macOS CI / package (push) Skipped
226e7b2 un-flipped the canvas but left destinationRect anchored at
pageRect.minY — the page's BOTTOM edge in bottom-up space — so
sub-page targets printed at the foot of the sheet and oversize
targets clipped their top rows. Anchor at pageRect.maxY - height,
restoring the spec'd top-left anchor (docs/14 §6, D9).

Adds the missing regression coverage: the geometry test now asserts
the top anchor with a sub-page raster, and the PDF harness gains a
banded (red-over-blue) fixture that fails on either a mirrored draw
or a bottom anchor — solid fills could catch neither.
2026-09-17 21:53:17 +01:00
gronod 226e7b2a78 test(print): flip canvas coordinate space to bottom-up for NSPrintOperation compatibility
macOS CI / build-and-test (push) Skipped
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 34s
- TargetPageCanvasView.isFlipped: true → false (bottom-up CoreGraphics origin)
- TargetCanvasGeometryTests: assertion updated to XCTAssertFalse(view.isFlipped)
2026-09-17 20:49:52 +01:00
gronod 2407abfd9e chore: bump MARKETING_VERSION for M12 native spool release
macOS CI / build-and-test (push) Failing after 4m51s
macOS CI / package (push) Skipped
2026-09-17 18:03:00 +01:00
gronod 356ae0bfa9 Merge pull request 'M12: native NSPrintOperation target spool (#201, #202)' (#209) from milestone/m12-native-spool into develop
macOS CI / build-and-test (push) Canceled after 0s
macOS CI / package (push) Canceled after 0s
2026-09-17 18:02:33 +01:00
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
33 changed files with 2299 additions and 246 deletions
+70 -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.
## Package layout
- `ICCery` — app target (SwiftUI shell).
- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel).
- `ICCery` — app target (SwiftUI shell). Also owns the native print stack in
`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.
## AGPL boundary
@@ -39,6 +43,17 @@ Empty cwd illegal (#59). Atomic writes = `.tmp` + rename (#213).
User-supplied strings via SwiftUI `Text` only (#114).
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
`scripts/version.sh` is the single source: tag/describe → `ICCERY_RELEASE_TAG`
(About shows `tag (marketing)`), `MARKETING_VERSION` = strict `X.Y.Z`,
@@ -78,10 +93,62 @@ codesign -dvv <sidecar>
```
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
## Remote test builds (runner host)
Test builds done as part of change implementations run on the CI runner
host, not the dev machine — the runner is a macOS 12.7.6 **x86_64** VM
with Xcode 14.2, and CI failures there are often environmental and do not
reproduce locally.
- Host: `localadmin@192.168.0.172` (SSH, LAN). Runner daemon:
`gitea-runner daemon -c /opt/gitea-runner/config.yaml`.
- Credentials: `~/Projects/build-host.env` defines `BUILD_HOST_USER` /
`BUILD_HOST_PASSWORD`. **Never read or print this file** — load it with
`set -a; source ~/Projects/build-host.env; set +a` so values stay in the
environment.
- `sshpass` is installed (`/usr/local/bin/sshpass`). Feed the password via
the `SSHPASS` env var — never on the command line:
```sh
SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e ssh localadmin@192.168.0.172 '<cmd>'
SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e scp <local> localadmin@192.168.0.172:<remote>
```
First connection needs `-o StrictHostKeyChecking=accept-new`.
- Remote checkout: `~/Projects/iccery-v2-mac` (anonymous HTTPS clone works;
full history needed by `scripts/version.sh`). Before testing, sync it to
the same base commit as the local work:
`git fetch origin && git checkout <local base sha>`, then `scp` each
locally-changed file over its remote counterpart (uncommitted work
transfers this way — there is no push).
- `xcodegen` is **not** on PATH: prepend
`$HOME/.local/xcodegen/2.38.0/xcodegen/bin` (prebuilt 2.38.0 installed
by `scripts/ensure-host-tools.sh`; brew's formula needs Xcode 15.3,
impossible on macOS 12 — #109).
- Mirror `.gitea/workflows/macos.yml` exactly, from the repo root with
`DERIVED=build/DerivedData-test`:
`scripts/ensure-host-tools.sh` → `xcodegen generate --spec project.yml`
→ `eval "$(scripts/version.sh)"` → `xcodebuild build-for-testing
-scheme ICCery -destination 'platform=macOS' -derivedDataPath "$DERIVED"
-configuration Debug ARCHS="$(uname -m)" ONLY_ACTIVE_ARCH=NO
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-' MARKETING_VERSION=…
CURRENT_PROJECT_VERSION=… ICCERY_RELEASE_TAG=…` → codesign every
`*_PackageProduct.framework` under `$DERIVED/Build/Products/Debug`
(#119) → `xcodebuild test-without-building -xctestrun <ICCery*.xctestrun>
-only-testing:ICCeryCoreTests -destination 'platform=macOS'
-derivedDataPath "$DERIVED"`.
- Run **unit tests only** (`ICCeryCoreTests`). UI tests need a logged-in
GUI session and fail environmentally on the VM (runs 29700, 29804,
42183) — do not use them for verification unless asked.
- xcodebuild takes minutes — run the ssh command in a background shell
and poll rather than blocking.
## Private ColorSync SPI
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
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
Use the `gitea` MCP (custom build with blocking support — verified working):
@@ -73,6 +73,26 @@ public enum CupsParsers {
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>
/// `lpoptions -p` — `key=value` pairs, values may be
@@ -137,6 +157,16 @@ public enum CupsParsers {
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
/// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`.
@@ -352,4 +382,62 @@ public enum CupsParsers {
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
"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
}
}
@@ -57,19 +57,29 @@ public struct CupsService: Sendable {
"lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true)
let defaultOut = try await run(
"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 statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout)
let deviceURIs = CupsParsers.lpstatDeviceURIs(output: deviceOut.stdout)
var printers: [Printer] = []
for name in names {
let displayName = try? await displayName(for: name)
let identity = try? await queueIdentity(for: name)
printers.append(Printer(
name: name,
status: statuses[name] ?? .unknown,
isDefault: name == defaultName,
displayName: displayName
displayName: identity?.displayName,
isAirPrint: CupsParsers.detectAirPrint(
deviceURI: deviceURIs[name],
makeAndModel: identity?.makeAndModel,
ppd: loadPPD(for: name) ?? "")
))
}
return printers
@@ -78,9 +88,19 @@ public struct CupsService: Sendable {
/// `lpoptions -p <queue>` → `printer-info` (the NSPrinter fallback
/// display name, docs/11 §binding).
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(
"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)
@@ -95,9 +115,16 @@ public struct CupsService: Sendable {
/// Trays / paper sizes / media types for a queue, with PPD
/// `*Key id/Human:` enrichment when the queue's PPD is readable.
/// Also resolves the per-media quality map from vendor driver
/// data (`MediaQualityConstraints`, #214) — empty when the driver
/// exposes none.
public func capabilities(for queue: String) async throws -> PrinterCapabilities {
let listings = try await optionListings(for: queue)
return capabilities(from: listings, ppd: loadPPD(for: queue))
let ppd = loadPPD(for: queue)
var caps = capabilities(from: listings, ppd: ppd)
caps.qualityIDsByMediaType = MediaQualityConstraints.resolve(
listings: listings, ppd: ppd)
return caps
}
/// Pure mapping — extracted so fixture tests need no process.
@@ -0,0 +1,329 @@
import Foundation
/// Per-media quality validity (#214): which of the queue's quality
/// tokens the driver actually accepts for a given media type. The
/// answer is not in `lpoptions -l`, IPP, or standard PPD
/// `*UIConstraints` for the two driver families handled here —
///
/// - Epson InkjetPrinter2: `*EPIJUIConstraint: <cond>|<forbidden>`
/// lines in the machine bundle's
/// `…/Contents/Resources/PDEData.dat`. A rule forbids its RHS
/// choice while every LHS `*<key> <value>` term holds. LHS keys
/// other than the media key (`EPIJ_PSrc`, `EPIJ_FdSo`,
/// `EPIJ_Ink_`, …) are evaluated against the queue's `lpoptions`
/// defaults — reproducing the driver's PDE state when it opens.
/// - Canon BJPrinter: `*CNIJNameTblPath` + `*CNIJTableID` PPD keys
/// locate `cnb_<TableID>0.tbl`, a binary record DB whose 20-byte
/// `{u16 0x30, u16 1, u32 0, u16 family, u16 flag, u32 mediaID,
/// u16 0, u16 quality}` records enumerate the allowed
/// `CNIJPrintQuality` values per `CNIJMediaType` (`mediaID`
/// `| 0x10000` marks the borderless variant of the same media).
/// - Generic: standard PPD `*UIConstraints:`/`*Constraints:` pairs
/// (Gutenprint etc.) — first non-empty source wins.
///
/// Every parser is pure; file access is injected so tests need no
/// installed driver. Failure or absence yields an empty map — callers
/// treat that as "unconstrained" and keep the full quality list.
public enum MediaQualityConstraints {
/// media-type id → allowed quality ids. Sources tried in order
/// (PPD constraints → Epson `PDEData.dat` → Canon `cnb` table);
/// the first source producing any entries wins.
public static func resolve(
listings: [CupsOptionListing],
ppd: String?,
readFile: (URL) -> Data? = { try? Data(contentsOf: $0) }
) -> [String: Set<String>] {
let optionKeys = Set(listings.map(\.key))
guard let mediaKey = CupsParsers.detectMediaTypeKey(
optionKeys: optionKeys),
let qualityKey = CupsParsers.detectQualityKey(
optionKeys: optionKeys),
let mediaListing = listings.first(where: {
$0.key == mediaKey }),
let qualityListing = listings.first(where: {
$0.key == qualityKey })
else { return [:] }
let mediaIDs = Set(mediaListing.choices)
let qualityIDs = Set(qualityListing.choices)
let defaults = Dictionary(
listings.compactMap { l in l.defaultChoice.map { (l.key, $0) } },
uniquingKeysWith: { first, _ in first })
guard let ppd else { return [:] }
let ppdMap = allowedMap(
forbidden: ppdUIConstraints(
ppd, mediaKey: mediaKey, qualityKey: qualityKey,
mediaIDs: mediaIDs, qualityIDs: qualityIDs),
mediaIDs: mediaIDs, qualityIDs: qualityIDs)
if !ppdMap.isEmpty { return ppdMap }
if let url = epijPDEDataPath(ppd: ppd),
let data = readFile(url),
let dat = String(data: data, encoding: .utf8)
?? String(data: data, encoding: .isoLatin1) {
let map = allowedMap(
forbidden: epijUIConstraints(
dat, mediaKey: mediaKey, qualityKey: qualityKey,
mediaIDs: mediaIDs, qualityIDs: qualityIDs,
defaults: defaults),
mediaIDs: mediaIDs, qualityIDs: qualityIDs)
if !map.isEmpty { return map }
}
if let url = cnijTablePath(ppd: ppd),
let data = readFile(url) {
let map = cnijMediaQualityTable(
data, mediaIDs: mediaIDs, qualityIDs: qualityIDs)
if !map.isEmpty { return map }
}
return [:]
}
// MARK: - PPD keyword lookup
/// `*<key>: <value>` or `*<key>: "<value>"` → the value.
/// Exact key match — `*CNIJTableIDFoo:` must not satisfy a lookup
/// for `CNIJTableID`.
public static func ppdKeyword(_ ppd: String, _ key: String) -> String? {
for raw in ppd.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix("*\(key)") else { continue }
let rest = line.dropFirst(key.count + 1)
guard rest.first == ":" else { continue }
var value = rest.dropFirst()
.trimmingCharacters(in: .whitespaces)
if value.count >= 2,
value.hasPrefix("\""), value.hasSuffix("\"") {
value = String(value.dropFirst().dropLast())
}
if !value.isEmpty { return value }
}
return nil
}
// MARK: - Generic PPD constraints
/// `*UIConstraints:`/`*Constraints:` lines pair two conflicting
/// option choices; the ones naming both the media key and the
/// quality key produce forbidden (media, quality) pairs.
/// `*cupsUIConstraints` resolver triples do not match the
/// `*…Constraints:` prefixes, so they are skipped naturally.
public static func ppdUIConstraints(
_ ppd: String,
mediaKey: String,
qualityKey: String,
mediaIDs: Set<String>,
qualityIDs: Set<String>
) -> [String: Set<String>] {
var forbidden: [String: Set<String>] = [:]
for raw in ppd.split(separator: "\n") {
var line = raw.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("*UIConstraints:") {
line = String(line.dropFirst("*UIConstraints:".count))
} else if line.hasPrefix("*Constraints:") {
line = String(line.dropFirst("*Constraints:".count))
} else {
continue
}
let terms = constraintTerms(line)
guard let media = terms[mediaKey], mediaIDs.contains(media),
let quality = terms[qualityKey],
qualityIDs.contains(quality)
else { continue }
forbidden[media, default: []].insert(quality)
}
return forbidden
}
// MARK: - Epson PDEData.dat
/// `EPIJDriverBasePath` + `EPIJMachineBundleName` → the machine
/// bundle's `Contents/Resources/PDEData.dat` (uniform across the
/// InkjetPrinter2 family).
public static func epijPDEDataPath(ppd: String) -> URL? {
guard let base = ppdKeyword(ppd, "EPIJDriverBasePath"),
let bundle = ppdKeyword(ppd, "EPIJMachineBundleName")
else { return nil }
return URL(fileURLWithPath: base)
.appendingPathComponent("Machine")
.appendingPathComponent(bundle)
.appendingPathComponent("Contents/Resources/PDEData.dat")
}
/// `*EPIJUIConstraint:` forbidden-pair rules → (media, quality)
/// pairs the driver greys out. A rule *fires* when its LHS
/// `*<mediaKey>` term equals the media (absent media term →
/// applies to every media) and every other LHS term's value
/// equals that key's `lpoptions` default. A term whose key the
/// queue does not advertise counts as satisfied — hiding a usable
/// quality is a soft restriction, while showing an invalid one
/// re-creates the reported print failure.
public static func epijUIConstraints(
_ dat: String,
mediaKey: String,
qualityKey: String,
mediaIDs: Set<String>,
qualityIDs: Set<String>,
defaults: [String: String]
) -> [String: Set<String>] {
var forbidden: [String: Set<String>] = [:]
for raw in dat.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix("*EPIJUIConstraint:"),
let bar = line.firstIndex(of: "|")
else { continue }
let rhs = constraintTerms(
String(line[line.index(after: bar)...]))
guard let quality = rhs[qualityKey],
qualityIDs.contains(quality)
else { continue }
let lhs = constraintTerms(
String(line[
line.index(
line.startIndex,
offsetBy: "*EPIJUIConstraint:".count)..<bar]))
// Non-media terms must hold at the queue's defaults;
// a key absent from `defaults` is satisfied (see above).
let fires = lhs.allSatisfy { key, value in
key == mediaKey || value.isEmpty
|| (defaults[key].map { $0 == value } ?? true)
}
guard fires else { continue }
if let media = lhs[mediaKey] {
guard mediaIDs.contains(media) else { continue }
forbidden[media, default: []].insert(quality)
} else {
for media in mediaIDs {
forbidden[media, default: []].insert(quality)
}
}
}
return forbidden
}
// MARK: - Canon cnb table
/// `CNIJNameTblPath` + `CNIJTableID` → `cnb_<TableID>0.tbl`.
public static func cnijTablePath(ppd: String) -> URL? {
guard let dir = ppdKeyword(ppd, "CNIJNameTblPath"),
let tableID = ppdKeyword(ppd, "CNIJTableID")
else { return nil }
return URL(fileURLWithPath: dir)
.appendingPathComponent("cnb_\(tableID)0.tbl")
}
/// Scans the Canon table for 20-byte LE records anchored on
/// `30 00 01 00 00 00 00 00`: `{u16 family, u16 flag,
/// u32 mediaID, u16 pad, u16 quality}` follows the anchor.
/// A row counts only when `pad == 0`, `mediaID & 0xFFFF` is a
/// listed media id, and `quality` is a listed quality id — so
/// unrelated tables cannot inject false entries. Records carry a
/// `family` field (0x03 on the Pro9500 II); the modal family is
/// used so sibling record layouts in the same file are ignored.
/// Returns allowed (not forbidden) sets directly.
public static func cnijMediaQualityTable(
_ data: Data,
mediaIDs: Set<String>,
qualityIDs: Set<String>
) -> [String: Set<String>] {
let mediaNums = Set(mediaIDs.compactMap { UInt32($0) })
let qualityNums = Set(qualityIDs.compactMap { UInt16($0) })
guard !mediaNums.isEmpty, !qualityNums.isEmpty else {
return [:]
}
let anchor: [UInt8] = [0x30, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00]
var rows: [UInt16: [(media: UInt32, quality: UInt16)]] = [:]
data.withUnsafeBytes { buffer in
guard let base = buffer.baseAddress else { return }
var i = 0
while i + 20 <= buffer.count {
if memcmp(base + i, anchor, anchor.count) == 0 {
let family = readU16(base, i + 8)
let media = readU32(base, i + 12)
let pad = readU16(base, i + 16)
let quality = readU16(base, i + 18)
let baseMedia = media & 0xFFFF
if pad == 0,
mediaNums.contains(baseMedia),
qualityNums.contains(quality) {
rows[family, default: []].append(
(baseMedia, quality))
}
}
i += 2
}
}
guard let dominant = rows.max(by: { $0.value.count < $1.value.count })
else { return [:] }
var map: [String: Set<String>] = [:]
for row in dominant.value {
map[String(row.media), default: []].insert(String(row.quality))
}
// Keep strict subsets only — a media with the full set (or an
// empty one) is unconstrained as far as the UI is concerned.
map = map.filter { _, ids in
!ids.isEmpty && ids != qualityIDs
}
return map
}
// MARK: - Shared
/// `*<key> <value>` term extraction for both PPD and Epson
/// constraint syntax. A `*<key>` not followed by a value is a
/// wildcard term (empty-string value → always satisfied).
private static func constraintTerms(_ text: String) -> [String: String] {
var terms: [String: String] = [:]
let tokens = text.split(separator: " ").map(String.init)
var index = 0
while index < tokens.count {
guard tokens[index].hasPrefix("*") else {
index += 1
continue
}
let key = String(tokens[index].dropFirst())
if index + 1 < tokens.count,
!tokens[index + 1].hasPrefix("*") {
terms[key] = tokens[index + 1]
index += 2
} else {
terms[key] = ""
index += 1
}
}
return terms
}
/// forbidden (media → quality ids) → allowed map entries,
/// emitting only strict non-empty subsets: a media whose allowed
/// set is empty or equals the full roster is unconstrained as far
/// as the picker is concerned.
private static func allowedMap(
forbidden: [String: Set<String>],
mediaIDs: Set<String>,
qualityIDs: Set<String>
) -> [String: Set<String>] {
var map: [String: Set<String>] = [:]
for media in mediaIDs {
let allowed = qualityIDs.subtracting(forbidden[media] ?? [])
if !allowed.isEmpty, allowed != qualityIDs {
map[media] = allowed
}
}
return map
}
private static func readU16(_ base: UnsafeRawPointer, _ offset: Int) -> UInt16 {
UInt16(base.load(fromByteOffset: offset, as: UInt8.self))
| UInt16(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8
}
private static func readU32(_ base: UnsafeRawPointer, _ offset: Int) -> UInt32 {
UInt32(base.load(fromByteOffset: offset, as: UInt8.self))
| UInt32(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8
| UInt32(base.load(fromByteOffset: offset + 2, as: UInt8.self)) << 16
| UInt32(base.load(fromByteOffset: offset + 3, as: UInt8.self)) << 24
}
}
@@ -17,17 +17,33 @@ public struct Printer: Codable, Equatable, Sendable {
public var status: PrinterStatus
public var isDefault: Bool
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(
name: String,
status: PrinterStatus = .unknown,
isDefault: Bool = false,
displayName: String? = nil
displayName: String? = nil,
isAirPrint: Bool = false
) {
self.name = name
self.status = status
self.isDefault = isDefault
self.displayName = displayName
self.isAirPrint = isAirPrint
}
/// `isAirPrint` predates #202 payloads — a missing key decodes as
/// `false` instead of failing the whole decode.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
status = try c.decode(PrinterStatus.self, forKey: .status)
isDefault = try c.decode(Bool.self, forKey: .isDefault)
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
isAirPrint = try c.decodeIfPresent(Bool.self, forKey: .isAirPrint) ?? false
}
}
@@ -91,6 +107,11 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
public var qualities: [PrinterQuality]
/// The `*`-marked default choice from `lpoptions -l`, if any.
public var qualityDefault: String?
/// Media-type id → the quality ids the driver accepts for it
/// (#214 — `MediaQualityConstraints`). Empty when the driver
/// exposes no per-media quality matrix; a missing entry means
/// "unconstrained" — show everything.
public var qualityIDsByMediaType: [String: Set<String>]
public init(
trays: [PrinterTray] = [],
@@ -99,7 +120,8 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
supportsOrientation: Bool = true,
qualityKey: String? = nil,
qualities: [PrinterQuality] = [],
qualityDefault: String? = nil
qualityDefault: String? = nil,
qualityIDsByMediaType: [String: Set<String>] = [:]
) {
self.trays = trays
self.paperSizes = paperSizes
@@ -108,6 +130,35 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
self.qualityKey = qualityKey
self.qualities = qualities
self.qualityDefault = qualityDefault
self.qualityIDsByMediaType = qualityIDsByMediaType
}
/// Qualities valid for `mediaID`, preserving the driver's listing
/// order (#180). Falls back to the full list when the driver has
/// no per-media data, the media is absent from the map, or the
/// filter would empty the picker (#214).
public func qualities(forMediaType mediaID: String?) -> [PrinterQuality] {
guard let mediaID,
let allowed = qualityIDsByMediaType[mediaID],
!allowed.isEmpty
else { return qualities }
let filtered = qualities.filter { allowed.contains($0.id) }
return filtered.isEmpty ? qualities : filtered
}
/// Whether `qualityID` is usable with `mediaID` — `true` whenever
/// the driver exposes no constraint for that media (#214). A
/// quality absent from `qualities` entirely is still allowed: a
/// driver-captured token can be legitimate even when `lpoptions`
/// never listed it.
public func allowsQuality(
_ qualityID: String, forMediaType mediaID: String?
) -> Bool {
guard let mediaID,
let allowed = qualityIDsByMediaType[mediaID],
!allowed.isEmpty
else { return true }
return allowed.contains(qualityID)
}
}
@@ -128,7 +179,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
public var ppdUncorrectedPassthrough: Bool?
/// Space-separated `key=value` captured from
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) —
/// the Stage 2 mirror only, never a spool payload.
/// the Stage 2 mirror only, never an `lp` payload.
public var cupsOptions: String?
public init(
+6 -5
View File
@@ -16,7 +16,7 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|---|---|
| Product | ICCery v2 for macOS |
| Bundle | `com.gronod.iccery2` |
| Version | 2.0.0 |
| Version | 2.0.3 |
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) |
| CI | Gitea Actions `macos-12` runner |
@@ -207,15 +207,16 @@ not in this tree.
leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate.
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`,
`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`
must not replace the input profile on cancel or non-zero exit.
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main
actor. Swift 5.7 / macOS 12: `ObservableObject`, not Observation
`@Observable`.
- **Print.** Unmanaged `lp` with ColorSync suppression
(`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel`
options win over derived CUPS keys. Never `lp -o raw`.
- **Print.** Unmanaged headless `NSPrintOperation` (#201 — no `lp`):
restore the captured `PrintTicket`, write both ColorSync vocabularies
(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
limit. Split large `VStack`/`Group` trees (#146).
+7 -1
View File
@@ -24,6 +24,7 @@ struct AppEnvironment: Sendable {
.map { URL(fileURLWithPath: $0) }
var bundledRoot = AppPaths.bundledArgyllDir
var cupsDir = URL(fileURLWithPath: "/usr/bin")
var ppdDir = URL(fileURLWithPath: "/etc/cups/ppd")
#if DEBUG
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
overrideDir = URL(fileURLWithPath: dir)
@@ -34,6 +35,11 @@ struct AppEnvironment: Sendable {
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
cupsDir = URL(fileURLWithPath: dir)
}
// #214 — fixture PPD dir lets UI tests exercise the media→
// quality constraint resolver without installed drivers.
if let dir = environment["ICCERY_CUPS_PPD_DIR"], !dir.isEmpty {
ppdDir = URL(fileURLWithPath: dir)
}
#endif
return AppEnvironment(
stateStore: WizardStateStore(),
@@ -46,7 +52,7 @@ struct AppEnvironment: Sendable {
),
cupsService: CupsService(
processManager: .shared,
binaryDir: cupsDir),
binaryDir: cupsDir, ppdDir: ppdDir),
historyStore: VerificationHistoryStore(),
mediaStore: MediaLibraryStore(),
recentProjectsStore: RecentProjectsStore()
+18 -9
View File
@@ -186,17 +186,9 @@ struct ColorSyncSuppressor {
func captureOptions(
from settings: PMPrintSettings
) -> (cupsOptions: String?, mediaType: String?) {
guard let toOptions = optionsResolver() else {
log("ColorSync: PMPrintSettingsToOptions unavailable — "
+ "panel options not captured")
guard let unfiltered = rawOptionsDump(from: settings) else {
return (nil, nil)
}
var raw: UnsafeMutablePointer<CChar>?
guard toOptions(settings, &raw) == 0, let raw else {
return (nil, nil)
}
defer { free(raw) }
let unfiltered = String(cString: raw)
let filtered = CupsOptionsFilter.filter(unfiltered)
return (
filtered.isEmpty ? nil : filtered,
@@ -204,6 +196,23 @@ struct ColorSyncSuppressor {
)
}
/// Unfiltered `PMPrintSettingsToOptions` output — the diagnostics
/// half of the capture path (#217 `ICCERY_DEBUG_PANEL_DUMP`).
/// `nil` when the symbol or the call fails.
func rawOptionsDump(from settings: PMPrintSettings) -> String? {
guard let toOptions = optionsResolver() else {
log("ColorSync: PMPrintSettingsToOptions unavailable — "
+ "panel options not captured")
return nil
}
var raw: UnsafeMutablePointer<CChar>?
guard toOptions(settings, &raw) == 0, let raw else {
return nil
}
defer { free(raw) }
return String(cString: raw)
}
// MARK: - dlsym
private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? {
+158 -56
View File
@@ -32,6 +32,19 @@ struct PrintPanelInitialSelections {
var orientation: String?
}
/// One bound-panel invocation (#217): the queue, its `lpoptions -l`
/// key roster, the Stage 2 pre-selections, and the session's captured
/// ticket — everything `makePanelPrintInfo` needs before the modal.
struct PrintPanelRequest {
let queue: String
let displayName: String?
let optionKeys: Set<String>
let initialSelections: PrintPanelInitialSelections
/// The queue's previously captured ticket — rehydrated before the
/// Stage 2 writes so vendor PDE state survives a reopen (#217).
let ticket: PrintTicket?
}
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
/// queue (issue 13, docs/11).
///
@@ -58,7 +71,8 @@ struct PrintPanelService {
displayName: String?,
cupsService: CupsService,
initialSelections: PrintPanelInitialSelections =
PrintPanelInitialSelections()
PrintPanelInitialSelections(),
ticket: PrintTicket? = nil
) async throws -> PanelCaptureResult? {
#if DEBUG
if UITestHooks.printPanelStubbed {
@@ -75,83 +89,139 @@ struct PrintPanelService {
let optionKeys = (try? await cupsService.optionKeys(for: queue))
?? []
return try runNativePanel(
queue: queue, displayName: display, optionKeys: optionKeys,
initialSelections: initialSelections)
PrintPanelRequest(
queue: queue, displayName: display,
optionKeys: optionKeys,
initialSelections: initialSelections, ticket: ticket))
}
// MARK: - Panel
private func runNativePanel(
queue: String,
displayName: String?,
optionKeys: Set<String>,
initialSelections: PrintPanelInitialSelections
) throws -> PanelCaptureResult? {
/// The pre-modal half of `runNativePanel` — a fully configured
/// `NSPrintInfo` plus the `boundViaPM` flag. Extracted (same
/// pattern as `NativeTargetSpooler.makePrintInfo`) so unit tests
/// assert ticket restore / Stage 2 precedence without driving the
/// modal (#217). The caller owns `printer` — it must stay alive
/// through the modal.
func makePanelPrintInfo(
for request: PrintPanelRequest,
boundTo printer: PMPrinter?
) throws -> (printInfo: NSPrintInfo, boundViaPM: Bool) {
let printInfo = NSPrintInfo()
var pmPrinter: PMPrinter?
var boundViaPM = false
// ① Bind the session to the selected CUPS queue (docs/11).
if let printer = PMTicketBridge.makePrinter(queue: queue) {
pmPrinter = printer
let session = PMTicketBridge.session(printInfo)
let settings = PMTicketBridge.settings(printInfo)
do {
try PMTicketBridge.bind(printer: printer, to: printInfo)
} catch {
PMTicketBridge.release(printer)
throw error
}
// Initial selections — after `PMSessionDefault*`, before
// ColorSync suppression ②–⑤ (locked write order,
// #183/#186). Paper is TWO writes (E1): the `PageSize`
// print-settings value drivers/capture read AND the
// `PMPageFormat` paper the panel's dropdown reflects.
applyInitialSelections(
initialSelections, to: settings, optionKeys: optionKeys)
if let paperToken = initialSelections.paperSize {
PMTicketBridge.applyPaper(
token: paperToken, printer: printer,
session: session, printInfo: printInfo)
}
boundViaPM = true
if let printer {
try PMTicketBridge.bind(printer: printer, to: printInfo)
configureBoundPanel(
printInfo, request: request, printer: printer)
} else {
// 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)' — "
"Print panel: PM binding unavailable for "
+ "'\(request.queue)' — "
+ "falling back to NSPrinter(displayName)")
guard let displayName,
guard let displayName = request.displayName,
let nsPrinter = NSPrinter(name: displayName)
else {
throw PrintPanelError.noPrinterFound(
displayName ?? queue)
request.displayName ?? request.queue)
}
printInfo.printer = nsPrinter
printInfo.setUpPrintOperationDefaultValues()
return (printInfo, false)
}
return (printInfo, true)
}
/// Post-bind configuration in locked order (#217 — mirrors the
/// spooler's S4→S7): ticket restore → Stage 2 selections →
/// validate → paper → suppression ②–⑤. Internal so unit tests can
/// drive it with `printer: nil` — `applyPaper` and the SPI layer
/// need a bound printer and skip when it is absent, while ticket
/// restore / selections / validate still run on the session-less
/// `NSPrintInfo`.
func configureBoundPanel(
_ printInfo: NSPrintInfo,
request: PrintPanelRequest,
printer: PMPrinter?
) {
let session = PMTicketBridge.session(printInfo)
let settings = PMTicketBridge.settings(printInfo)
// Rehydrate the captured ticket *before* the Stage 2 writes
// (#217 — restore-then-override). Warn-only, unlike the
// spooler's fatal `try`: a stale/corrupt ticket must not keep
// the dialog from opening. `restore` itself refuses a
// cross-queue replay (R3).
if let ticket = request.ticket {
do {
try PMTicketBridge.restore(ticket, into: printInfo)
} catch {
AppLogger.shared.warn(
"Print panel: ticket restore failed — "
+ "opening on queue defaults "
+ "(\(error.localizedDescription))")
}
}
// Initial selections — Stage 2 wins over the restored ticket
// (D6 parity). Paper is TWO writes (E1): the `PageSize`
// print-settings value drivers/capture read AND the
// `PMPageFormat` paper the panel's dropdown reflects.
applyInitialSelections(
request.initialSelections, to: settings,
optionKeys: request.optionKeys)
// Reconcile the pre-writes into the ticket before the modal
// (#217 RC2): the vendor PDE derives pane state from a
// validated ticket. Warn-only — log when the driver adjusts
// so a companion-key desync is diagnosable.
var changed = DarwinBoolean(false)
_ = PMSessionValidatePrintSettings(session, settings, &changed)
if changed.boolValue {
AppLogger.shared.info(
"Print panel: driver adjusted the pre-selections")
}
printInfo.updateFromPMPrintSettings()
if let printer, let paperToken = request.initialSelections.paperSize {
PMTicketBridge.applyPaper(
token: paperToken, printer: printer,
session: session, printInfo: printInfo)
}
// ②–⑤ ColourSync suppression. The SPI needs a session with a
// current printer to attach to; the SetValue/mirror layers
// are warn-only and run regardless.
if printer != nil {
suppressor.applySPIMode(to: session) // ②
}
suppressor.applyLockedKeys(to: settings) // ③
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
let driverBypass = suppressor.applyDriverBypass( // ④
to: settings, optionKeys: request.optionKeys)
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
}
private func runNativePanel(
_ request: PrintPanelRequest
) throws -> PanelCaptureResult? {
// The PM printer handle is held through the modal and
// released on every path.
let pmPrinter = PMTicketBridge.makePrinter(queue: request.queue)
defer {
if let printer = pmPrinter {
PMTicketBridge.release(printer)
}
}
// ②–⑤ ColourSync suppression — only on the PM path: the SPI
// and PMPrintSettingsSetValue need a session with a current
// printer to attach to.
var settings = PMTicketBridge.settings(printInfo)
var driverBypass: (key: String, value: String)?
if boundViaPM {
let session = PMTicketBridge.session(printInfo)
suppressor.applySPIMode(to: session) // ②
suppressor.applyLockedKeys(to: settings) // ③
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
driverBypass = suppressor.applyDriverBypass( // ④
to: settings, optionKeys: optionKeys)
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
}
let (printInfo, boundViaPM) = try makePanelPrintInfo(
for: request, boundTo: pmPrinter)
debugDump(printInfo, when: "pre-modal")
let panel = NSPrintPanel()
panel.options = [
@@ -174,8 +244,9 @@ struct PrintPanelService {
var cupsOptions: String?
var mediaType: String?
if boundViaPM {
settings = PMTicketBridge.settings(printInfo)
let captured = suppressor.captureOptions(from: settings)
debugDump(printInfo, when: "post-OK")
let captured = suppressor.captureOptions(
from: PMTicketBridge.settings(printInfo))
cupsOptions = captured.cupsOptions
mediaType = captured.mediaType
}
@@ -183,8 +254,8 @@ struct PrintPanelService {
let resolvedQueue = boundViaPM
? PMTicketBridge.currentPrinterID(
session: PMTicketBridge.session(printInfo),
fallback: queue)
: queue
fallback: request.queue)
: request.queue
// ⑦ Serialise the native ticket — the payload `lp -o` could
// never carry (#201). Warn-only via `try?`: a serialise
// failure must not lose the Stage 2 mirror above.
@@ -243,4 +314,35 @@ struct PrintPanelService {
}
}
/// `ICCERY_DEBUG_PANEL_DUMP=1` — log the unfiltered
/// `PMPrintSettingsToOptions` output and the serialised
/// settings/page-format byte counts (#217 diagnostics for the
/// Epson RC3 question: does the PDE source pane state from the
/// session ticket or lpoptions?). DEBUG-only, warn-only.
private func debugDump(_ printInfo: NSPrintInfo, when phase: String) {
#if DEBUG
guard ProcessInfo.processInfo.environment[
"ICCERY_DEBUG_PANEL_DUMP"] == "1" else { return }
let raw = suppressor.rawOptionsDump(
from: PMTicketBridge.settings(printInfo)) ?? "<unavailable>"
var settingsSize = -1
var ref: Unmanaged<CFData>?
if PMPrintSettingsCreateDataRepresentation(
PMTicketBridge.settings(printInfo), &ref,
kPMDataFormatXMLDefault) == noErr, let ref {
settingsSize = (ref.takeRetainedValue() as Data).count
}
var formatSize = -1
var formatRef: Unmanaged<CFData>?
if PMPageFormatCreateDataRepresentation(
PMTicketBridge.pageFormat(printInfo), &formatRef,
kPMDataFormatXMLDefault) == noErr, let formatRef {
formatSize = (formatRef.takeRetainedValue() as Data).count
}
AppLogger.shared.info(
"Print panel dump (\(phase)): settings=\(settingsSize)B "
+ "pageFormat=\(formatSize)B options: \(raw)")
#endif
}
}
@@ -25,7 +25,15 @@ final class PrintSessionViewModel: ObservableObject {
}
@Published var printerCaps = PrinterCapabilities()
@Published var selectedTray: Int?
@Published var selectedMediaType: String?
@Published var selectedMediaType: String? {
didSet {
// A media switch can invalidate the current quality pick —
// re-clamp into the driver's allowed set (#214).
if selectedMediaType != oldValue {
clampQualityToMedia()
}
}
}
/// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183).
@Published var selectedPaperSize: Int?
/// Print-quality option token, e.g. `"303"` (#183).
@@ -123,9 +131,16 @@ final class PrintSessionViewModel: ObservableObject {
if selectedTray == nil {
selectedTray = printerCaps.trays.first?.id
}
// Quality seeds inside the selected media's allowed set
// (#214): driver default when valid there, else the first
// allowed choice.
if selectedQuality == nil {
selectedQuality = printerCaps.qualityDefault
?? printerCaps.qualities.first?.id
let allowed = availableQualities
selectedQuality = printerCaps.qualityDefault.flatMap { d in
allowed.contains(where: { $0.id == d }) ? d : nil
} ?? allowed.first?.id
} else {
clampQualityToMedia()
}
// Caps reload is a re-mirror trigger for the paper picker
// (#183 E4) — pageSize + printer changes route here too.
@@ -137,6 +152,32 @@ final class PrintSessionViewModel: ObservableObject {
// MARK: - Paper / quality selection (#183)
/// The quality picker's source (#214): `printerCaps.qualities`
/// filtered to what the driver accepts for `selectedMediaType`.
/// Equals the full list whenever the queue exposes no per-media
/// quality matrix — and never empty while `qualities` is not.
var availableQualities: [PrinterQuality] {
printerCaps.qualities(forMediaType: selectedMediaType)
}
/// Keep `selectedQuality` inside the allowed set for the current
/// media: keep the pick when still valid, else the driver default
/// when valid, else the first allowed choice (#214).
private func clampQualityToMedia() {
let allowed = availableQualities
guard !allowed.isEmpty else { return }
if let quality = selectedQuality,
allowed.contains(where: { $0.id == quality }) {
return
}
if let fallback = printerCaps.qualityDefault,
allowed.contains(where: { $0.id == fallback }) {
selectedQuality = fallback
} else {
selectedQuality = allowed.first?.id
}
}
/// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
/// a capability whose name matches `pageSize.rawValue` → its id;
/// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
@@ -190,13 +231,15 @@ final class PrintSessionViewModel: ObservableObject {
quality: selectedQuality,
mediaType: selectedMediaType,
orientation: printOrientation)
let ticket = capturedTickets[queue]
Task { @MainActor in
do {
guard let result = try await PrintPanelService()
.showProperties(
queue: queue, displayName: displayName,
cupsService: cups,
initialSelections: selections)
initialSelections: selections,
ticket: ticket)
else {
printNotice = Notice(
kind: .info,
@@ -230,7 +273,12 @@ final class PrintSessionViewModel: ObservableObject {
.first(where: { $0.name == paper }) {
selectedPaperSize = match.id
}
if let quality = result.properties.options.quality {
// A captured quality the driver rejects for the
// (possibly just-captured) media is dropped — keeping
// it would only re-create a failed print (#214).
if let quality = result.properties.options.quality,
printerCaps.allowsQuality(
quality, forMediaType: selectedMediaType) {
selectedQuality = quality
}
if let orientation = result.properties.options.orientation {
@@ -358,6 +406,15 @@ final class PrintSessionViewModel: ObservableObject {
let optionKeys = (try? await environment.cupsService
.optionKeys(for: queue)) ?? []
attachDiagnostics()
// Defence-in-depth (#214): an invalid media+quality pair can
// never reach the ticket — substitute the first allowed
// quality when the pick is stale (UI already clamps; this is
// the last gate before the driver).
var quality = selectedQuality
if let q = quality,
!printerCaps.allowsQuality(q, forMediaType: selectedMediaType) {
quality = availableQualities.first?.id ?? q
}
return TargetPrintRequest(
queue: queue,
displayName: printers.first { $0.name == queue }?.displayName,
@@ -373,7 +430,7 @@ final class PrintSessionViewModel: ObservableObject {
paperSize: selectedPaperSizeToken,
mediaType: selectedMediaType,
qualityKey: printerCaps.qualityKey,
quality: selectedQuality,
quality: quality,
orientation: printOrientation),
optionKeys: optionKeys)
}
@@ -27,9 +27,11 @@ final class TargetPageCanvasView: NSView {
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 }
/// Bottom-up CoreGraphics space: `CGContext.draw(_:in:)` renders
/// the raster upright — a flipped view prints mirrored (#211).
/// Page 1 is the BOTTOM band of the frame, the non-flipped
/// pagination convention.
override var isFlipped: Bool { false }
override var isOpaque: Bool { true }
override func knowsPageRange(_ range: NSRangePointer) -> Bool {
@@ -37,7 +39,7 @@ final class TargetPageCanvasView: NSView {
return true
}
/// 1-based page → its paper-sized rect, stacked top-down.
/// 1-based page → its paper-sized rect, stacked bottom-up.
override func rectForPage(_ page: Int) -> NSRect {
NSRect(x: 0,
y: CGFloat(page - 1) * paperSize.height,
@@ -53,7 +55,7 @@ final class TargetPageCanvasView: NSView {
let pageRect = rectForPage(page)
let size = pages[page - 1].pointSize
return NSRect(x: Self.snap(pageRect.minX),
y: Self.snap(pageRect.minY),
y: Self.snap(pageRect.maxY - size.height),
width: size.width,
height: size.height)
}
+16 -7
View File
@@ -266,12 +266,19 @@ struct Stage2View: View {
.background(Theme.background)
.clipShape(Capsule())
.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")
}
}
// Placeholder — Phase 5 (AirPrint detection, M12)
// replaces this with the live unmanaged-colour
// warning badge for AirPrint queues.
EmptyView()
.accessibilityIdentifier("airPrintWarningBadge")
Button(action: workflow.print.refreshPrinters) {
Image(systemName: "arrow.clockwise")
}
@@ -403,11 +410,13 @@ struct Stage2View: View {
.accessibilityIdentifier("paperSizeGroup")
}
/// Quality picker — driver tokens with PPD-enriched labels (#183).
/// Quality picker — driver tokens with PPD-enriched labels (#183),
/// filtered to what the driver accepts for the selected media
/// (#214).
private var qualityGroup: some View {
VStack(alignment: .leading, spacing: 4) {
Picker("Quality", selection: $workflow.print.selectedQuality) {
ForEach(workflow.print.printerCaps.qualities, id: \.id) {
ForEach(workflow.print.availableQualities, id: \.id) {
Text($0.name).tag(Optional($0.id))
}
}
+171
View File
@@ -455,4 +455,175 @@ final class CupsParserTests: XCTestCase {
processManager: ProcessManager(),
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)")
}
}
@@ -0,0 +1,311 @@
import XCTest
import Foundation
@testable import ICCeryCore
/// #214 — media → allowed-quality resolution: generic PPD
/// `*UIConstraints`, Epson `PDEData.dat` `*EPIJUIConstraint` rules,
/// and the Canon `cnb_*.tbl` record scan. All fixtures inline /
/// injected — no installed drivers touched.
final class MediaQualityConstraintsTests: XCTestCase {
/// Epson-flavoured `lpoptions -l` listings: the XP-55 advertises
/// the conditional LHS keys (`EPIJ_PSrc`/`EPIJ_FdSo`/`EPIJ_Ink_`)
/// with defaults 2/2/1.
private var epsonListings: [CupsOptionListing] {
CupsParsers.lpoptionsList("""
PageSize/Media Size: 4x6 5x7 *A4 Letter
EPIJ_Medi/Media Type: *0 92 13 15 145 12 2 75 26 76 93
EPIJ_PSrc/Page Setup: *2 3 25
EPIJ_FdSo/Paper Source: *2 3 12
EPIJ_Ink_/Grayscale: *1 0
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
""")
}
private var epsonQualityIDs: Set<String> {
["301", "302", "303", "304", "305", "307", "308"]
}
// MARK: - ppdKeyword
func testPPDKeywordQuotedAndBare() {
let ppd = """
*EPIJDriverBasePath: "/Library/Printers/EPSON/InkjetPrinter2"
*EPIJMachineBundleName: "EP14C0605W.data"
*CNIJTableID: 354
*CNIJTableIDFoo: 999
"""
XCTAssertEqual(
MediaQualityConstraints.ppdKeyword(ppd, "EPIJDriverBasePath"),
"/Library/Printers/EPSON/InkjetPrinter2")
XCTAssertEqual(
MediaQualityConstraints.ppdKeyword(ppd, "EPIJMachineBundleName"),
"EP14C0605W.data")
XCTAssertEqual(
MediaQualityConstraints.ppdKeyword(ppd, "CNIJTableID"), "354")
// Prefix-safety: CNIJTableIDFoo must not satisfy the lookup.
XCTAssertNil(MediaQualityConstraints.ppdKeyword(ppd, "CNIJTableIDX"))
XCTAssertNil(MediaQualityConstraints.ppdKeyword(ppd, "Missing"))
}
// MARK: - Path derivation
func testEPIJPDEDataPath() {
let ppd = """
*EPIJDriverBasePath: "/Library/Printers/EPSON/InkjetPrinter2"
*EPIJMachineBundleName: "EP14C0605W.data"
"""
XCTAssertEqual(
MediaQualityConstraints.epijPDEDataPath(ppd: ppd)?.path,
"/Library/Printers/EPSON/InkjetPrinter2/Machine/"
+ "EP14C0605W.data/Contents/Resources/PDEData.dat")
XCTAssertNil(MediaQualityConstraints.epijPDEDataPath(
ppd: "*CNIJTableID: 354\n"))
}
func testCNIJTablePath() {
let ppd = """
*CNIJNameTblPath: "/Library/Printers/Canon/BJPrinter/Resources/Database/CIJPro9500IIseries.db/Contents/Resources"
*CNIJTableID: 354
"""
XCTAssertEqual(
MediaQualityConstraints.cnijTablePath(ppd: ppd)?.path,
"/Library/Printers/Canon/BJPrinter/Resources/Database/"
+ "CIJPro9500IIseries.db/Contents/Resources/cnb_3540.tbl")
XCTAssertNil(MediaQualityConstraints.cnijTablePath(
ppd: "*EPIJMachineBundleName: \"x\"\n"))
}
// MARK: - Generic PPD UIConstraints
func testPPDUIConstraintsBothOrders() {
let ppd = """
*UIConstraints: *MediaType Glossy *PrintQuality Draft
*UIConstraints: *PrintQuality Draft *MediaType Matte
*Constraints: *MediaType Glossy *PrintQuality Low
*UIConstraints: *InputSlot Rear *PrintQuality Draft
"""
let forbidden = MediaQualityConstraints.ppdUIConstraints(
ppd, mediaKey: "MediaType", qualityKey: "PrintQuality",
mediaIDs: ["Glossy", "Matte"],
qualityIDs: ["Draft", "Low", "High"])
XCTAssertEqual(forbidden["Glossy"], ["Draft", "Low"])
XCTAssertEqual(forbidden["Matte"], ["Draft"])
}
// MARK: - Epson EPIJUIConstraint
/// Realistic XP-55 fragment: pure-media rules plus conditional
/// rules on `EPIJ_PSrc`/`EPIJ_Ink_` evaluated against defaults.
func testEPIJConstraintsAgainstDefaults() {
let dat = """
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 307
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 308
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 303
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 304
*EPIJUIConstraint: *EPIJ_PSrc 3 *EPIJ_Medi 0|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_PSrc 2 *EPIJ_Medi 0 *EPIJ_Ink_ 0|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_PSrc 3 *EPIJ_Medi 92|*EPIJ_Qual 999
"""
let forbidden = MediaQualityConstraints.epijUIConstraints(
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
mediaIDs: ["0", "15", "92"], qualityIDs: epsonQualityIDs,
defaults: ["EPIJ_PSrc": "2", "EPIJ_Ink_": "1"])
// Media 0: pure rules forbid 305/307/308; the PSrc=3 rule is
// inert at default PSrc=2 (301 survives); the PSrc=2 rule
// additionally requires Ink_=0 — default is 1, so inert too
// (302 survives).
XCTAssertEqual(forbidden["0"], ["305", "307", "308"])
XCTAssertEqual(forbidden["15"], ["301", "302", "303", "304"])
// 999 is not a listed quality id — ignored outright.
XCTAssertEqual(forbidden["92"], ["301"])
}
/// A conditional rule whose extra terms all match the defaults
/// fires like a pure-media rule.
func testEPIJConditionalRuleFiresAtDefaults() {
let dat = """
*EPIJUIConstraint: *EPIJ_PSrc 2 *EPIJ_Medi 0|*EPIJ_Qual 304
"""
let forbidden = MediaQualityConstraints.epijUIConstraints(
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
mediaIDs: ["0"], qualityIDs: epsonQualityIDs,
defaults: ["EPIJ_PSrc": "2"])
XCTAssertEqual(forbidden["0"], ["304"])
}
/// A rule with no media term applies to every listed media; a term
/// on a key the queue does not advertise counts as satisfied
/// (conservative-forbid — #214 semantics).
func testEPIJGlobalAndUnknownKeyRules() {
let dat = """
*EPIJUIConstraint: *EPIJ_Ink_ 1|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_Mode 9 *EPIJ_Medi 0|*EPIJ_Qual 307
"""
let forbidden = MediaQualityConstraints.epijUIConstraints(
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
mediaIDs: ["0", "15"], qualityIDs: epsonQualityIDs,
defaults: ["EPIJ_Ink_": "1"])
// Global rule: Ink_=1 matches default → 305 forbidden on both.
XCTAssertTrue(forbidden["0"]!.contains("305"))
XCTAssertTrue(forbidden["15"]!.contains("305"))
// EPIJ_Mode is unlisted → satisfied → (0, 307) forbidden too.
XCTAssertTrue(forbidden["0"]!.contains("307"))
}
/// The end-to-end XP-55 expectation through `resolve` — the exact
/// sets the user reported from the driver PDE.
func testResolveEpsonProducesDriverSets() {
let dat = """
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 307
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 308
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 303
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 304
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 308
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 303
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 304
"""
let ppd = """
*EPIJDriverBasePath: "/drivers/epson"
*EPIJMachineBundleName: "EP14C0605W.data"
"""
let datURL = URL(fileURLWithPath:
"/drivers/epson/Machine/EP14C0605W.data/"
+ "Contents/Resources/PDEData.dat")
let map = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
readFile: { $0 == datURL ? dat.data(using: .utf8) : nil })
// Plain paper: Fast Economy / Economy / Normal / Fine.
XCTAssertEqual(map["0"], ["301", "302", "303", "304"])
// Premium Semigloss: Quality / Best Quality / Draft.
XCTAssertEqual(map["15"], ["305", "307", "308"])
// Ultra Glossy drops Fast Economy…Fine and Draft.
XCTAssertEqual(map["92"], ["305", "307"])
// Unconstrained media (no rules) produce no entry.
XCTAssertNil(map["13"])
}
// MARK: - Canon cnb table
/// Builds one 20-byte Canon record: `30 00 01 00 00 00 00 00`,
/// `family:u16`, `flag:u16`, `media:u32`, `pad:u16`, `quality:u16`.
private func canonRecord(
family: UInt16 = 3, flag: UInt16 = 0,
media: UInt32, pad: UInt16 = 0, quality: UInt16
) -> Data {
var d = Data([0x30, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00])
d.append(contentsOf: [UInt8(family & 0xFF), UInt8(family >> 8)])
d.append(contentsOf: [UInt8(flag & 0xFF), UInt8(flag >> 8)])
d.append(contentsOf: [
UInt8(media & 0xFF), UInt8((media >> 8) & 0xFF),
UInt8((media >> 16) & 0xFF), UInt8((media >> 24) & 0xFF)])
d.append(contentsOf: [UInt8(pad & 0xFF), UInt8(pad >> 8)])
d.append(contentsOf: [UInt8(quality & 0xFF), UInt8(quality >> 8)])
return d
}
func testCNBTableScan() {
var data = Data()
// Junk prefix — even length keeps the records 2-byte aligned.
data.append(contentsOf: [0xFF, 0x00])
// media 0 → {0, 10, 15, 20} across flag 0/1 variants.
for q: UInt16 in [10, 15, 20, 0] {
data.append(canonRecord(media: 0, quality: q))
}
data.append(canonRecord(flag: 1, media: 0, quality: 10))
// media 50 → {0, 5, 10}; plus its borderless variant 0x10032.
for q: UInt16 in [0, 5, 10] {
data.append(canonRecord(media: 50, quality: q))
}
data.append(canonRecord(media: 0x10032, quality: 0))
// Noise: unknown media, unknown quality, nonzero pad, and a
// different record family that must lose the modal vote.
data.append(canonRecord(media: 999, quality: 5))
data.append(canonRecord(media: 50, quality: 7))
data.append(canonRecord(media: 50, pad: 1, quality: 15))
data.append(canonRecord(family: 9, media: 50, quality: 15))
data.append(Data([0x11, 0x22, 0x33]))
let map = MediaQualityConstraints.cnijMediaQualityTable(
data,
mediaIDs: ["0", "50", "42"],
qualityIDs: ["0", "5", "10", "15", "20"])
XCTAssertEqual(map["0"], ["0", "10", "15", "20"])
XCTAssertEqual(map["50"], ["0", "5", "10"])
XCTAssertNil(map["42"])
}
func testCNBGarbageFailsOpen() {
let map = MediaQualityConstraints.cnijMediaQualityTable(
Data((0..<4096).map { _ in UInt8.random(in: 0...255) }),
mediaIDs: ["0", "50"], qualityIDs: ["0", "5", "10"])
XCTAssertTrue(map.isEmpty)
}
// MARK: - resolve ordering / fallback
func testResolvePrefersPPDConstraints() {
let ppd = """
*UIConstraints: *EPIJ_Medi 0 *EPIJ_Qual 305
*EPIJDriverBasePath: "/should/not/be/read"
*EPIJMachineBundleName: "x.data"
"""
var reads = 0
let map = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
readFile: { _ in reads += 1; return nil })
XCTAssertEqual(map["0"], epsonQualityIDs.subtracting(["305"]))
// The Epson path is never touched once PPD constraints hit.
XCTAssertEqual(reads, 0)
}
func testResolveNoPPDOrKeysFailsOpen() {
XCTAssertTrue(MediaQualityConstraints.resolve(
listings: epsonListings, ppd: nil).isEmpty)
// No media key in the roster → no map.
XCTAssertTrue(MediaQualityConstraints.resolve(
listings: CupsParsers.lpoptionsList(
"PageSize/Media Size: *A4 Letter\n"),
ppd: "*CNIJTableID: 354\n").isEmpty)
}
// MARK: - PrinterCapabilities accessors
func testCapabilitiesAccessorFallbacks() {
var caps = PrinterCapabilities(
mediaTypes: [
PrinterMediaType(id: "0", name: "Plain"),
PrinterMediaType(id: "15", name: "Semigloss"),
],
qualities: ["301", "302", "303", "308", "304", "305", "307"]
.map { PrinterQuality(id: $0, name: "Q\($0)") })
// No map → everything, in driver order (#180).
XCTAssertEqual(caps.qualities(forMediaType: "0").map(\.id),
["301", "302", "303", "308", "304", "305", "307"])
XCTAssertTrue(caps.allowsQuality("305", forMediaType: "0"))
caps.qualityIDsByMediaType = [
"0": ["301", "302", "303", "304"],
"15": ["305", "307", "308"],
]
XCTAssertEqual(caps.qualities(forMediaType: "0").map(\.id),
["301", "302", "303", "304"])
// Driver order preserved — 308 sits before 304 in lpoptions.
XCTAssertEqual(caps.qualities(forMediaType: "15").map(\.id),
["308", "305", "307"])
XCTAssertTrue(caps.allowsQuality("308", forMediaType: "15"))
XCTAssertFalse(caps.allowsQuality("308", forMediaType: "0"))
// Unknown media → unconstrained.
XCTAssertEqual(caps.qualities(forMediaType: "99").count, 7)
XCTAssertTrue(caps.allowsQuality("305", forMediaType: nil))
}
}
+142 -37
View File
@@ -40,21 +40,8 @@ final class NativeSpoolPDFTests: XCTestCase {
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 info = makeSaveInfo(pdfURL: pdfURL)
let canvas = TargetPageCanvasView(
pages: [raster, raster], paperSize: paperSize)
@@ -88,29 +75,7 @@ final class NativeSpoolPDFTests: XCTestCase {
) 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()
let rep = try renderPageToBitmap(page, mediaBox: mediaBox)
func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) {
let color = try XCTUnwrap(
@@ -161,4 +126,144 @@ final class NativeSpoolPDFTests: XCTestCase {
XCTAssertEqual(outside.1, 1, accuracy: 0.04)
XCTAssertEqual(outside.2, 1, accuracy: 0.04)
}
/// #211 — a top/bottom banded block proves the page is drawn
/// upright AND anchored at the paper's top edge: a mirrored draw
/// swaps the bands; a bottom-anchored draw leaves white above the
/// block instead of below.
func testSavePDFDrawsPageUprightTopAnchored() throws {
try XCTSkipIf(
ProcessInfo.processInfo.environment["ICCERY_SKIP_PRINT_PDF"]
== "1",
"ICCERY_SKIP_PRINT_PDF=1 — print system unavailable")
// 144×144 px @ 72 dpi → a 144×144 pt block, top half red,
// bottom half blue.
let topBytes: [UInt8] = [230, 40, 50]
let bottomBytes: [UInt8] = [40, 50, 230]
let tiff = try TargetTestFixtures.makeTIFF(
px: CGSize(width: 144, height: 144), dpi: 72,
components: 3, bitsPerComponent: 8,
pixelBytes: topBytes,
bottomHalfPixelBytes: bottomBytes, in: tempRoot)
let raster = try TargetRasterLoader.load(tiff: tiff)
let pdfURL = tempRoot.appendingPathComponent("bands.pdf")
let info = makeSaveInfo(pdfURL: pdfURL)
let canvas = TargetPageCanvasView(
pages: [raster], paperSize: paperSize)
let operation = NSPrintOperation(view: canvas, printInfo: info)
operation.showsPrintPanel = false
operation.showsProgressPanel = false
operation.canSpawnSeparateThread = false
XCTAssertTrue(operation.run(),
"NSPrintOperation.save failed")
let document = try XCTUnwrap(
CGPDFDocument(pdfURL as CFURL))
let page = try XCTUnwrap(document.page(at: 1))
let mediaBox = page.getBoxRect(.mediaBox)
let rep = try renderPageToBitmap(page, mediaBox: mediaBox)
func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) {
let color = try XCTUnwrap(
rep.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB))
return (color.redComponent,
color.greenComponent,
color.blueComponent)
}
func matches(
_ p: (Double, Double, Double), _ rgb: [UInt8]
) -> Bool {
abs(p.0 - Double(rgb[0]) / 255) < 0.04
&& abs(p.1 - Double(rgb[1]) / 255) < 0.04
&& abs(p.2 - Double(rgb[2]) / 255) < 0.04
}
// Painted region = the page fill inset by the queue's
// unprintable margin (R10); minimum opaque y is its top edge
// in the rendered bitmap (same convention as the 1:1 test).
let width = Int(ceil(mediaBox.width))
let height = Int(ceil(mediaBox.height))
var topX = width, topY = height
for y in 0..<height {
for x in 0..<width
where (rep.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.5 {
topX = min(topX, x)
topY = min(topY, y)
}
}
XCTAssertLessThan(topY, height, "PDF page rendered empty")
// The 144 pt block hangs from the page's top edge: top-band
// colour for the first 72 pt, bottom-band colour for the
// next 72 pt, then the white page fill.
XCTAssertTrue(
try matches(pixel(topX + 10, topY + 10), topBytes),
"block top is not the top-band colour — mirrored draw?")
XCTAssertTrue(
try matches(pixel(topX + 10, topY + 82), bottomBytes),
"block bottom is not the bottom-band colour — mirrored draw?")
XCTAssertTrue(
try matches(pixel(topX + 10, topY + 134), bottomBytes),
"block does not reach 144 pt below the painted top edge")
let below = try pixel(topX + 10, topY + 160)
XCTAssertEqual(below.0, 1, accuracy: 0.04)
XCTAssertEqual(below.1, 1, accuracy: 0.04)
XCTAssertEqual(below.2, 1, accuracy: 0.04)
}
/// Print settings shared by the save-PDF harness tests: A4, zero
/// margins, `.clip` pagination, 1:1, no centring, `.save` to
/// `pdfURL`.
private func makeSaveInfo(pdfURL: URL) -> NSPrintInfo {
let info = NSPrintInfo()
info.paperSize = paperSize
info.orientation = .portrait
info.topMargin = 0
info.bottomMargin = 0
info.leftMargin = 0
info.rightMargin = 0
info.horizontalPagination = .clip
info.verticalPagination = .clip
info.scalingFactor = 1.0
info.isHorizontallyCentered = false
info.isVerticallyCentered = false
info.jobDisposition = .save
info.dictionary()[
NSPrintInfo.AttributeKey.jobSavingURL] = pdfURL
return info
}
/// Render a saved-PDF page into a bitmap at 1 px/pt.
private func renderPageToBitmap(
_ page: CGPDFPage, mediaBox: CGRect
) throws -> NSBitmapImageRep {
let width = Int(ceil(mediaBox.width))
let height = Int(ceil(mediaBox.height))
let rep = try XCTUnwrap(NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: width,
pixelsHigh: height,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0))
let context = try XCTUnwrap(
NSGraphicsContext(bitmapImageRep: rep))
let cg = context.cgContext
let transform = page.getDrawingTransform(
.mediaBox,
rect: CGRect(x: 0, y: 0, width: width, height: height),
rotate: 0, preserveAspectRatio: true)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = context
cg.concatenate(transform)
cg.drawPDFPage(page)
NSGraphicsContext.restoreGraphicsState()
return rep
}
}
@@ -0,0 +1,148 @@
import AppKit
import Foundation
import XCTest
@testable import ICCery
/// Issue #217 — the panel's pre-modal seam
/// (`PrintPanelService.configureBoundPanel`) must rehydrate the
/// captured `PrintTicket` before the Stage 2 writes, validate the
/// result, and refuse a foreign-queue ticket — all without driving
/// the modal or needing a real queue (`printer: nil` exercises
/// restore → selections → validate on a session-less `NSPrintInfo`).
@MainActor
final class PrintPanelTicketTests: XCTestCase {
private let service = PrintPanelService()
/// The queue a destination `NSPrintInfo` will report as bound —
/// the session's current printer, else a synthetic token for a
/// queue-less environment (same convention as PrintTicketTests).
private func boundQueue(of printInfo: NSPrintInfo) -> String {
PMTicketBridge.currentPrinterID(
session: PMTicketBridge.session(printInfo),
fallback: "UnboundQueue")
}
private func makeTicket(
queue: String,
key: String = "EPIJ_Qual",
value: String = "305"
) throws -> PrintTicket {
let source = NSPrintInfo()
XCTAssertTrue(
PMTicketBridge.setValue(
value, forKey: key, locked: false,
in: PMTicketBridge.settings(source),
context: "PrintPanelTicketTests"))
return try PMTicketBridge.serialise(source, queue: queue)
}
private func request(
ticket: PrintTicket?,
selections: PrintPanelInitialSelections =
PrintPanelInitialSelections()
) -> PrintPanelRequest {
PrintPanelRequest(
queue: "Panel_Q", displayName: nil, optionKeys: [],
initialSelections: selections, ticket: ticket)
}
/// RC1 — a captured vendor key is present in the configured
/// printInfo before the modal would open.
func testTicketRestoredPreModal() throws {
let printInfo = NSPrintInfo()
let ticket = try makeTicket(queue: boundQueue(of: printInfo))
service.configureBoundPanel(
printInfo, request: request(ticket: ticket), printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"305")
}
/// D6 parity — Stage 2 selections win over the restored ticket.
func testStage2WinsOverTicket() throws {
let printInfo = NSPrintInfo()
let ticket = try makeTicket(queue: boundQueue(of: printInfo))
var selections = PrintPanelInitialSelections()
selections.qualityKey = "EPIJ_Qual"
selections.quality = "303"
service.configureBoundPanel(
printInfo,
request: request(ticket: ticket, selections: selections),
printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"303")
}
/// R3 — a ticket captured for another queue is refused; the
/// destination's settings stay untouched and nothing throws.
func testCrossQueueTicketRefusedAtPanel() throws {
let printInfo = NSPrintInfo()
let bound = boundQueue(of: printInfo)
let foreign = "DefinitelyNot_\(bound)"
try XCTSkipIf(
bound == foreign,
"destination resolved to the foreign queue")
XCTAssertTrue(
PMTicketBridge.setValue(
"999", forKey: "EPIJ_Qual", locked: false,
in: PMTicketBridge.settings(printInfo),
context: "PrintPanelTicketTests"))
let ticket = try makeTicket(queue: foreign)
service.configureBoundPanel(
printInfo, request: request(ticket: ticket), printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"999")
}
/// First-ever open — no ticket, selections still land and the
/// post-selections validate keeps them readable.
func testNoTicketKeepsSelections() {
let printInfo = NSPrintInfo()
var selections = PrintPanelInitialSelections()
selections.paperSize = "A4"
selections.qualityKey = "EPIJ_Qual"
selections.quality = "303"
selections.orientation = "landscape"
service.configureBoundPanel(
printInfo,
request: request(ticket: nil, selections: selections),
printer: nil)
let settings = PMTicketBridge.settings(printInfo)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "PageSize", in: settings), "A4")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual", in: settings), "303")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "orientation-requested", in: settings), "4")
// The suppression writes still land on the ticket so the PDE
// sees the locked AP_* pair (③) and Quartz vocabulary (⑤′).
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "AP_ColorMatchingMode", in: settings),
"AP_ApplicationColorMatching")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "PMColorMatchingMode", in: settings),
"APCustomColorMatching")
}
}
@@ -408,6 +408,169 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty)
}
// MARK: - Media-aware quality filtering (#214)
/// Constrains the loaded fixture caps: `Stationery` allows
/// {301,302,303,304}, `Glossy` allows {305,307}, `Matte` is
/// unconstrained. Mirrors the Epson `EPIJUIConstraint` matrix.
private func constrainCaps(_ vm: PrintSessionViewModel) {
var caps = vm.printerCaps
caps.qualityIDsByMediaType = [
"Stationery": ["301", "302", "303", "304"],
"Glossy": ["305", "307"],
]
vm.printerCaps = caps
}
/// Media switch → an invalid quality pick re-seeds to the first
/// allowed choice; the picker source shrinks to the media's set.
func testMediaChangeClampsInvalidQuality() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print)
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
XCTAssertEqual(workflow.print.selectedQuality, "303")
workflow.print.selectedMediaType = "Glossy"
XCTAssertEqual(workflow.print.selectedQuality, "305")
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
["305", "307"])
}
/// A still-valid pick survives a media switch; an unconstrained
/// media keeps the pick too.
func testMediaChangeKeepsValidQuality() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print)
workflow.print.selectedQuality = "302"
workflow.print.selectedMediaType = "Matte" // unconstrained
XCTAssertEqual(workflow.print.selectedQuality, "302")
workflow.print.selectedMediaType = "Glossy" // 302 invalid
XCTAssertEqual(workflow.print.selectedQuality, "305")
workflow.print.selectedQuality = "307"
workflow.print.selectedMediaType = "Stationery" // 307 invalid
// Driver default 303 is allowed there → preferred over first.
XCTAssertEqual(workflow.print.selectedQuality, "303")
}
/// Seeding lands inside the allowed set even when the driver
/// default quality is invalid for the first media — end to end
/// through `CupsService.capabilities(for:)` + a fixture
/// `PDEData.dat` (#214).
func testQualitySeedRespectsMediaMap() async throws {
let ppdDir = env.root.appendingPathComponent("ppd")
let epsonRoot = env.root.appendingPathComponent("epson-driver")
let datDir = epsonRoot.appendingPathComponent(
"Machine/M.data/Contents/Resources")
try FileManager.default.createDirectory(
at: ppdDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: datDir, withIntermediateDirectories: true)
try """
*EPIJDriverBasePath: "\(epsonRoot.path)"
*EPIJMachineBundleName: "M.data"
""".write(
to: ppdDir.appendingPathComponent("Mock_Q.ppd"),
atomically: true, encoding: .utf8)
// Stationery forbids everything except 305/307 — including the
// `lpoptions` default 303.
try """
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 301
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 302
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 303
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 308
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 304
""".write(
to: datDir.appendingPathComponent("PDEData.dat"),
atomically: true, encoding: .utf8)
var environment = env.environment
environment = AppEnvironment(
stateStore: environment.stateStore,
settingsStore: environment.settingsStore,
presetStore: environment.presetStore,
runner: environment.runner,
cupsService: CupsService(
processManager: ProcessManager(),
binaryDir: binDir, ppdDir: ppdDir),
historyStore: environment.historyStore,
mediaStore: environment.mediaStore,
recentProjectsStore: environment.recentProjectsStore)
let workflow = TargetWorkflowViewModel(environment: environment)
workflow.print.selectedPrinter = "Mock_Q"
await workflow.print.reloadSelectedCapabilities()
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
XCTAssertEqual(workflow.print.printerCaps
.qualityIDsByMediaType["Stationery"], ["305", "307"])
// 303 is the driver default but invalid on Stationery → 305.
XCTAssertEqual(workflow.print.selectedQuality, "305")
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
["305", "307"])
}
/// No constraint map → every quality stays selectable on every
/// media (the pre-#214 behaviour, by design for unknown drivers).
func testUnconstrainedDriverKeepsAllQualities() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
workflow.print.selectedMediaType = "Glossy"
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
["301", "302", "303", "308", "304", "305", "307"])
XCTAssertEqual(workflow.print.selectedQuality, "303")
}
/// A captured quality invalid for the captured media is dropped —
/// the clamped selection stands.
func testPanelResultInvalidQualityDropped() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "EPIJ_Qual=305", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print) // Stationery forbids 305
XCTAssertEqual(workflow.print.selectedQuality, "303")
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.selectedQuality, "303")
}
/// `makeRequest` substitutes a stale-invalid quality before the
/// ticket is written — the last gate before the driver (#214).
func testSpoolSubstitutesStaleQuality() async throws {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print)
workflow.print.selectedMediaType = "Glossy"
workflow.print.selectedQuality = "308" // stale, invalid
workflow.print.spooler = RecordingTargetSpooler(
logURL: spoolLogURL)
let tiff = env.root.appendingPathComponent("page1.tif")
try Data([0x49, 0x49]).write(to: tiff)
let page = GalleryPage(
index: 0,
page: PrinttargPage(
filename: "page1.tif", patches: 10,
widthMm: 210, heightMm: 297),
fileURL: tiff, previewPNG: nil, previewError: nil)
workflow.print.printPage(page)
let log = await waitForFile(spoolLogURL)
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
XCTAssertFalse(log.contains("EPIJ_Qual=308"), log)
}
/// Poll until the panel task posts a notice whose text contains
/// `fragment` (the Task-completion signal for `nil` results too).
private func waitForNotice(
@@ -4,7 +4,7 @@ import Foundation
import XCTest
@testable import ICCery
/// Issue #201 Phase 3 — `TargetPageCanvasView` top-down page stacking,
/// Issue #201 Phase 3 — `TargetPageCanvasView` bottom-up page stacking,
/// snapped top-left anchoring, and the draw-time interpolation /
/// antialias contract asserted via `drawProbe`.
@MainActor
@@ -35,11 +35,13 @@ final class TargetCanvasGeometryTests: XCTestCase {
// MARK: - Page stacking
func testRectForPageStacksTopDown() {
/// Bottom-up space: page 1 is the BOTTOM band of the frame — the
/// non-flipped pagination convention (#211).
func testRectForPageStacksBottomUp() {
let view = TargetPageCanvasView(
pages: [raster(), raster(), raster()],
paperSize: paperSize)
XCTAssertTrue(view.isFlipped)
XCTAssertFalse(view.isFlipped)
let page1 = view.rectForPage(1)
let page3 = view.rectForPage(3)
@@ -68,18 +70,33 @@ final class TargetCanvasGeometryTests: XCTestCase {
pages: [raster(), raster()],
paperSize: paperSize)
// Near-full-page raster: in bottom-up space the page's top
// edge is `maxY`, so the top-anchored origin is ~0.03 pt
// negative — the overflow clips at the BOTTOM of the sheet.
let first = view.destinationRect(forPage: 1)
XCTAssertEqual(first.origin.x, 0, accuracy: 0.001)
XCTAssertEqual(first.origin.y, 0, accuracy: 0.001)
XCTAssertEqual(first.origin.y, -0.03, accuracy: 0.001)
XCTAssertEqual(first.width, 595.2, accuracy: 0.01)
XCTAssertEqual(first.height, 841.92, accuracy: 0.01)
let second = view.destinationRect(forPage: 2)
XCTAssertEqual(
second.origin.y, paperSize.height, accuracy: 0.001)
second.origin.y,
2 * paperSize.height - first.height, accuracy: 0.001)
XCTAssertEqual(second.size, first.size)
for rect in [first, second] {
// A raster smaller than the paper hangs from the page's TOP
// edge — minY would anchor it at the bottom (#211 follow-up).
let small = TargetPageCanvasView(
pages: [raster(pixelWidth: 72, pixelHeight: 72, dpi: 72)],
paperSize: paperSize)
let smallRect = small.destinationRect(forPage: 1)
XCTAssertEqual(smallRect.origin.x, 0, accuracy: 0.001)
XCTAssertEqual(
smallRect.origin.y, paperSize.height - 72, accuracy: 0.001)
XCTAssertEqual(smallRect.size, CGSize(width: 72, height: 72))
for rect in [first, second, smallRect] {
XCTAssertEqual(
rect.origin.x * 1000,
(rect.origin.x * 1000).rounded(),
@@ -27,12 +27,16 @@ enum TargetTestFixtures {
/// colour space for 1/3/4 components at 8 or 16 bpc. `alpha: true`
/// appends one alpha sample per pixel (`last`). `pixelBytes` is the
/// repeating per-pixel pattern (big-endian for 16 bpc).
/// `bottomHalfPixelBytes`, when set, is the pattern for the bottom
/// half of the rows — a vertical asymmetry that lets tests catch a
/// mirrored draw (#211).
static func makeImage(
px: CGSize,
components: Int,
bitsPerComponent: Int,
alpha: Bool = false,
pixelBytes: [UInt8]? = nil
pixelBytes: [UInt8]? = nil,
bottomHalfPixelBytes: [UInt8]? = nil
) -> CGImage? {
let width = Int(px.width)
let height = Int(px.height)
@@ -40,19 +44,31 @@ enum TargetTestFixtures {
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
func buildRow(_ pattern: [UInt8]) -> [UInt8] {
var row = [UInt8](repeating: 0, count: bytesPerRow)
for offset in stride(
from: 0, to: bytesPerRow, by: pattern.count) {
for (index, byte) in pattern.enumerated()
where offset + index < bytesPerRow {
row[offset + index] = byte
}
}
return row
}
let topRow = buildRow(
pixelBytes
?? Array(0..<bytesPerPixel).map {
UInt8(($0 * 37 + 11) & 0xff)
})
let bottomRow = buildRow(bottomHalfPixelBytes ?? pixelBytes
?? Array(0..<bytesPerPixel).map {
UInt8(($0 * 37 + 11) & 0xff)
})
var bytes = [UInt8]()
bytes.reserveCapacity(bytesPerRow * height)
for _ in 0..<height {
bytes.append(contentsOf: row)
for rowIndex in 0..<height {
bytes.append(
contentsOf: rowIndex < height / 2 ? topRow : bottomRow)
}
let data = Data(bytes)
@@ -88,6 +104,7 @@ enum TargetTestFixtures {
/// Writes `makeImage` output to a single-page TIFF. `dpi` nil
/// produces a TIFF with no resolution tags (the 72-fallback path).
/// `bottomHalfPixelBytes` passes through to `makeImage` (#211).
static func makeTIFF(
px: CGSize,
dpi: Double?,
@@ -95,12 +112,14 @@ enum TargetTestFixtures {
bitsPerComponent: Int = 8,
alpha: Bool = false,
pixelBytes: [UInt8]? = nil,
bottomHalfPixelBytes: [UInt8]? = nil,
in directory: URL
) throws -> URL {
guard let image = makeImage(
px: px, components: components,
bitsPerComponent: bitsPerComponent,
alpha: alpha, pixelBytes: pixelBytes)
alpha: alpha, pixelBytes: pixelBytes,
bottomHalfPixelBytes: bottomHalfPixelBytes)
else { throw FixtureError.imageNotCreated }
let url = directory.appendingPathComponent(
"fixture-\(UUID().uuidString).tiff")
+1 -1
View File
@@ -22,5 +22,5 @@ if [ "$list" = "1" ]; then
printf 'ColorModel/Output Mode: *RGB Gray\n'
exit 0
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
+6
View File
@@ -12,6 +12,12 @@ case "$1" in
-d)
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
;;
@@ -251,6 +251,87 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
}
/// #214 — a fixture PPD pointing at a fixture `PDEData.dat` (via
/// `ICCERY_CUPS_PPD_DIR`) constrains the quality picker to the
/// media's allowed set; switching media re-filters and clamps the
/// selection.
func testQualityPickerFiltersByMediaConstraints() throws {
let ppdDir = testRoot.appendingPathComponent("ppd")
let epsonRoot = testRoot.appendingPathComponent("epson-driver")
let datDir = epsonRoot.appendingPathComponent(
"Machine/M.data/Contents/Resources")
try FileManager.default.createDirectory(
at: ppdDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: datDir, withIntermediateDirectories: true)
try """
*EPIJDriverBasePath: "\(epsonRoot.path)"
*EPIJMachineBundleName: "M.data"
""".write(
to: ppdDir.appendingPathComponent("Mock_Epson_7450.ppd"),
atomically: true, encoding: .utf8)
try """
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 305
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 307
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 308
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 301
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 302
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 303
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 304
""".write(
to: datDir.appendingPathComponent("PDEData.dat"),
atomically: true, encoding: .utf8)
app.launchEnvironment["ICCERY_CUPS_PPD_DIR"] = ppdDir.path
launchAppWithDefaults()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
let qualityPopup = app.popUpButtons["printerQualitySelect"]
XCTAssertTrue(qualityPopup.waitForExistence(timeout: 10))
// Default media Stationery → {301,302,303,304} only.
qualityPopup.click()
let stationeryExpected = ["301", "302", "303", "304"]
for token in stationeryExpected {
XCTAssertTrue(
app.menuItems[token].waitForExistence(timeout: 5),
"Missing quality menu item \(token)")
}
XCTAssertFalse(app.menuItems["307"].exists)
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
// Switch to PhotographicGlossy → {305,307,308} in driver
// order (308 first — lpoptions order), selection clamped.
let mediaPopup = element("printerMediaTypeSelect")
.descendants(matching: .popUpButton).firstMatch
XCTAssertTrue(mediaPopup.waitForExistence(timeout: 5))
mediaPopup.click()
let glossyItem = app.menuItems["PhotographicGlossy"]
XCTAssertTrue(glossyItem.waitForExistence(timeout: 5))
glossyItem.click()
qualityPopup.click()
let glossyExpected = ["308", "305", "307"]
for token in glossyExpected {
XCTAssertTrue(
app.menuItems[token].waitForExistence(timeout: 5),
"Missing quality menu item \(token)")
}
let titles = app.menuItems.allElementsBoundByIndex
.map(\.title)
.filter { glossyExpected.contains($0) }
XCTAssertEqual(titles, glossyExpected)
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
// The stale pick (303) clamped to the first allowed token.
let deadline = Date().addingTimeInterval(5)
while Date() < deadline,
selection(of: "printerQualitySelect") != "308" {
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
XCTAssertEqual(selection(of: "printerQualitySelect"), "308")
}
/// #201 D5 — per-page mode records one line per page; the
/// `chkSingleSpoolJob` toggle collapses the job into a single
/// request logged once with `pages=N`.
@@ -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")
}
}
+18 -29
View File
@@ -114,18 +114,18 @@ final class Milestone3UITests: XCTestCase {
return recordedSpoolLog()
}
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button
/// moves up, clear of the Dock collision zone at the window's
/// bottom edge (#132).
/// Scrolls `stage-2` with the synthesized scroll wheel so
/// `identifier`'s button moves up, clear of the Dock collision
/// zone at the window's bottom edge (#132).
///
/// macOS overlay scrollbars are not in the AX tree — never use
/// `app.scrollBars` — and a synthesized scroll wheel is inert on
/// this LazyVGrid, so the scroll is a real drag on the gallery
/// cell's content. A stale/off-screen AX frame resolves to a screen
/// point that can be a Dock icon — a coordinate click there once
/// opened Calendar instead of Print. Callers must click only when
/// the returned element `isHittable`; never coordinate-click a
/// stale frame.
/// `app.scrollBars` — and a click-drag does not scroll a macOS
/// ScrollView (content-drag scrolling is iOS-only); the scroll
/// wheel is the mechanism the platform supports (#215). A
/// stale/off-screen AX frame resolves to a screen point that can
/// be a Dock icon — a coordinate click there once opened Calendar
/// instead of Print. Callers must click only when the returned
/// element `isHittable`; never coordinate-click a stale frame.
@discardableResult
private func scrollStage2UntilHittable(
_ identifier: String,
@@ -134,6 +134,8 @@ final class Milestone3UITests: XCTestCase {
var button = app.buttons[identifier]
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0")
let scrollView = app.scrollViews["stage-2"]
XCTAssertTrue(scrollView.waitForExistence(timeout: 10), "stage-2")
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
@@ -142,25 +144,12 @@ final class Milestone3UITests: XCTestCase {
button.frame.maxY < windowBottom - 80 {
return button
}
// Grab the upper half of the cell (the TIFF, not the Print
// button / Dock) and drag toward the top of the window.
// Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves
// the Dock zone.
if cell.isHittable {
let start = cell.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.25))
let end = start.withOffset(CGVector(dx: 0, dy: -280))
start.press(forDuration: 0.15, thenDragTo: end)
} else {
// Cell not hit-testable: drag the stage-2 content
// directly — still content, still never scrollBars.
let scrollView = app.scrollViews["stage-2"]
scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.55))
.press(forDuration: 0.15, thenDragTo:
scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.15)))
}
// Wheel-down inside the stage-2 viewport: content moves
// UP ⇒ Print leaves the Dock zone. Negative deltaY scrolls
// toward the document bottom (#215).
scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.5))
.scroll(byDeltaX: 0, deltaY: -60)
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
button = app.buttons[identifier]
}
+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`. |
| `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 |
|-------|------|---------|-------|-------|
+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
- `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):
@@ -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.
### 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]
show_printer_properties
run_on_main_thread
run_native_print_panel
① PMSessionSetCurrentPMPrinter bind queue
PrintSessionViewModel.openPrinterPreferences
PrintPanelService.showProperties
runNativePanel
① PMPrinterCreateFromPrinterID + PMSessionSetCurrentPMPrinter bind queue
② set_session_color_matching_mode SPI gray out PDE Color Matching
③ PMPrintSettingsSetValue AP_ColorMatchingMode + dotted
④ detect_driver_color_bypass → SetValue pre-select Canon/Epson/Gutenprint "off"
⑤ 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)
⑥ PMPrintSettingsToOptions → filter → PrintPropertiesResult
⑥ PMPrintSettingsToOptions → filter → Stage 2 mirror
⑦ PMTicketBridge.serialise(printInfo) → PrintTicket ticket capture
[frontend]
capturedCupsOptions[printer] = cups_options
capturedCupsOptions[queue] = mirror + capturedTickets[queue] = ticket
[Print Target]
print_target_native → macos::print_target → build_lp_args
ALWAYS -o AP_ColorMatchingMode=AP_ApplicationColorMatching
ALWAYS -o AP.ColorMatchingMode=AP_ApplicationColorMatching
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>
PrintSessionViewModel → TargetPrintRequest → NativeTargetSpooler.spool
restore ticket → Stage 2 writes → NSPrintOperation.runOperation
(S1–S14 below — 1:1, device colour space, panels off)
```
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_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)
@@ -204,6 +201,18 @@ print_settings.insert(<bypass_key>, <bypass_val>)
`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)
```
@@ -231,9 +240,27 @@ After OK:
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)
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
@@ -255,31 +282,38 @@ Duplex: Duplex, sides
- Drops `collate`, `copies`, `pserrorhandler-requested`, `job-sheets`
- **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>"
-o AP_ColorMatchingMode=AP_ApplicationColorMatching
-o AP.ColorMatchingMode=AP_ApplicationColorMatching
… captured / detected options …
<tiff_path>
S1 NSPrintInfo()
S2 printInfo.printer = NSPrinter(name: queue) ?? NSPrinter(name: displayName) [best effort]
S3 PMTicketBridge.makePrinter(queue:) → bind(printer:to:) ① + PMSessionDefault*
S4 ticket != nil → PMTicketBridge.restore(ticket, into: printInfo) ⑦′
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:
1. Parse `opts.cups_options` into `-o k=v`, record lowercased keys in `added_keys`.
2. If `media_type` set and none of `mediatype` / `cnijmediatype` / `epij_medi` / `stpmediatype` already added: `detect_media_type_key(lpoptions)` and add it.
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`.**
4. Orientation → `orientation-requested=4|3` unless already present.
5. `PageSize=` unless `pagesize` already present.
`ppd_uncorrected_passthrough` is stored from the panel but **does not change macOS lp flags**. There is no `-o raw` on macOS.
### `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: …"`).
`TicketWriteResolver` produces the resolved write list as a pure value, in a
locked order: both `AP_*` keys (locked) → the three Quartz keys (⑤′) →
`PageSize` → the detected media key → the detected quality key → the driver
colour bypass → `orientation-requested`. Stage 2 overrides **always win**
over the rehydrated ticket (D6 — the v1 captured-wins inversion is gone);
`raw` can never appear because there is no `lp`. Panels stay off
(`showsPrintPanel` / `showsProgressPanel` false, `canSpawnSeparateThread`
false); a multi-page job is one `TargetPageCanvasView` driven by
`knowsPageRange` / `rectForPage`, drawing each page 1:1 top-left anchored
with interpolation and antialiasing disabled (D9, docs/14 §6).
### Cancellation as `None`
@@ -295,19 +329,26 @@ The rewrite:
- `display_name` fallback for `NSPrinter::printerWithName`.
- Private SPI to lock Color Matching.
- Dual AP_* keys (underscore + dotted).
- Driver-specific PPD bypass pre-selected and re-applied on `lp`.
- Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions`.
- 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` (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")`).
### 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`.
- `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`.
- 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
@@ -338,7 +379,12 @@ PMRelease
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 |
|------|----------|------|
+4 -1
View File
@@ -53,7 +53,10 @@ First-match order:
`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>"
+10 -10
View File
@@ -8,7 +8,7 @@ Separate native macOS AppKit app. Spec: `/tmp/ICCery-CPU/SPEC.md`. Binary name `
### 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
- 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)
- 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`):
@@ -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`.
- 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` |
| 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/`.
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`.
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 |
|---------|-------------------|-------------|------------------------|
| Spool | `lp` TIFF | Quartz `NSPrintOperation` | Keep `lp` for unattended; TargetPrint for preview+panel |
| ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** if using NSPrintOperation; keep AP_* on `lp` |
| 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** — **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) |
| 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 |
| Geometry | none (filter decides) | 1:1 pt from DPI | TargetPrint (or do not scale in GDI/`lp`) |
| Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges |
| AirPrint | none | detected + warned | Port detector into ICCery printer list |
| 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 — **adopted in v2.0 via #201** (interpolation/antialias off in `TargetPageCanvasView.draw`) |
| 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 |
| Windows | GDI ICM_OFF | n/a | Keep GDI; do not route through TargetPrint |
+1 -1
View File
@@ -85,7 +85,7 @@ Lock these before rewriting UI:
4. `parseProfcheckReport` JSON + legacy + empty→warning
5. Color bypass detector: Canon/Epson/Gutenprint samples
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`
9. `snapshot_ti3` 1-based and removes canonical
10. DEVMODE round-trip size
+9 -3
View File
@@ -29,6 +29,12 @@
## 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.
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).
@@ -120,7 +126,7 @@ Issues 1–6.
**Hardware:** none.
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.
**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.
- 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`
Milestone: M3
@@ -583,7 +589,7 @@ Milestone: **Later**
- 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.
- 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`.
- 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.
+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_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.1"
MARKETING_VERSION: "2.0.3"
CURRENT_PROJECT_VERSION: "1"
# Stamped by scripts/version.sh at packaging/CI time (release tag or
# `git describe` string); About shows it next to the marketing version.
+1 -1
View File
@@ -150,7 +150,7 @@ if [ ! -f "$DMG_BACKGROUND" ]; then
fi
echo "==> Building DMG"
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.0')"
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.3')"
BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')"
# Tagged/described builds carry the tag in the DMG name so prerelease
# artefacts are self-describing (ICCery-2.0.0-pre2-grok-236.dmg); a plain