Compare commits

...
Author SHA1 Message Date
gronod f5163bccca docs: add printing-workflow roadmap candidates
macOS CI / build-and-test (push) Successful in 8s
macOS CI / package (push) Successful in 3m17s
Outline features, defects, UI tweaks, and backend work that can
follow the shipped printer path. Display profiling and issue #16
stay out of scope.
2026-09-23 08:45:37 +01:00
gronod efeefb8cea chore: bump MARKETING_VERSION to 2.0.4
macOS CI / build-and-test (push) Successful in 43m3s
macOS CI / package (push) Successful in 3m25s
Bumps the project version ahead of the develop→main merge so the
upcoming release tag can match project.yml.
2026-09-22 19:40:28 +01:00
gronod 7fc38aabbb Merge branch 'milestone/m13-print-ticket' of https://git.i3omb.com/gronod/iccery-v2-mac into milestone/m13-print-ticket
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 14m9s
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 1m48s
2026-09-22 19:22:48 +01:00
gronod 972e166430 Merge branch 'feat/218-paper-source' into milestone/m13-print-ticket 2026-09-22 19:21:18 +01:00
gronod ecbf46723b feat(print): #218 Stage 2 paper-source selection + orientation dropdown
macOS CI / build-and-test (push) Skipped
- Detect vendor tray keys in CupsParsers with InputSlot/MediaSource/EPIJ_FdSo/EPIJ_PSrc/CNIJMediaSupply roster; EPIJ_FdSo wins over EPIJ_PSrc.
- PrinterTray now carries raw token + display name; PrinterCapabilities exposes trayKey, trayDefault and tray-specific quality maps.
- CupsService enumerates trays from the detected key, enriches labels from PPD, seeds selection from CUPS default, and precomputes per-tray media→quality constraints.
- MediaQualityConstraints resolves quality availability from both selected media and selected tray, falling back to the media-only map.
- TicketWriteResolver emits the selected tray key=token in the locked write order between PageSize and media key.
- PrintPanelService applies the Stage 2 tray token to PMPrintSettings on open and captures the tray back from the full roster plus AP_D_InputSlot.
- PrintSessionViewModel keeps selectedTrayToken, applies capture-back, preserves unknown captured tokens as synthetic trays, and passes tray into spool overrides.
- Stage2View replaces portrait/landscape buttons with an orientation dropdown (printerOrientationSelect) while preserving portrait/landscape semantics and orientation-requested=3|4.
- Add/update core, UI and fixture tests for tray detection, enumeration, PPD labels, defaults, unknown-token synthesis, panel capture, ticket write order and tray-aware quality clamping.
- Update docs/10-print-system.md, docs/11-print-macos.md, docs/21-ui-reference.md and docs/24-issues-invariants.md.

Verified on the macOS 12.7.6 x86_64 CI runner: xcodebuild build-for-testing and ICCeryCoreTests pass (557 tests, 0 failures).
2026-09-22 19:19:33 +01:00
gronod bd14f30a05 Merge pull request '[Bug/High] Restore captured PrintTicket when reopening the printer properties dialog' (#219) from feat/217-panel-ticket-restore into milestone/m13-print-ticket
macOS CI / build-and-test (push) Skipped
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 1m25s
Reviewed-on: #219
2026-09-22 18:56:31 +01:00
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
41 changed files with 4112 additions and 318 deletions
+74 -3
View File
@@ -11,8 +11,12 @@ Native macOS printer ICC/ICM profiling frontend. Drives the Gronod ArgyllCMS 3.5
- No Tauri, no Rust host, no WKWebView, no Three.js. - No Tauri, no Rust host, no WKWebView, no Three.js.
## Package layout ## Package layout
- `ICCery` — app target (SwiftUI shell). - `ICCery` — app target (SwiftUI shell). Also owns the native print stack in
- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel). `Sources/ICCery/Print/`: `PMTicketBridge`, `PrintTicket`,
`TicketWriteResolver`, `NativeTargetSpooler` (+ `RecordingTargetSpooler`),
`TargetRaster`, `TargetPageCanvasView` (#201 D1 — AppKit/`NSPrintOperation`
lives here, never in `ICCeryCore`).
- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel; CUPS enumeration/parsers only).
- `ICCeryPrintKit` — v2.1 only (issue 16). Zero deps on wizard types. - `ICCeryPrintKit` — v2.1 only (issue 16). Zero deps on wizard types.
## AGPL boundary ## AGPL boundary
@@ -39,6 +43,17 @@ Empty cwd illegal (#59). Atomic writes = `.tmp` + rename (#213).
User-supplied strings via SwiftUI `Text` only (#114). User-supplied strings via SwiftUI `Text` only (#114).
TIFF never rendered directly — host-side PNG preview (#58). TIFF never rendered directly — host-side PNG preview (#58).
## Print spool — native since v2.0 (#201); v1 `lp` path eradicated
Target printing is a headless `NSPrintOperation` via `NativeTargetSpooler`
(#201): restore the captured `PrintTicket`, apply `TicketWriteResolver`
(Stage 2 always wins, D6), draw 1:1 with interpolation off.
`lp` is eradicated from the target-print path (historical v1: `LpArgs`,
`CupsService.printTarget`, `ICCERY_TEST_LP_ARGV` all deleted).
`CupsParsers`/`CupsOptionsFilter` stay (D4): enumeration, capabilities,
media/quality/bypass key detection and the Stage 2 mirror.
UI-test seam: `ICCERY_TEST_SPOOL_LOG` — DEBUG `RecordingTargetSpooler`
appends one resolved-ticket line per page (D8).
## Versioning ## Versioning
`scripts/version.sh` is the single source: tag/describe → `ICCERY_RELEASE_TAG` `scripts/version.sh` is the single source: tag/describe → `ICCERY_RELEASE_TAG`
(About shows `tag (marketing)`), `MARKETING_VERSION` = strict `X.Y.Z`, (About shows `tag (marketing)`), `MARKETING_VERSION` = strict `X.Y.Z`,
@@ -78,10 +93,66 @@ codesign -dvv <sidecar>
``` ```
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging. Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
## Remote test builds (runner host)
**Policy: all builds and test runs that _can_ execute on the remote host
_should_ execute there.** Use `localadmin@192.168.0.172` whenever the
Gitea `macos_12` runner is not actively executing a workflow job.
Test builds done as part of change implementations run on the CI runner
host, not the dev machine — the runner is a macOS 12.7.6 **x86_64** VM
with Xcode 14.2, and CI failures there are often environmental and do not
reproduce locally.
- Host: `localadmin@192.168.0.172` (SSH, LAN). Runner daemon:
`gitea-runner daemon -c /opt/gitea-runner/config.yaml`.
- Credentials: `~/Projects/build-host.env` defines `BUILD_HOST_USER` /
`BUILD_HOST_PASSWORD`. **Never read or print this file** — load it with
`set -a; source ~/Projects/build-host.env; set +a` so values stay in the
environment.
- `sshpass` is installed (`/usr/local/bin/sshpass`). Feed the password via
the `SSHPASS` env var — never on the command line:
```sh
SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e ssh localadmin@192.168.0.172 '<cmd>'
SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e scp <local> localadmin@192.168.0.172:<remote>
```
First connection needs `-o StrictHostKeyChecking=accept-new`.
- Remote checkout: `~/Projects/iccery-v2-mac` (anonymous HTTPS clone works;
full history needed by `scripts/version.sh`). Before testing, sync it to
the same base commit as the local work:
`git fetch origin && git checkout <local base sha>`, then `scp` each
locally-changed file over its remote counterpart (uncommitted work
transfers this way — there is no push).
- `xcodegen` is **not** on PATH: prepend
`$HOME/.local/xcodegen/2.38.0/xcodegen/bin` (prebuilt 2.38.0 installed
by `scripts/ensure-host-tools.sh`; brew's formula needs Xcode 15.3,
impossible on macOS 12 — #109).
- Mirror `.gitea/workflows/macos.yml` exactly, from the repo root with
`DERIVED=build/DerivedData-test`:
`scripts/ensure-host-tools.sh` → `xcodegen generate --spec project.yml`
→ `eval "$(scripts/version.sh)"` → `xcodebuild build-for-testing
-scheme ICCery -destination 'platform=macOS' -derivedDataPath "$DERIVED"
-configuration Debug ARCHS="$(uname -m)" ONLY_ACTIVE_ARCH=NO
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-' MARKETING_VERSION=…
CURRENT_PROJECT_VERSION=… ICCERY_RELEASE_TAG=…` → codesign every
`*_PackageProduct.framework` under `$DERIVED/Build/Products/Debug`
(#119) → `xcodebuild test-without-building -xctestrun <ICCery*.xctestrun>
-only-testing:ICCeryCoreTests -destination 'platform=macOS'
-derivedDataPath "$DERIVED"`.
- Run **unit tests only** (`ICCeryCoreTests`). UI tests need a logged-in
GUI session and fail environmentally on the VM (runs 29700, 29804,
42183) — do not use them for verification unless asked.
- xcodebuild takes minutes — run the ssh command in a background shell
and poll rather than blocking.
## Private ColorSync SPI ## Private ColorSync SPI
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`. 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`. Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix. One spool path remains (#201 D2): write **both** vocabularies on the native
path — locked AP_* (`AP_ColorMatchingMode` + `AP.ColorMatchingMode` =
`AP_ApplicationColorMatching`) **and** the Quartz dictionary
(`PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`,
legacy `com.apple.print.PrintSettings.PMColorMatchingMode`, nested
`com.apple.print.printSettings` mirror).
## Gitea issue dependencies ## Gitea issue dependencies
Use the `gitea` MCP (custom build with blocking support — verified working): Use the `gitea` MCP (custom build with blocking support — verified working):
@@ -17,7 +17,8 @@ public enum CupsOptionsFilter {
// Media // Media
"MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType", "MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType",
// Tray // Tray
"InputSlot", "AP_D_InputSlot", "InputSlot", "MediaSource", "AP_D_InputSlot",
"EPIJ_FdSo", "EPIJ_PSrc", "CNIJMediaSupply",
// Size // Size
"PageSize", "PageSize",
// Colour bypass // Colour bypass
@@ -73,6 +73,26 @@ public enum CupsParsers {
return nil return nil
} }
/// `lpstat -v` — `device for <name>: <uri>` → queue → device URI.
/// Entries with no URI (e.g. `network <name>` remote stubs) are
/// skipped (#202 AirPrint detection).
public static func lpstatDeviceURIs(output: String) -> [String: String] {
var result: [String: String] = [:]
for line in output.split(separator: "\n") {
let text = line.trimmingCharacters(in: .whitespaces)
guard text.hasPrefix("device for ") else { continue }
let rest = text.dropFirst("device for ".count)
guard let colon = rest.firstIndex(of: ":") else { continue }
let name = String(rest[..<colon])
.trimmingCharacters(in: .whitespaces)
let uri = String(rest[rest.index(after: colon)...])
.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty, !uri.isEmpty else { continue }
result[name] = uri
}
return result
}
// MARK: - lpoptions -p <queue> // MARK: - lpoptions -p <queue>
/// `lpoptions -p` — `key=value` pairs, values may be /// `lpoptions -p` — `key=value` pairs, values may be
@@ -137,6 +157,16 @@ public enum CupsParsers {
return value return value
} }
/// Make-and-model from `printer-make-and-model` in `lpoptions -p`
/// output — an AirPrint detection input (#202).
public static func lpoptionsMakeAndModel(output: String) -> String? {
guard let value = lpoptions(output)
.first(where: { $0.key == "printer-make-and-model" })?.value,
!value.isEmpty
else { return nil }
return value
}
// MARK: - lpoptions -l // MARK: - lpoptions -l
/// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`. /// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`.
@@ -323,6 +353,55 @@ public enum CupsParsers {
.map { $0 == "4" ? "landscape" : "portrait" } .map { $0 == "4" ? "landscape" : "portrait" }
} }
// MARK: - Tray detection (#218)
/// Paper-source option key in preference order. `EPIJ_FdSo` is
/// preferred over `EPIJ_PSrc` because Epson InkjetPrinter2 PPDs
/// label `EPIJ_FdSo` as "Paper Source" and `EPIJ_PSrc` as
/// "Page Setup" — the feed source is what Stage 2 should control.
/// `AP_D_InputSlot` is a capture-side alias only.
public static let trayKeys = [
"InputSlot", "MediaSource", "EPIJ_FdSo", "EPIJ_PSrc",
"CNIJMediaSupply"
]
public static func detectTrayKey(optionKeys: Set<String>) -> String? {
trayKeys.first { optionKeys.contains($0) }
}
/// Extract a paper-source token from a captured `key=value` string.
/// If `preferredKey` is supplied and present, it wins; otherwise
/// every roster key is tried. `AP_D_InputSlot` is accepted as an
/// alias when it carries a non-empty value. Empty values are
/// ignored so an `AP_D_InputSlot=` placeholder never maps to a
/// selection (#218 capture-back).
public static func extractTray(
fromOptionsString options: String,
preferredKey: String? = nil
) -> String? {
let pairs = lpoptions(options)
if let preferredKey,
let value = pairs.first(where: {
$0.key.caseInsensitiveCompare(preferredKey) == .orderedSame
})?.value, !value.isEmpty {
return value
}
for key in trayKeys {
if let value = pairs.first(where: {
$0.key.caseInsensitiveCompare(key) == .orderedSame
})?.value, !value.isEmpty {
return value
}
}
// Capture-side alias used by some macOS Core Printing paths.
if let value = pairs.first(where: {
$0.key.caseInsensitiveCompare("AP_D_InputSlot") == .orderedSame
})?.value, !value.isEmpty {
return value
}
return nil
}
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys /// Driver "no colour adjustment" key=value for `lpoptions -l` keys
/// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`; /// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`;
/// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`; /// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`;
@@ -352,4 +431,62 @@ public enum CupsParsers {
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection", "EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
"EpsonColorMode", "EpsonColorMode",
] ]
// MARK: - AirPrint detection (#202, docs/14 §10.2)
/// AirPrint queues always colour-manage the URF raster path, so
/// unmanaged device colour cannot be guaranteed — Stage 2 warns
/// via `airPrintWarningBadge`. `true` when ANY rule matches:
///
/// 1. device URI contains `apple-airprint://`
/// 2. PPD declares `*APAirPrint: True`
/// 3. make-and-model contains "Apple" **and** "AirPrint"
/// 4. `ipps://` URI **and** the PPD text mentions "airprint"
/// (case-insensitive)
/// 5. local unencrypted `ipp://` URI resolved via an AirPrint mDNS
/// subtype (`_universal._sub._ipp._tcp`)
/// 6. PPD carries a `*cupsFilter2` rule whose destination MIME
/// type is `image/urf` — the raster only AirPrint consumes
public static func detectAirPrint(
deviceURI: String?, makeAndModel: String?, ppd: String
) -> Bool {
if let uri = deviceURI {
let lower = uri.lowercased()
if lower.contains("apple-airprint://") { return true }
if lower.hasPrefix("ipps://"),
ppd.range(of: "airprint", options: .caseInsensitive) != nil {
return true
}
if lower.hasPrefix("ipp://"),
lower.contains("_universal._sub._ipp._tcp") {
return true
}
}
if ppd.contains("*APAirPrint: True") { return true }
if let model = makeAndModel,
model.contains("Apple"), model.contains("AirPrint") {
return true
}
return ppdCarriesURFFilter(ppd)
}
/// Rule 6 — `*cupsFilter2: "src dst cost program"` (quotes
/// optional); `image/urf` as the destination token means the queue
/// consumes the AirPrint raster.
private static func ppdCarriesURFFilter(_ ppd: String) -> Bool {
for rawLine in ppd.split(separator: "\n") {
let line = rawLine.trimmingCharacters(in: .whitespaces)
guard line.lowercased().hasPrefix("*cupsfilter2"),
let colon = line.firstIndex(of: ":")
else { continue }
let tokens = line[line.index(after: colon)...]
.replacingOccurrences(of: "\"", with: "")
.split(separator: " ")
if tokens.count >= 2,
tokens[1].lowercased() == "image/urf" {
return true
}
}
return false
}
} }
@@ -57,19 +57,29 @@ public struct CupsService: Sendable {
"lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true) "lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true)
let defaultOut = try await run( let defaultOut = try await run(
"lpstat", ["-d"], id: ProcessID.cupsLpstat("d"), tolerateFailure: true) "lpstat", ["-d"], id: ProcessID.cupsLpstat("d"), tolerateFailure: true)
// One-shot device-URI fetch for AirPrint detection (#202). A
// failed `lpstat -v` is tolerated — enumeration proceeds with
// no URIs and no fallback respawn.
let deviceOut = try await run(
"lpstat", ["-v"], id: ProcessID.cupsLpstat("v"), tolerateFailure: true)
let names = CupsParsers.lpstatDestinations(destinationsOut.stdout) let names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout) let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout) let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout)
let deviceURIs = CupsParsers.lpstatDeviceURIs(output: deviceOut.stdout)
var printers: [Printer] = [] var printers: [Printer] = []
for name in names { for name in names {
let displayName = try? await displayName(for: name) let identity = try? await queueIdentity(for: name)
printers.append(Printer( printers.append(Printer(
name: name, name: name,
status: statuses[name] ?? .unknown, status: statuses[name] ?? .unknown,
isDefault: name == defaultName, isDefault: name == defaultName,
displayName: displayName displayName: identity?.displayName,
isAirPrint: CupsParsers.detectAirPrint(
deviceURI: deviceURIs[name],
makeAndModel: identity?.makeAndModel,
ppd: loadPPD(for: name) ?? "")
)) ))
} }
return printers return printers
@@ -78,9 +88,19 @@ public struct CupsService: Sendable {
/// `lpoptions -p <queue>` → `printer-info` (the NSPrinter fallback /// `lpoptions -p <queue>` → `printer-info` (the NSPrinter fallback
/// display name, docs/11 §binding). /// display name, docs/11 §binding).
public func displayName(for queue: String) async throws -> String? { public func displayName(for queue: String) async throws -> String? {
try await queueIdentity(for: queue).displayName
}
/// One `lpoptions -p <queue>` spawn yields both identity fields —
/// `printer-info` (display name) and `printer-make-and-model`
/// (AirPrint rule 3, #202).
private func queueIdentity(
for queue: String
) async throws -> (displayName: String?, makeAndModel: String?) {
let result = try await run( let result = try await run(
"lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue)) "lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue))
return CupsParsers.lpoptionsDisplayName(result.stdout) return (CupsParsers.lpoptionsDisplayName(result.stdout),
CupsParsers.lpoptionsMakeAndModel(output: result.stdout))
} }
// MARK: - Capabilities (lpoptions -l + PPD) // MARK: - Capabilities (lpoptions -l + PPD)
@@ -95,9 +115,36 @@ public struct CupsService: Sendable {
/// Trays / paper sizes / media types for a queue, with PPD /// Trays / paper sizes / media types for a queue, with PPD
/// `*Key id/Human:` enrichment when the queue's PPD is readable. /// `*Key id/Human:` enrichment when the queue's PPD is readable.
/// Also resolves the per-media quality map from vendor driver
/// data (`MediaQualityConstraints`, #214) — empty when the driver
/// exposes none.
public func capabilities(for queue: String) async throws -> PrinterCapabilities { public func capabilities(for queue: String) async throws -> PrinterCapabilities {
let listings = try await optionListings(for: queue) let listings = try await optionListings(for: queue)
return capabilities(from: listings, ppd: loadPPD(for: queue)) let ppd = loadPPD(for: queue)
var caps = capabilities(from: listings, ppd: ppd)
// Default queue-level map, then per-tray overrides (#218).
// Share one file-read cache across all contexts so multiple
// tray-token resolutions read Epson PDEData.dat only once.
var fileCache: [URL: Data] = [:]
let readFile: (URL) -> Data? = {
if let cached = fileCache[$0] { return cached }
guard let data = try? Data(contentsOf: $0) else { return nil }
fileCache[$0] = data
return data
}
caps.qualityIDsByMediaType = MediaQualityConstraints.resolve(
listings: listings, ppd: ppd,
optionOverrides: [:], readFile: readFile)
if let trayKey = caps.trayKey {
for tray in caps.trays {
let map = MediaQualityConstraints.resolve(
listings: listings, ppd: ppd,
optionOverrides: [trayKey: tray.token],
readFile: readFile)
caps.qualityIDsByMediaTypeByTray[tray.token] = map
}
}
return caps
} }
/// Pure mapping — extracted so fixture tests need no process. /// Pure mapping — extracted so fixture tests need no process.
@@ -105,16 +152,12 @@ public struct CupsService: Sendable {
from listings: [CupsOptionListing], from listings: [CupsOptionListing],
ppd: String? ppd: String?
) -> PrinterCapabilities { ) -> PrinterCapabilities {
var trays: [PrinterTray] = [] let optionKeys = Set(listings.map(\.key))
var sizes: [PrinterPaperSize] = [] var sizes: [PrinterPaperSize] = []
var media: [PrinterMediaType] = [] var media: [PrinterMediaType] = []
for listing in listings { for listing in listings {
switch listing.key { switch listing.key {
case "InputSlot", "MediaSource":
trays = listing.choices.enumerated().map {
PrinterTray(id: $0.offset + 1, name: $0.element)
}
case "PageSize", "MediaSize": case "PageSize", "MediaSize":
sizes = listing.choices.enumerated().map { sizes = listing.choices.enumerated().map {
PrinterPaperSize(id: $0.offset + 1, name: $0.element) PrinterPaperSize(id: $0.offset + 1, name: $0.element)
@@ -132,12 +175,32 @@ public struct CupsService: Sendable {
} }
} }
// Paper source — one deterministic vendor roster key (#218).
let trayKey = CupsParsers.detectTrayKey(optionKeys: optionKeys)
let trays: [PrinterTray]
let trayDefault: String?
if let trayKey,
let listing = listings.first(where: { $0.key == trayKey }) {
let labels = ppd.map {
CupsParsers.ppdChoiceLabels($0, key: trayKey)
} ?? [:]
trays = listing.choices.enumerated().map {
PrinterTray(
id: $0.offset + 1,
token: $0.element,
name: labels[$0.element] ?? $0.element)
}
trayDefault = listing.defaultChoice
} else {
trays = []
trayDefault = nil
}
// Print quality — the detected roster key's listing maps to // Print quality — the detected roster key's listing maps to
// `PrinterQuality` with PPD labels and the `*` default (#183). // `PrinterQuality` with PPD labels and the `*` default (#183).
var qualities: [PrinterQuality] = [] var qualities: [PrinterQuality] = []
var qualityDefault: String? var qualityDefault: String?
let qualityKey = CupsParsers.detectQualityKey( let qualityKey = CupsParsers.detectQualityKey(optionKeys: optionKeys)
optionKeys: Set(listings.map(\.key)))
if let qualityKey, if let qualityKey,
let listing = listings.first(where: { $0.key == qualityKey }) { let listing = listings.first(where: { $0.key == qualityKey }) {
let labels = ppd.map { let labels = ppd.map {
@@ -151,7 +214,8 @@ public struct CupsService: Sendable {
return PrinterCapabilities( return PrinterCapabilities(
trays: trays, paperSizes: sizes, mediaTypes: media, trays: trays, paperSizes: sizes, mediaTypes: media,
qualityKey: qualityKey, qualities: qualities, trayKey: trayKey, qualityKey: qualityKey,
qualities: qualities, trayDefault: trayDefault,
qualityDefault: qualityDefault) qualityDefault: qualityDefault)
} }
@@ -0,0 +1,333 @@
import Foundation
/// Per-media quality validity (#214): which of the queue's quality
/// tokens the driver actually accepts for a given media type. The
/// answer is not in `lpoptions -l`, IPP, or standard PPD
/// `*UIConstraints` for the two driver families handled here —
///
/// - Epson InkjetPrinter2: `*EPIJUIConstraint: <cond>|<forbidden>`
/// lines in the machine bundle's
/// `…/Contents/Resources/PDEData.dat`. A rule forbids its RHS
/// choice while every LHS `*<key> <value>` term holds. LHS keys
/// other than the media key (`EPIJ_PSrc`, `EPIJ_FdSo`,
/// `EPIJ_Ink_`, …) are evaluated against the queue's `lpoptions`
/// defaults — reproducing the driver's PDE state when it opens.
/// - Canon BJPrinter: `*CNIJNameTblPath` + `*CNIJTableID` PPD keys
/// locate `cnb_<TableID>0.tbl`, a binary record DB whose 20-byte
/// `{u16 0x30, u16 1, u32 0, u16 family, u16 flag, u32 mediaID,
/// u16 0, u16 quality}` records enumerate the allowed
/// `CNIJPrintQuality` values per `CNIJMediaType` (`mediaID`
/// `| 0x10000` marks the borderless variant of the same media).
/// - Generic: standard PPD `*UIConstraints:`/`*Constraints:` pairs
/// (Gutenprint etc.) — first non-empty source wins.
///
/// Every parser is pure; file access is injected so tests need no
/// installed driver. Failure or absence yields an empty map — callers
/// treat that as "unconstrained" and keep the full quality list.
public enum MediaQualityConstraints {
/// media-type id → allowed quality ids. Sources tried in order
/// (PPD constraints → Epson `PDEData.dat` → Canon `cnb` table);
/// the first source producing any entries wins.
public static func resolve(
listings: [CupsOptionListing],
ppd: String?,
optionOverrides: [String: String] = [:],
readFile: (URL) -> Data? = { try? Data(contentsOf: $0) }
) -> [String: Set<String>] {
let optionKeys = Set(listings.map(\.key))
guard let mediaKey = CupsParsers.detectMediaTypeKey(
optionKeys: optionKeys),
let qualityKey = CupsParsers.detectQualityKey(
optionKeys: optionKeys),
let mediaListing = listings.first(where: {
$0.key == mediaKey }),
let qualityListing = listings.first(where: {
$0.key == qualityKey })
else { return [:] }
let mediaIDs = Set(mediaListing.choices)
let qualityIDs = Set(qualityListing.choices)
var defaults = Dictionary(
listings.compactMap { l in l.defaultChoice.map { (l.key, $0) } },
uniquingKeysWith: { first, _ in first })
for (key, value) in optionOverrides {
defaults[key] = value
}
guard let ppd else { return [:] }
let ppdMap = allowedMap(
forbidden: ppdUIConstraints(
ppd, mediaKey: mediaKey, qualityKey: qualityKey,
mediaIDs: mediaIDs, qualityIDs: qualityIDs),
mediaIDs: mediaIDs, qualityIDs: qualityIDs)
if !ppdMap.isEmpty { return ppdMap }
if let url = epijPDEDataPath(ppd: ppd),
let data = readFile(url),
let dat = String(data: data, encoding: .utf8)
?? String(data: data, encoding: .isoLatin1) {
let map = allowedMap(
forbidden: epijUIConstraints(
dat, mediaKey: mediaKey, qualityKey: qualityKey,
mediaIDs: mediaIDs, qualityIDs: qualityIDs,
defaults: defaults),
mediaIDs: mediaIDs, qualityIDs: qualityIDs)
if !map.isEmpty { return map }
}
if let url = cnijTablePath(ppd: ppd),
let data = readFile(url) {
let map = cnijMediaQualityTable(
data, mediaIDs: mediaIDs, qualityIDs: qualityIDs)
if !map.isEmpty { return map }
}
return [:]
}
// MARK: - PPD keyword lookup
/// `*<key>: <value>` or `*<key>: "<value>"` → the value.
/// Exact key match — `*CNIJTableIDFoo:` must not satisfy a lookup
/// for `CNIJTableID`.
public static func ppdKeyword(_ ppd: String, _ key: String) -> String? {
for raw in ppd.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix("*\(key)") else { continue }
let rest = line.dropFirst(key.count + 1)
guard rest.first == ":" else { continue }
var value = rest.dropFirst()
.trimmingCharacters(in: .whitespaces)
if value.count >= 2,
value.hasPrefix("\""), value.hasSuffix("\"") {
value = String(value.dropFirst().dropLast())
}
if !value.isEmpty { return value }
}
return nil
}
// MARK: - Generic PPD constraints
/// `*UIConstraints:`/`*Constraints:` lines pair two conflicting
/// option choices; the ones naming both the media key and the
/// quality key produce forbidden (media, quality) pairs.
/// `*cupsUIConstraints` resolver triples do not match the
/// `*…Constraints:` prefixes, so they are skipped naturally.
public static func ppdUIConstraints(
_ ppd: String,
mediaKey: String,
qualityKey: String,
mediaIDs: Set<String>,
qualityIDs: Set<String>
) -> [String: Set<String>] {
var forbidden: [String: Set<String>] = [:]
for raw in ppd.split(separator: "\n") {
var line = raw.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("*UIConstraints:") {
line = String(line.dropFirst("*UIConstraints:".count))
} else if line.hasPrefix("*Constraints:") {
line = String(line.dropFirst("*Constraints:".count))
} else {
continue
}
let terms = constraintTerms(line)
guard let media = terms[mediaKey], mediaIDs.contains(media),
let quality = terms[qualityKey],
qualityIDs.contains(quality)
else { continue }
forbidden[media, default: []].insert(quality)
}
return forbidden
}
// MARK: - Epson PDEData.dat
/// `EPIJDriverBasePath` + `EPIJMachineBundleName` → the machine
/// bundle's `Contents/Resources/PDEData.dat` (uniform across the
/// InkjetPrinter2 family).
public static func epijPDEDataPath(ppd: String) -> URL? {
guard let base = ppdKeyword(ppd, "EPIJDriverBasePath"),
let bundle = ppdKeyword(ppd, "EPIJMachineBundleName")
else { return nil }
return URL(fileURLWithPath: base)
.appendingPathComponent("Machine")
.appendingPathComponent(bundle)
.appendingPathComponent("Contents/Resources/PDEData.dat")
}
/// `*EPIJUIConstraint:` forbidden-pair rules → (media, quality)
/// pairs the driver greys out. A rule *fires* when its LHS
/// `*<mediaKey>` term equals the media (absent media term →
/// applies to every media) and every other LHS term's value
/// equals that key's `lpoptions` default. A term whose key the
/// queue does not advertise counts as satisfied — hiding a usable
/// quality is a soft restriction, while showing an invalid one
/// re-creates the reported print failure.
public static func epijUIConstraints(
_ dat: String,
mediaKey: String,
qualityKey: String,
mediaIDs: Set<String>,
qualityIDs: Set<String>,
defaults: [String: String]
) -> [String: Set<String>] {
var forbidden: [String: Set<String>] = [:]
for raw in dat.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix("*EPIJUIConstraint:"),
let bar = line.firstIndex(of: "|")
else { continue }
let rhs = constraintTerms(
String(line[line.index(after: bar)...]))
guard let quality = rhs[qualityKey],
qualityIDs.contains(quality)
else { continue }
let lhs = constraintTerms(
String(line[
line.index(
line.startIndex,
offsetBy: "*EPIJUIConstraint:".count)..<bar]))
// Non-media terms must hold at the queue's defaults;
// a key absent from `defaults` is satisfied (see above).
let fires = lhs.allSatisfy { key, value in
key == mediaKey || value.isEmpty
|| (defaults[key].map { $0 == value } ?? true)
}
guard fires else { continue }
if let media = lhs[mediaKey] {
guard mediaIDs.contains(media) else { continue }
forbidden[media, default: []].insert(quality)
} else {
for media in mediaIDs {
forbidden[media, default: []].insert(quality)
}
}
}
return forbidden
}
// MARK: - Canon cnb table
/// `CNIJNameTblPath` + `CNIJTableID` → `cnb_<TableID>0.tbl`.
public static func cnijTablePath(ppd: String) -> URL? {
guard let dir = ppdKeyword(ppd, "CNIJNameTblPath"),
let tableID = ppdKeyword(ppd, "CNIJTableID")
else { return nil }
return URL(fileURLWithPath: dir)
.appendingPathComponent("cnb_\(tableID)0.tbl")
}
/// Scans the Canon table for 20-byte LE records anchored on
/// `30 00 01 00 00 00 00 00`: `{u16 family, u16 flag,
/// u32 mediaID, u16 pad, u16 quality}` follows the anchor.
/// A row counts only when `pad == 0`, `mediaID & 0xFFFF` is a
/// listed media id, and `quality` is a listed quality id — so
/// unrelated tables cannot inject false entries. Records carry a
/// `family` field (0x03 on the Pro9500 II); the modal family is
/// used so sibling record layouts in the same file are ignored.
/// Returns allowed (not forbidden) sets directly.
public static func cnijMediaQualityTable(
_ data: Data,
mediaIDs: Set<String>,
qualityIDs: Set<String>
) -> [String: Set<String>] {
let mediaNums = Set(mediaIDs.compactMap { UInt32($0) })
let qualityNums = Set(qualityIDs.compactMap { UInt16($0) })
guard !mediaNums.isEmpty, !qualityNums.isEmpty else {
return [:]
}
let anchor: [UInt8] = [0x30, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00]
var rows: [UInt16: [(media: UInt32, quality: UInt16)]] = [:]
data.withUnsafeBytes { buffer in
guard let base = buffer.baseAddress else { return }
var i = 0
while i + 20 <= buffer.count {
if memcmp(base + i, anchor, anchor.count) == 0 {
let family = readU16(base, i + 8)
let media = readU32(base, i + 12)
let pad = readU16(base, i + 16)
let quality = readU16(base, i + 18)
let baseMedia = media & 0xFFFF
if pad == 0,
mediaNums.contains(baseMedia),
qualityNums.contains(quality) {
rows[family, default: []].append(
(baseMedia, quality))
}
}
i += 2
}
}
guard let dominant = rows.max(by: { $0.value.count < $1.value.count })
else { return [:] }
var map: [String: Set<String>] = [:]
for row in dominant.value {
map[String(row.media), default: []].insert(String(row.quality))
}
// Keep strict subsets only — a media with the full set (or an
// empty one) is unconstrained as far as the UI is concerned.
map = map.filter { _, ids in
!ids.isEmpty && ids != qualityIDs
}
return map
}
// MARK: - Shared
/// `*<key> <value>` term extraction for both PPD and Epson
/// constraint syntax. A `*<key>` not followed by a value is a
/// wildcard term (empty-string value → always satisfied).
private static func constraintTerms(_ text: String) -> [String: String] {
var terms: [String: String] = [:]
let tokens = text.split(separator: " ").map(String.init)
var index = 0
while index < tokens.count {
guard tokens[index].hasPrefix("*") else {
index += 1
continue
}
let key = String(tokens[index].dropFirst())
if index + 1 < tokens.count,
!tokens[index + 1].hasPrefix("*") {
terms[key] = tokens[index + 1]
index += 2
} else {
terms[key] = ""
index += 1
}
}
return terms
}
/// forbidden (media → quality ids) → allowed map entries,
/// emitting only strict non-empty subsets: a media whose allowed
/// set is empty or equals the full roster is unconstrained as far
/// as the picker is concerned.
private static func allowedMap(
forbidden: [String: Set<String>],
mediaIDs: Set<String>,
qualityIDs: Set<String>
) -> [String: Set<String>] {
var map: [String: Set<String>] = [:]
for media in mediaIDs {
let allowed = qualityIDs.subtracting(forbidden[media] ?? [])
if !allowed.isEmpty, allowed != qualityIDs {
map[media] = allowed
}
}
return map
}
private static func readU16(_ base: UnsafeRawPointer, _ offset: Int) -> UInt16 {
UInt16(base.load(fromByteOffset: offset, as: UInt8.self))
| UInt16(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8
}
private static func readU32(_ base: UnsafeRawPointer, _ offset: Int) -> UInt32 {
UInt32(base.load(fromByteOffset: offset, as: UInt8.self))
| UInt32(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8
| UInt32(base.load(fromByteOffset: offset + 2, as: UInt8.self)) << 16
| UInt32(base.load(fromByteOffset: offset + 3, as: UInt8.self)) << 24
}
}
@@ -17,28 +17,48 @@ public struct Printer: Codable, Equatable, Sendable {
public var status: PrinterStatus public var status: PrinterStatus
public var isDefault: Bool public var isDefault: Bool
public var displayName: String? public var displayName: String?
/// AirPrint queue (#202, docs/14 §10.2) — the URF pipeline is always
/// colour-managed, so Stage 2 shows a persistent warning badge.
public var isAirPrint: Bool = false
public init( public init(
name: String, name: String,
status: PrinterStatus = .unknown, status: PrinterStatus = .unknown,
isDefault: Bool = false, isDefault: Bool = false,
displayName: String? = nil displayName: String? = nil,
isAirPrint: Bool = false
) { ) {
self.name = name self.name = name
self.status = status self.status = status
self.isDefault = isDefault self.isDefault = isDefault
self.displayName = displayName self.displayName = displayName
self.isAirPrint = isAirPrint
}
/// `isAirPrint` predates #202 payloads — a missing key decodes as
/// `false` instead of failing the whole decode.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
status = try c.decode(PrinterStatus.self, forKey: .status)
isDefault = try c.decode(Bool.self, forKey: .isDefault)
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
isAirPrint = try c.decodeIfPresent(Bool.self, forKey: .isAirPrint) ?? false
} }
} }
/// Paper source. `id` is the 1-based index of the `InputSlot` / /// Paper source. `id` is the 1-based index of the `InputSlot` /
/// `MediaSource` choice (not a PPD code) — docs/10. /// `MediaSource` / vendor choice (not a PPD code); `token` is the
/// exact CUPS/PPD machine value written to the ticket; `name` is the
/// PPD-enriched display label (#218).
public struct PrinterTray: Codable, Equatable, Sendable { public struct PrinterTray: Codable, Equatable, Sendable {
public var id: Int public var id: Int
public var token: String
public var name: String public var name: String
public init(id: Int, name: String) { public init(id: Int, token: String, name: String) {
self.id = id self.id = id
self.token = token
self.name = name self.name = name
} }
} }
@@ -85,29 +105,101 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
/// Always `true` on macOS (spec parity — CUPS honours /// Always `true` on macOS (spec parity — CUPS honours
/// `orientation-requested`). /// `orientation-requested`).
public var supportsOrientation: Bool public var supportsOrientation: Bool
/// The queue's detected tray enumeration key
/// (`CupsParsers.detectTrayKey`), e.g. `EPIJ_FdSo` (#218).
public var trayKey: String?
/// The queue's detected quality enumeration key /// The queue's detected quality enumeration key
/// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183). /// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183).
public var qualityKey: String? public var qualityKey: String?
public var qualities: [PrinterQuality] public var qualities: [PrinterQuality]
/// The `*`-marked default tray token from `lpoptions -l`, if any
/// (#218).
public var trayDefault: String?
/// The `*`-marked default choice from `lpoptions -l`, if any. /// The `*`-marked default choice from `lpoptions -l`, if any.
public var qualityDefault: String? public var qualityDefault: String?
/// Media-type id → the quality ids the driver accepts for it
/// (#214 — `MediaQualityConstraints`). Empty when the driver
/// exposes no per-media quality matrix; a missing entry means
/// "unconstrained" — show everything.
public var qualityIDsByMediaType: [String: Set<String>]
/// Tray token → media-type id → allowed quality ids. Computed
/// once at capability load; a missing tray falls back to
/// `qualityIDsByMediaType` (#218).
public var qualityIDsByMediaTypeByTray: [String: [String: Set<String>]]
public init( public init(
trays: [PrinterTray] = [], trays: [PrinterTray] = [],
paperSizes: [PrinterPaperSize] = [], paperSizes: [PrinterPaperSize] = [],
mediaTypes: [PrinterMediaType] = [], mediaTypes: [PrinterMediaType] = [],
supportsOrientation: Bool = true, supportsOrientation: Bool = true,
trayKey: String? = nil,
qualityKey: String? = nil, qualityKey: String? = nil,
qualities: [PrinterQuality] = [], qualities: [PrinterQuality] = [],
qualityDefault: String? = nil trayDefault: String? = nil,
qualityDefault: String? = nil,
qualityIDsByMediaType: [String: Set<String>] = [:],
qualityIDsByMediaTypeByTray: [String: [String: Set<String>]] = [:]
) { ) {
self.trays = trays self.trays = trays
self.paperSizes = paperSizes self.paperSizes = paperSizes
self.mediaTypes = mediaTypes self.mediaTypes = mediaTypes
self.supportsOrientation = supportsOrientation self.supportsOrientation = supportsOrientation
self.trayKey = trayKey
self.qualityKey = qualityKey self.qualityKey = qualityKey
self.qualities = qualities self.qualities = qualities
self.trayDefault = trayDefault
self.qualityDefault = qualityDefault self.qualityDefault = qualityDefault
self.qualityIDsByMediaType = qualityIDsByMediaType
self.qualityIDsByMediaTypeByTray = qualityIDsByMediaTypeByTray
}
/// The quality map for the requested tray token, if any.
/// Prefers the tray-specific map; otherwise falls back to the
/// queue-default map. An explicitly empty tray map means the tray
/// is unconstrained, so `nil` is returned only when neither map
/// exists.
private func qualityMap(
forTrayToken trayToken: String?
) -> [String: Set<String>]? {
if let trayToken,
let map = qualityIDsByMediaTypeByTray[trayToken],
!map.isEmpty {
return map
}
return qualityIDsByMediaType.isEmpty ? nil : qualityIDsByMediaType
}
/// Qualities valid for `mediaID` and an optional tray token,
/// preserving the driver's listing order (#180/#218). Falls back
/// to the full list when the driver has no per-media data, the
/// media is absent from the map, or the filter would empty the
/// picker (#214).
public func qualities(
forMediaType mediaID: String?,
trayToken: String? = nil
) -> [PrinterQuality] {
guard let mediaID,
let allowed = qualityMap(forTrayToken: trayToken)?[mediaID],
!allowed.isEmpty
else { return qualities }
let filtered = qualities.filter { allowed.contains($0.id) }
return filtered.isEmpty ? qualities : filtered
}
/// Whether `qualityID` is usable with `mediaID` and an optional
/// tray token (#218). A quality absent from `qualities` entirely
/// is still allowed: a driver-captured token can be legitimate
/// even when `lpoptions` never listed it.
public func allowsQuality(
_ qualityID: String,
forMediaType mediaID: String?,
trayToken: String? = nil
) -> Bool {
guard let mediaID,
let allowed = qualityMap(forTrayToken: trayToken)?[mediaID],
!allowed.isEmpty
else { return true }
return allowed.contains(qualityID)
} }
} }
@@ -128,7 +220,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
public var ppdUncorrectedPassthrough: Bool? public var ppdUncorrectedPassthrough: Bool?
/// Space-separated `key=value` captured from /// Space-separated `key=value` captured from
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) — /// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) —
/// the Stage 2 mirror only, never a spool payload. /// the Stage 2 mirror only, never an `lp` payload.
public var cupsOptions: String? public var cupsOptions: String?
public init( 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 | | Product | ICCery v2 for macOS |
| Bundle | `com.gronod.iccery2` | | Bundle | `com.gronod.iccery2` |
| Version | 2.0.0 | | Version | 2.0.3 |
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` | | Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) | | Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) |
| CI | Gitea Actions `macos-12` runner | | CI | Gitea Actions `macos-12` runner |
@@ -207,15 +207,16 @@ not in this tree.
leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate. leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate.
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`, - **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`,
`ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`, `ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`,
`ProfcheckArgs`, `LpArgs`, `SpotReadArgs`, …). UI must not concatenate flags. `ProfcheckArgs`, `SpotReadArgs`, …). UI must not concatenate flags.
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal` - **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal`
must not replace the input profile on cancel or non-zero exit. must not replace the input profile on cancel or non-zero exit.
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main - **Concurrency.** View models are `@MainActor`. No blocking I/O on the main
actor. Swift 5.7 / macOS 12: `ObservableObject`, not Observation actor. Swift 5.7 / macOS 12: `ObservableObject`, not Observation
`@Observable`. `@Observable`.
- **Print.** Unmanaged `lp` with ColorSync suppression - **Print.** Unmanaged headless `NSPrintOperation` (#201 — no `lp`):
(`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel` restore the captured `PrintTicket`, write both ColorSync vocabularies
options win over derived CUPS keys. Never `lp -o raw`. (locked `AP_*` + Quartz `PMColorMatchingMode`), Stage 2 selections always
win over the ticket, draw 1:1 with interpolation off.
- **SwiftUI ViewBuilder.** Xcode 14.2 / Swift 5.7 still has the ten-child - **SwiftUI ViewBuilder.** Xcode 14.2 / Swift 5.7 still has the ten-child
limit. Split large `VStack`/`Group` trees (#146). limit. Split large `VStack`/`Group` trees (#146).
+20 -2
View File
@@ -24,6 +24,7 @@ struct AppEnvironment: Sendable {
.map { URL(fileURLWithPath: $0) } .map { URL(fileURLWithPath: $0) }
var bundledRoot = AppPaths.bundledArgyllDir var bundledRoot = AppPaths.bundledArgyllDir
var cupsDir = URL(fileURLWithPath: "/usr/bin") var cupsDir = URL(fileURLWithPath: "/usr/bin")
var ppdDir = URL(fileURLWithPath: "/etc/cups/ppd")
#if DEBUG #if DEBUG
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty { if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
overrideDir = URL(fileURLWithPath: dir) overrideDir = URL(fileURLWithPath: dir)
@@ -34,6 +35,11 @@ struct AppEnvironment: Sendable {
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty { if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
cupsDir = URL(fileURLWithPath: dir) cupsDir = URL(fileURLWithPath: dir)
} }
// #214 — fixture PPD dir lets UI tests exercise the media→
// quality constraint resolver without installed drivers.
if let dir = environment["ICCERY_CUPS_PPD_DIR"], !dir.isEmpty {
ppdDir = URL(fileURLWithPath: dir)
}
#endif #endif
return AppEnvironment( return AppEnvironment(
stateStore: WizardStateStore(), stateStore: WizardStateStore(),
@@ -46,7 +52,7 @@ struct AppEnvironment: Sendable {
), ),
cupsService: CupsService( cupsService: CupsService(
processManager: .shared, processManager: .shared,
binaryDir: cupsDir), binaryDir: cupsDir, ppdDir: ppdDir),
historyStore: VerificationHistoryStore(), historyStore: VerificationHistoryStore(),
mediaStore: MediaLibraryStore(), mediaStore: MediaLibraryStore(),
recentProjectsStore: RecentProjectsStore() recentProjectsStore: RecentProjectsStore()
@@ -127,17 +133,29 @@ enum UITestHooks {
/// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and /// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and
/// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the /// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the
/// queue the panel was opened for). /// queue the panel was opened for).
static func printPanelResult(forQueue queue: String) -> PrintPropertiesResult? { static func printPanelResult(
forQueue queue: String,
trays: [PrinterTray] = [],
preferredTrayKey: String? = nil
) -> PrintPropertiesResult? {
switch env["ICCERY_TEST_PRINT_PANEL"] { switch env["ICCERY_TEST_PRINT_PANEL"] {
case "ok": case "ok":
let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap { let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap {
$0.isEmpty ? nil : $0 $0.isEmpty ? nil : $0
} }
let trayToken = options.flatMap {
CupsParsers.extractTray(
fromOptionsString: $0, preferredKey: preferredTrayKey)
}
let paperSource = trays.first(where: {
$0.token == trayToken
}).map(\.id)
return PrintPropertiesResult( return PrintPropertiesResult(
selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap { selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap {
$0.isEmpty ? nil : $0 $0.isEmpty ? nil : $0
} ?? queue, } ?? queue,
options: PrintOptions( options: PrintOptions(
paperSource: paperSource,
orientation: options.flatMap { orientation: options.flatMap {
CupsParsers.extractOrientation(fromOptionsString: $0) CupsParsers.extractOrientation(fromOptionsString: $0)
}, },
+30 -15
View File
@@ -181,29 +181,44 @@ struct ColorSyncSuppressor {
// MARK: - Layer ⑥ capture // MARK: - Layer ⑥ capture
/// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType)`. /// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType,
/// The malloc'd C string is freed after copying. /// tray)`. The malloc'd C string is freed after copying. `trayKey`
/// is the queue's detected tray enumeration key; if nil, the full
/// roster plus the capture-only `AP_D_InputSlot` alias is tried.
func captureOptions( func captureOptions(
from settings: PMPrintSettings from settings: PMPrintSettings,
) -> (cupsOptions: String?, mediaType: String?) { trayKey: String?
guard let toOptions = optionsResolver() else { ) -> (cupsOptions: String?, mediaType: String?, tray: String?) {
log("ColorSync: PMPrintSettingsToOptions unavailable — " guard let unfiltered = rawOptionsDump(from: settings) else {
+ "panel options not captured") return (nil, nil, nil)
return (nil, nil)
} }
var raw: UnsafeMutablePointer<CChar>?
guard toOptions(settings, &raw) == 0, let raw else {
return (nil, nil)
}
defer { free(raw) }
let unfiltered = String(cString: raw)
let filtered = CupsOptionsFilter.filter(unfiltered) let filtered = CupsOptionsFilter.filter(unfiltered)
return ( return (
filtered.isEmpty ? nil : filtered, filtered.isEmpty ? nil : filtered,
CupsParsers.extractMediaType(fromOptionsString: unfiltered) CupsParsers.extractMediaType(fromOptionsString: unfiltered),
CupsParsers.extractTray(
fromOptionsString: unfiltered,
preferredKey: trayKey)
) )
} }
/// Unfiltered `PMPrintSettingsToOptions` output — the diagnostics
/// half of the capture path (#217 `ICCERY_DEBUG_PANEL_DUMP`).
/// `nil` when the symbol or the call fails.
func rawOptionsDump(from settings: PMPrintSettings) -> String? {
guard let toOptions = optionsResolver() else {
log("ColorSync: PMPrintSettingsToOptions unavailable — "
+ "panel options not captured")
return nil
}
var raw: UnsafeMutablePointer<CChar>?
guard toOptions(settings, &raw) == 0, let raw else {
return nil
}
defer { free(raw) }
return String(cString: raw)
}
// MARK: - dlsym // MARK: - dlsym
private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? { private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? {
+189 -60
View File
@@ -21,6 +21,10 @@ struct PrintPanelInitialSelections {
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written /// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written
/// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1). /// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1).
var paperSize: String? var paperSize: String?
/// The queue's detected tray enumeration key, e.g. `EPIJ_FdSo`.
var trayKey: String?
/// The selected tray machine token.
var tray: String?
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`. /// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
var qualityKey: String? var qualityKey: String?
/// The selected quality token. /// The selected quality token.
@@ -32,6 +36,23 @@ struct PrintPanelInitialSelections {
var orientation: String? var orientation: String?
} }
/// One bound-panel invocation (#217): the queue, its `lpoptions -l`
/// key roster, the Stage 2 pre-selections, and the session's captured
/// ticket — everything `makePanelPrintInfo` needs before the modal.
struct PrintPanelRequest {
let queue: String
let displayName: String?
let optionKeys: Set<String>
let initialSelections: PrintPanelInitialSelections
/// The queue's previously captured ticket — rehydrated before the
/// Stage 2 writes so vendor PDE state survives a reopen (#217).
let ticket: PrintTicket?
/// Roster of paper-source choices used to map a captured option
/// token back to a 1-based ID. Empty for queues with no tray key
/// (#218).
let trays: [PrinterTray]
}
/// Preferences → native `NSPrintPanel` bound to the selected CUPS /// Preferences → native `NSPrintPanel` bound to the selected CUPS
/// queue (issue 13, docs/11). /// queue (issue 13, docs/11).
/// ///
@@ -58,11 +79,16 @@ struct PrintPanelService {
displayName: String?, displayName: String?,
cupsService: CupsService, cupsService: CupsService,
initialSelections: PrintPanelInitialSelections = initialSelections: PrintPanelInitialSelections =
PrintPanelInitialSelections() PrintPanelInitialSelections(),
ticket: PrintTicket? = nil,
trays: [PrinterTray] = []
) async throws -> PanelCaptureResult? { ) async throws -> PanelCaptureResult? {
#if DEBUG #if DEBUG
if UITestHooks.printPanelStubbed { if UITestHooks.printPanelStubbed {
return UITestHooks.printPanelResult(forQueue: queue).map { return UITestHooks.printPanelResult(
forQueue: queue, trays: trays,
preferredTrayKey: initialSelections.trayKey
).map {
PanelCaptureResult(properties: $0, ticket: nil) PanelCaptureResult(properties: $0, ticket: nil)
} }
} }
@@ -75,83 +101,140 @@ struct PrintPanelService {
let optionKeys = (try? await cupsService.optionKeys(for: queue)) let optionKeys = (try? await cupsService.optionKeys(for: queue))
?? [] ?? []
return try runNativePanel( return try runNativePanel(
queue: queue, displayName: display, optionKeys: optionKeys, PrintPanelRequest(
initialSelections: initialSelections) queue: queue, displayName: display,
optionKeys: optionKeys,
initialSelections: initialSelections, ticket: ticket,
trays: trays))
} }
// MARK: - Panel // MARK: - Panel
private func runNativePanel( /// The pre-modal half of `runNativePanel` — a fully configured
queue: String, /// `NSPrintInfo` plus the `boundViaPM` flag. Extracted (same
displayName: String?, /// pattern as `NativeTargetSpooler.makePrintInfo`) so unit tests
optionKeys: Set<String>, /// assert ticket restore / Stage 2 precedence without driving the
initialSelections: PrintPanelInitialSelections /// modal (#217). The caller owns `printer` — it must stay alive
) throws -> PanelCaptureResult? { /// through the modal.
func makePanelPrintInfo(
for request: PrintPanelRequest,
boundTo printer: PMPrinter?
) throws -> (printInfo: NSPrintInfo, boundViaPM: Bool) {
let printInfo = NSPrintInfo() let printInfo = NSPrintInfo()
var pmPrinter: PMPrinter?
var boundViaPM = false
// ① Bind the session to the selected CUPS queue (docs/11). // ① Bind the session to the selected CUPS queue (docs/11).
if let printer = PMTicketBridge.makePrinter(queue: queue) { if let printer {
pmPrinter = printer
let session = PMTicketBridge.session(printInfo)
let settings = PMTicketBridge.settings(printInfo)
do {
try PMTicketBridge.bind(printer: printer, to: printInfo) try PMTicketBridge.bind(printer: printer, to: printInfo)
} catch { configureBoundPanel(
PMTicketBridge.release(printer) printInfo, request: request, printer: printer)
throw error
}
// Initial selections — after `PMSessionDefault*`, before
// ColorSync suppression ②–⑤ (locked write order,
// #183/#186). Paper is TWO writes (E1): the `PageSize`
// print-settings value drivers/capture read AND the
// `PMPageFormat` paper the panel's dropdown reflects.
applyInitialSelections(
initialSelections, to: settings, optionKeys: optionKeys)
if let paperToken = initialSelections.paperSize {
PMTicketBridge.applyPaper(
token: paperToken, printer: printer,
session: session, printInfo: printInfo)
}
boundViaPM = true
} else { } else {
// Fallback: NSPrinter by display name (docs/11 §binding). // Fallback: NSPrinter by display name (docs/11 §binding).
// Warn — the display name can resolve a *different* queue // Warn — the display name can resolve a *different* queue
// (#186 E2: diagnosable, not a proven defect). // (#186 E2: diagnosable, not a proven defect).
AppLogger.shared.warn( AppLogger.shared.warn(
"Print panel: PM binding unavailable for '\(queue)' — " "Print panel: PM binding unavailable for "
+ "'\(request.queue)' — "
+ "falling back to NSPrinter(displayName)") + "falling back to NSPrinter(displayName)")
guard let displayName, guard let displayName = request.displayName,
let nsPrinter = NSPrinter(name: displayName) let nsPrinter = NSPrinter(name: displayName)
else { else {
throw PrintPanelError.noPrinterFound( throw PrintPanelError.noPrinterFound(
displayName ?? queue) request.displayName ?? request.queue)
} }
printInfo.printer = nsPrinter printInfo.printer = nsPrinter
printInfo.setUpPrintOperationDefaultValues() printInfo.setUpPrintOperationDefaultValues()
return (printInfo, false)
} }
return (printInfo, true)
}
/// Post-bind configuration in locked order (#217 — mirrors the
/// spooler's S4→S7): ticket restore → Stage 2 selections →
/// validate → paper → suppression ②–⑤. Internal so unit tests can
/// drive it with `printer: nil` — `applyPaper` and the SPI layer
/// need a bound printer and skip when it is absent, while ticket
/// restore / selections / validate still run on the session-less
/// `NSPrintInfo`.
func configureBoundPanel(
_ printInfo: NSPrintInfo,
request: PrintPanelRequest,
printer: PMPrinter?
) {
let session = PMTicketBridge.session(printInfo)
let settings = PMTicketBridge.settings(printInfo)
// Rehydrate the captured ticket *before* the Stage 2 writes
// (#217 — restore-then-override). Warn-only, unlike the
// spooler's fatal `try`: a stale/corrupt ticket must not keep
// the dialog from opening. `restore` itself refuses a
// cross-queue replay (R3).
if let ticket = request.ticket {
do {
try PMTicketBridge.restore(ticket, into: printInfo)
} catch {
AppLogger.shared.warn(
"Print panel: ticket restore failed — "
+ "opening on queue defaults "
+ "(\(error.localizedDescription))")
}
}
// Initial selections — Stage 2 wins over the restored ticket
// (D6 parity). Paper is TWO writes (E1): the `PageSize`
// print-settings value drivers/capture read AND the
// `PMPageFormat` paper the panel's dropdown reflects.
applyInitialSelections(
request.initialSelections, to: settings,
optionKeys: request.optionKeys)
// Reconcile the pre-writes into the ticket before the modal
// (#217 RC2): the vendor PDE derives pane state from a
// validated ticket. Warn-only — log when the driver adjusts
// so a companion-key desync is diagnosable.
var changed = DarwinBoolean(false)
_ = PMSessionValidatePrintSettings(session, settings, &changed)
if changed.boolValue {
AppLogger.shared.info(
"Print panel: driver adjusted the pre-selections")
}
printInfo.updateFromPMPrintSettings()
if let printer, let paperToken = request.initialSelections.paperSize {
PMTicketBridge.applyPaper(
token: paperToken, printer: printer,
session: session, printInfo: printInfo)
}
// ②–⑤ ColourSync suppression. The SPI needs a session with a
// current printer to attach to; the SetValue/mirror layers
// are warn-only and run regardless.
if printer != nil {
suppressor.applySPIMode(to: session) // ②
}
suppressor.applyLockedKeys(to: settings) // ③
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
let driverBypass = suppressor.applyDriverBypass( // ④
to: settings, optionKeys: request.optionKeys)
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
}
private func runNativePanel(
_ request: PrintPanelRequest
) throws -> PanelCaptureResult? {
// The PM printer handle is held through the modal and
// released on every path.
let pmPrinter = PMTicketBridge.makePrinter(queue: request.queue)
defer { defer {
if let printer = pmPrinter { if let printer = pmPrinter {
PMTicketBridge.release(printer) PMTicketBridge.release(printer)
} }
} }
// ②–⑤ ColourSync suppression — only on the PM path: the SPI let (printInfo, boundViaPM) = try makePanelPrintInfo(
// and PMPrintSettingsSetValue need a session with a current for: request, boundTo: pmPrinter)
// printer to attach to.
var settings = PMTicketBridge.settings(printInfo) debugDump(printInfo, when: "pre-modal")
var driverBypass: (key: String, value: String)?
if boundViaPM {
let session = PMTicketBridge.session(printInfo)
suppressor.applySPIMode(to: session) // ②
suppressor.applyLockedKeys(to: settings) // ③
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
driverBypass = suppressor.applyDriverBypass( // ④
to: settings, optionKeys: optionKeys)
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
}
let panel = NSPrintPanel() let panel = NSPrintPanel()
panel.options = [ panel.options = [
@@ -173,27 +256,35 @@ struct PrintPanelService {
// (#183); the PDE may rewrite or drop them (R12). // (#183); the PDE may rewrite or drop them (R12).
var cupsOptions: String? var cupsOptions: String?
var mediaType: String? var mediaType: String?
var trayToken: String?
if boundViaPM { if boundViaPM {
settings = PMTicketBridge.settings(printInfo) debugDump(printInfo, when: "post-OK")
let captured = suppressor.captureOptions(from: settings) let captured = suppressor.captureOptions(
from: PMTicketBridge.settings(printInfo),
trayKey: request.initialSelections.trayKey)
cupsOptions = captured.cupsOptions cupsOptions = captured.cupsOptions
mediaType = captured.mediaType mediaType = captured.mediaType
trayToken = captured.tray
} }
let capturedOptions = cupsOptions ?? "" let capturedOptions = cupsOptions ?? ""
let resolvedQueue = boundViaPM let resolvedQueue = boundViaPM
? PMTicketBridge.currentPrinterID( ? PMTicketBridge.currentPrinterID(
session: PMTicketBridge.session(printInfo), session: PMTicketBridge.session(printInfo),
fallback: queue) fallback: request.queue)
: queue : request.queue
// ⑦ Serialise the native ticket — the payload `lp -o` could // ⑦ Serialise the native ticket — the payload `lp -o` could
// never carry (#201). Warn-only via `try?`: a serialise // never carry (#201). Warn-only via `try?`: a serialise
// failure must not lose the Stage 2 mirror above. // failure must not lose the Stage 2 mirror above.
let ticket = try? PMTicketBridge.serialise( let ticket = try? PMTicketBridge.serialise(
printInfo, queue: resolvedQueue) printInfo, queue: resolvedQueue)
let paperSource = request.trays.first(where: {
$0.token == trayToken
}).map(\.id)
return PanelCaptureResult( return PanelCaptureResult(
properties: PrintPropertiesResult( properties: PrintPropertiesResult(
selectedPrinter: boundViaPM ? resolvedQueue : nil, selectedPrinter: boundViaPM ? resolvedQueue : nil,
options: PrintOptions( options: PrintOptions(
paperSource: paperSource,
orientation: CupsParsers.extractOrientation( orientation: CupsParsers.extractOrientation(
fromOptionsString: capturedOptions), fromOptionsString: capturedOptions),
paperSize: CupsParsers.extractOption( paperSize: CupsParsers.extractOption(
@@ -207,10 +298,10 @@ struct PrintPanelService {
ticket: ticket) ticket: ticket)
} }
/// Initial-selection `PMPrintSettings` writes — paper, quality, /// Initial-selection `PMPrintSettings` writes — paper, tray,
/// media type, orientation. All warn-only: a driver that ignores /// quality, media type, orientation. All warn-only: a driver that
/// a key must not keep the panel from opening (R12 surfaces via /// ignores a key must not keep the panel from opening (R12 surfaces
/// the capture echo instead). /// via the capture echo instead).
private func applyInitialSelections( private func applyInitialSelections(
_ selections: PrintPanelInitialSelections, _ selections: PrintPanelInitialSelections,
to settings: PMPrintSettings, to settings: PMPrintSettings,
@@ -221,6 +312,13 @@ struct PrintPanelService {
paperSize, forKey: "PageSize", locked: false, paperSize, forKey: "PageSize", locked: false,
in: settings, context: "Print panel") in: settings, context: "Print panel")
} }
// Paper source via the queue's detected tray roster key (#218).
if let tray = selections.tray, !tray.isEmpty,
let trayKey = selections.trayKey {
PMTicketBridge.setValue(
tray, forKey: trayKey, locked: false,
in: settings, context: "Print panel")
}
if let key = selections.qualityKey, let value = selections.quality { if let key = selections.qualityKey, let value = selections.quality {
PMTicketBridge.setValue( PMTicketBridge.setValue(
value, forKey: key, locked: false, value, forKey: key, locked: false,
@@ -243,4 +341,35 @@ struct PrintPanelService {
} }
} }
/// `ICCERY_DEBUG_PANEL_DUMP=1` — log the unfiltered
/// `PMPrintSettingsToOptions` output and the serialised
/// settings/page-format byte counts (#217 diagnostics for the
/// Epson RC3 question: does the PDE source pane state from the
/// session ticket or lpoptions?). DEBUG-only, warn-only.
private func debugDump(_ printInfo: NSPrintInfo, when phase: String) {
#if DEBUG
guard ProcessInfo.processInfo.environment[
"ICCERY_DEBUG_PANEL_DUMP"] == "1" else { return }
let raw = suppressor.rawOptionsDump(
from: PMTicketBridge.settings(printInfo)) ?? "<unavailable>"
var settingsSize = -1
var ref: Unmanaged<CFData>?
if PMPrintSettingsCreateDataRepresentation(
PMTicketBridge.settings(printInfo), &ref,
kPMDataFormatXMLDefault) == noErr, let ref {
settingsSize = (ref.takeRetainedValue() as Data).count
}
var formatSize = -1
var formatRef: Unmanaged<CFData>?
if PMPageFormatCreateDataRepresentation(
PMTicketBridge.pageFormat(printInfo), &formatRef,
kPMDataFormatXMLDefault) == noErr, let formatRef {
formatSize = (formatRef.takeRetainedValue() as Data).count
}
AppLogger.shared.info(
"Print panel dump (\(phase)): settings=\(settingsSize)B "
+ "pageFormat=\(formatSize)B options: \(raw)")
#endif
}
} }
@@ -24,8 +24,24 @@ final class PrintSessionViewModel: ObservableObject {
} }
} }
@Published var printerCaps = PrinterCapabilities() @Published var printerCaps = PrinterCapabilities()
@Published var selectedTray: Int? @Published var selectedTray: Int? {
@Published var selectedMediaType: String? didSet {
// A tray switch can invalidate the current quality pick
// using the tray-specific Epson constraint map (#218).
if selectedTray != oldValue {
clampQualityToMedia()
}
}
}
@Published var selectedMediaType: String? {
didSet {
// A media switch can invalidate the current quality pick —
// re-clamp into the driver's allowed set (#214).
if selectedMediaType != oldValue {
clampQualityToMedia()
}
}
}
/// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183). /// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183).
@Published var selectedPaperSize: Int? @Published var selectedPaperSize: Int?
/// Print-quality option token, e.g. `"303"` (#183). /// Print-quality option token, e.g. `"303"` (#183).
@@ -120,12 +136,22 @@ final class PrintSessionViewModel: ObservableObject {
if selectedMediaType == nil { if selectedMediaType == nil {
selectedMediaType = printerCaps.mediaTypes.first?.id selectedMediaType = printerCaps.mediaTypes.first?.id
} }
if selectedTray == nil { if selectedTray == nil || !printerCaps.trays
selectedTray = printerCaps.trays.first?.id .contains(where: { $0.id == selectedTray }) {
selectedTray = printerCaps.trayDefault.flatMap { token in
printerCaps.trays.first { $0.token == token }?.id
} ?? printerCaps.trays.first?.id
} }
// Quality seeds inside the selected media's allowed set
// (#214): driver default when valid there, else the first
// allowed choice.
if selectedQuality == nil { if selectedQuality == nil {
selectedQuality = printerCaps.qualityDefault let allowed = availableQualities
?? printerCaps.qualities.first?.id selectedQuality = printerCaps.qualityDefault.flatMap { d in
allowed.contains(where: { $0.id == d }) ? d : nil
} ?? allowed.first?.id
} else {
clampQualityToMedia()
} }
// Caps reload is a re-mirror trigger for the paper picker // Caps reload is a re-mirror trigger for the paper picker
// (#183 E4) — pageSize + printer changes route here too. // (#183 E4) — pageSize + printer changes route here too.
@@ -137,6 +163,50 @@ final class PrintSessionViewModel: ObservableObject {
// MARK: - Paper / quality selection (#183) // MARK: - Paper / quality selection (#183)
/// The raw tray token for the currently selected source (#218).
var selectedTrayToken: String? {
guard let id = selectedTray else { return nil }
return printerCaps.trays.first { $0.id == id }?.token
}
/// The quality picker's source (#214/#218): `printerCaps.qualities`
/// filtered to what the driver accepts for `selectedMediaType` and
/// the current tray token. Equals the full list whenever the queue
/// exposes no per-media/per-tray quality matrix — and never empty
/// while `qualities` is not.
var availableQualities: [PrinterQuality] {
printerCaps.qualities(
forMediaType: selectedMediaType,
trayToken: selectedTrayToken)
}
/// Keep `selectedQuality` inside the allowed set for the current
/// media and tray: keep the pick when still valid, else the driver
/// default when valid, else the first allowed choice (#214/#218).
private func clampQualityToMedia() {
let allowed = availableQualities
guard !allowed.isEmpty else { return }
if let quality = selectedQuality,
allowed.contains(where: { $0.id == quality }) {
return
}
if let fallback = printerCaps.qualityDefault,
allowed.contains(where: { $0.id == fallback }) {
selectedQuality = fallback
} else {
selectedQuality = allowed.first?.id
}
}
/// The tray token for a captured option string, used both for
/// applying panel results and for synthesising unknown selections.
private func capturedTrayToken(from options: String?) -> String? {
guard let options else { return nil }
return CupsParsers.extractTray(
fromOptionsString: options,
preferredKey: printerCaps.trayKey)
}
/// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`: /// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
/// a capability whose name matches `pageSize.rawValue` → its id; /// a capability whose name matches `pageSize.rawValue` → its id;
/// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`); /// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
@@ -186,17 +256,22 @@ final class PrintSessionViewModel: ObservableObject {
let cups = environment.cupsService let cups = environment.cupsService
let selections = PrintPanelInitialSelections( let selections = PrintPanelInitialSelections(
paperSize: selectedPaperSizeToken, paperSize: selectedPaperSizeToken,
trayKey: printerCaps.trayKey,
tray: selectedTrayToken,
qualityKey: printerCaps.qualityKey, qualityKey: printerCaps.qualityKey,
quality: selectedQuality, quality: selectedQuality,
mediaType: selectedMediaType, mediaType: selectedMediaType,
orientation: printOrientation) orientation: printOrientation)
let ticket = capturedTickets[queue]
Task { @MainActor in Task { @MainActor in
do { do {
guard let result = try await PrintPanelService() guard let result = try await PrintPanelService()
.showProperties( .showProperties(
queue: queue, displayName: displayName, queue: queue, displayName: displayName,
cupsService: cups, cupsService: cups,
initialSelections: selections) initialSelections: selections,
ticket: ticket,
trays: printerCaps.trays)
else { else {
printNotice = Notice( printNotice = Notice(
kind: .info, kind: .info,
@@ -221,6 +296,28 @@ final class PrintSessionViewModel: ObservableObject {
if let media = result.properties.options.mediaType { if let media = result.properties.options.mediaType {
selectedMediaType = media selectedMediaType = media
} }
// Capture-return paper source (#218): a dialog tray
// change updates Stage 2; unknown tokens are appended
// so a driver-only source remains selectable.
if let options = result.properties.options.cupsOptions,
let trayToken = capturedTrayToken(from: options),
!trayToken.isEmpty {
if let known = printerCaps.trays
.first(where: { $0.token == trayToken }) {
selectedTray = known.id
} else {
let nextID = (printerCaps.trays.map(\.id).max() ?? 0) + 1
let synthetic = PrinterTray(
id: nextID, token: trayToken, name: trayToken)
printerCaps.trays.append(synthetic)
selectedTray = nextID
}
} else if let paperSource = result.properties.options.paperSource,
printerCaps.trays.contains(where: {
$0.id == paperSource
}) {
selectedTray = paperSource
}
// Capture-return (#183/#186): a dialog paper/quality/ // Capture-return (#183/#186): a dialog paper/quality/
// orientation change updates the Stage 2 selections — // orientation change updates the Stage 2 selections —
// never `workflow.pageSize` (printtarg layout is // never `workflow.pageSize` (printtarg layout is
@@ -230,7 +327,12 @@ final class PrintSessionViewModel: ObservableObject {
.first(where: { $0.name == paper }) { .first(where: { $0.name == paper }) {
selectedPaperSize = match.id selectedPaperSize = match.id
} }
if let quality = result.properties.options.quality { // A captured quality the driver rejects for the
// (possibly just-captured) media is dropped — keeping
// it would only re-create a failed print (#214).
if let quality = result.properties.options.quality,
printerCaps.allowsQuality(
quality, forMediaType: selectedMediaType) {
selectedQuality = quality selectedQuality = quality
} }
if let orientation = result.properties.options.orientation { if let orientation = result.properties.options.orientation {
@@ -358,6 +460,17 @@ final class PrintSessionViewModel: ObservableObject {
let optionKeys = (try? await environment.cupsService let optionKeys = (try? await environment.cupsService
.optionKeys(for: queue)) ?? [] .optionKeys(for: queue)) ?? []
attachDiagnostics() attachDiagnostics()
// Defence-in-depth (#214): an invalid media+quality pair can
// never reach the ticket — substitute the first allowed
// quality when the pick is stale (UI already clamps; this is
// the last gate before the driver).
var quality = selectedQuality
if let q = quality,
!printerCaps.allowsQuality(
q, forMediaType: selectedMediaType,
trayToken: selectedTrayToken) {
quality = availableQualities.first?.id ?? q
}
return TargetPrintRequest( return TargetPrintRequest(
queue: queue, queue: queue,
displayName: printers.first { $0.name == queue }?.displayName, displayName: printers.first { $0.name == queue }?.displayName,
@@ -373,7 +486,9 @@ final class PrintSessionViewModel: ObservableObject {
paperSize: selectedPaperSizeToken, paperSize: selectedPaperSizeToken,
mediaType: selectedMediaType, mediaType: selectedMediaType,
qualityKey: printerCaps.qualityKey, qualityKey: printerCaps.qualityKey,
quality: selectedQuality, quality: quality,
trayKey: printerCaps.trayKey,
tray: selectedTrayToken,
orientation: printOrientation), orientation: printOrientation),
optionKeys: optionKeys) optionKeys: optionKeys)
} }
@@ -27,9 +27,11 @@ final class TargetPageCanvasView: NSView {
fatalError("TargetPageCanvasView is code-only") fatalError("TargetPageCanvasView is code-only")
} }
/// Flipped: the CoreGraphics coordinate space is correctly oriented /// Bottom-up CoreGraphics space: `CGContext.draw(_:in:)` renders
/// top-down, so page 1 is the TOP rect. /// the raster upright — a flipped view prints mirrored (#211).
override var isFlipped: Bool { true } /// 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 var isOpaque: Bool { true }
override func knowsPageRange(_ range: NSRangePointer) -> Bool { override func knowsPageRange(_ range: NSRangePointer) -> Bool {
@@ -37,7 +39,7 @@ final class TargetPageCanvasView: NSView {
return true 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 { override func rectForPage(_ page: Int) -> NSRect {
NSRect(x: 0, NSRect(x: 0,
y: CGFloat(page - 1) * paperSize.height, y: CGFloat(page - 1) * paperSize.height,
@@ -53,7 +55,7 @@ final class TargetPageCanvasView: NSView {
let pageRect = rectForPage(page) let pageRect = rectForPage(page)
let size = pages[page - 1].pointSize let size = pages[page - 1].pointSize
return NSRect(x: Self.snap(pageRect.minX), return NSRect(x: Self.snap(pageRect.minX),
y: Self.snap(pageRect.minY), y: Self.snap(pageRect.maxY - size.height),
width: size.width, width: size.width,
height: size.height) height: size.height)
} }
+18 -8
View File
@@ -8,6 +8,8 @@ struct TargetPrintOverrides: Equatable {
var mediaType: String? var mediaType: String?
var qualityKey: String? // PrinterCapabilities.qualityKey var qualityKey: String? // PrinterCapabilities.qualityKey
var quality: String? var quality: String?
var trayKey: String? // PrinterCapabilities.trayKey
var tray: String? // raw tray token (#218)
var orientation: String? // "portrait" | "landscape" var orientation: String? // "portrait" | "landscape"
} }
@@ -42,10 +44,11 @@ enum TicketWriteResolver {
/// 5 `com.apple.print.PrintSettings.PMColorMatchingMode` /// 5 `com.apple.print.PrintSettings.PMColorMatchingMode`
/// = APCustomColorMatching (unlocked) /// = APCustomColorMatching (unlocked)
/// 6 `PageSize` = overrides.paperSize /// 6 `PageSize` = overrides.paperSize
/// 7 detectMediaTypeKey(optionKeys) = overrides.mediaType /// 7 overrides.trayKey = overrides.tray
/// 8 overrides.qualityKey = overrides.quality /// 8 detectMediaTypeKey(optionKeys) = overrides.mediaType
/// 9 detectDriverColorBypass(optionKeys) (unlocked) /// 9 overrides.qualityKey = overrides.quality
/// 10 `orientation-requested` = 3 | 4 /// 10 detectDriverColorBypass(optionKeys) (unlocked)
/// 11 `orientation-requested` = 3 | 4
/// ///
/// Invariants: `raw` can never appear (there is no `lp`); nothing /// Invariants: `raw` can never appear (there is no `lp`); nothing
/// is suppressed because a captured key already exists (D6 — the /// is suppressed because a captured key already exists (D6 — the
@@ -87,7 +90,14 @@ enum TicketWriteResolver {
key: "PageSize", value: paperSize, locked: false)) key: "PageSize", value: paperSize, locked: false))
} }
// 7 — media type via the queue's detected vendor key. // 7 — paper source via the queue's detected tray roster key.
if let trayKey = overrides.trayKey, !trayKey.isEmpty,
let tray = overrides.tray, !tray.isEmpty {
writes.append(TicketWrite(
key: trayKey, value: tray, locked: false))
}
// 8 — media type via the queue's detected vendor key.
if let mediaType = overrides.mediaType, if let mediaType = overrides.mediaType,
let mediaKey = CupsParsers.detectMediaTypeKey( let mediaKey = CupsParsers.detectMediaTypeKey(
optionKeys: optionKeys) { optionKeys: optionKeys) {
@@ -95,7 +105,7 @@ enum TicketWriteResolver {
key: mediaKey, value: mediaType, locked: false)) key: mediaKey, value: mediaType, locked: false))
} }
// 8 — print quality; the detected enumeration key travels in // 9 — print quality; the detected enumeration key travels in
// the overrides (`PrinterCapabilities.qualityKey`, #183). // the overrides (`PrinterCapabilities.qualityKey`, #183).
if let qualityKey = overrides.qualityKey, if let qualityKey = overrides.qualityKey,
let quality = overrides.quality { let quality = overrides.quality {
@@ -103,7 +113,7 @@ enum TicketWriteResolver {
key: qualityKey, value: quality, locked: false)) key: qualityKey, value: quality, locked: false))
} }
// 9 — driver "no colour adjustment" bypass. Never gated on // 10 — driver "no colour adjustment" bypass. Never gated on
// `ppdUncorrectedPassthrough` — macOS always bypasses. // `ppdUncorrectedPassthrough` — macOS always bypasses.
if let bypass = CupsParsers.detectDriverColorBypass( if let bypass = CupsParsers.detectDriverColorBypass(
optionKeys: optionKeys) { optionKeys: optionKeys) {
@@ -111,7 +121,7 @@ enum TicketWriteResolver {
key: bypass.key, value: bypass.value, locked: false)) key: bypass.key, value: bypass.value, locked: false))
} }
// 10 — orientation, CUPS IPP codes portrait=3 / landscape=4. // 11 — orientation, CUPS IPP codes portrait=3 / landscape=4.
if let orientation = overrides.orientation { if let orientation = overrides.orientation {
writes.append(TicketWrite( writes.append(TicketWrite(
key: "orientation-requested", key: "orientation-requested",
+22 -17
View File
@@ -266,12 +266,19 @@ struct Stage2View: View {
.background(Theme.background) .background(Theme.background)
.clipShape(Capsule()) .clipShape(Capsule())
.accessibilityIdentifier("printerStatusBadge") .accessibilityIdentifier("printerStatusBadge")
} // AirPrint queues colour-manage the URF path —
// Placeholder — Phase 5 (AirPrint detection, M12) // unmanaged colour cannot be guaranteed (#202).
// replaces this with the live unmanaged-colour if selected.isAirPrint {
// warning badge for AirPrint queues. Text("AirPrint queue — unmanaged colour "
EmptyView() + "cannot be guaranteed.")
.font(.caption)
.foregroundStyle(.orange)
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.orange.opacity(0.12))
.clipShape(Capsule())
.accessibilityIdentifier("airPrintWarningBadge") .accessibilityIdentifier("airPrintWarningBadge")
}
}
Button(action: workflow.print.refreshPrinters) { Button(action: workflow.print.refreshPrinters) {
Image(systemName: "arrow.clockwise") Image(systemName: "arrow.clockwise")
} }
@@ -290,7 +297,7 @@ struct Stage2View: View {
// ViewBuilder ≤10 children (R13). // ViewBuilder ≤10 children (R13).
HStack(spacing: 14) { HStack(spacing: 14) {
if !workflow.print.printerCaps.trays.isEmpty { if !workflow.print.printerCaps.trays.isEmpty {
Picker("Tray", selection: $workflow.print.selectedTray) { Picker("Paper source", selection: $workflow.print.selectedTray) {
ForEach(workflow.print.printerCaps.trays, id: \.id) { ForEach(workflow.print.printerCaps.trays, id: \.id) {
Text($0.name).tag(Optional($0.id)) Text($0.name).tag(Optional($0.id))
} }
@@ -315,16 +322,12 @@ struct Stage2View: View {
if !workflow.print.printerCaps.qualities.isEmpty { if !workflow.print.printerCaps.qualities.isEmpty {
qualityGroup qualityGroup
} }
HStack(spacing: 0) { Picker("Orientation", selection: $workflow.print.printOrientation) {
Button("Portrait") { workflow.print.printOrientation = "portrait" } Text("Portrait").tag("portrait")
.buttonStyle(.bordered) Text("Landscape").tag("landscape")
.tint(workflow.print.printOrientation == "portrait" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientPortrait")
Button("Landscape") { workflow.print.printOrientation = "landscape" }
.buttonStyle(.bordered)
.tint(workflow.print.printOrientation == "landscape" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientLandscape")
} }
.frame(maxWidth: 200)
.accessibilityIdentifier("printerOrientationSelect")
Spacer() Spacer()
} }
// Stage 1 owns the custom dimensions — the caption lives // Stage 1 owns the custom dimensions — the caption lives
@@ -403,11 +406,13 @@ struct Stage2View: View {
.accessibilityIdentifier("paperSizeGroup") .accessibilityIdentifier("paperSizeGroup")
} }
/// Quality picker — driver tokens with PPD-enriched labels (#183). /// Quality picker — driver tokens with PPD-enriched labels (#183),
/// filtered to what the driver accepts for the selected media
/// (#214).
private var qualityGroup: some View { private var qualityGroup: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Picker("Quality", selection: $workflow.print.selectedQuality) { Picker("Quality", selection: $workflow.print.selectedQuality) {
ForEach(workflow.print.printerCaps.qualities, id: \.id) { ForEach(workflow.print.availableQualities, id: \.id) {
Text($0.name).tag(Optional($0.id)) Text($0.name).tag(Optional($0.id))
} }
} }
@@ -54,6 +54,28 @@ final class CupsOptionsFilterTests: XCTestCase {
XCTAssertNil(CupsParsers.extractMediaType( XCTAssertNil(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4")) fromOptionsString: "PageSize=A4"))
} }
/// #218 — vendor paper-source keys survive the filter, and an
/// empty `AP_D_InputSlot=` is still dropped.
func testKeepsTrayKeys() {
let raw = "InputSlot=Rear MediaSource=Front EPIJ_FdSo=Rear "
+ "EPIJ_PSrc=Front CNIJMediaSupply=1 AP_D_InputSlot=Auto"
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
let emptyAlias = "AP_D_InputSlot= InputSlot=Rear"
XCTAssertEqual(CupsOptionsFilter.filter(emptyAlias), "InputSlot=Rear")
}
func testExtractTray() {
XCTAssertEqual(CupsParsers.extractTray(
fromOptionsString: "EPIJ_FdSo=Rear EPIJ_PSrc=Front",
preferredKey: "EPIJ_FdSo"), "Rear")
XCTAssertEqual(CupsParsers.extractTray(
fromOptionsString: "AP_D_InputSlot=Auto InputSlot=Rear",
preferredKey: "InputSlot"), "Rear")
XCTAssertNil(CupsParsers.extractTray(
fromOptionsString: "PageSize=A4"))
}
} }
/// Issue 14 — the dlsym attempt order and first-success semantics. /// Issue 14 — the dlsym attempt order and first-success semantics.
+253 -4
View File
@@ -83,11 +83,13 @@ final class CupsParserTests: XCTestCase {
let caps = service.capabilities(from: listings, ppd: nil) let caps = service.capabilities(from: listings, ppd: nil)
XCTAssertEqual(caps.trays, [ XCTAssertEqual(caps.trays, [
PrinterTray(id: 1, name: "Auto"), PrinterTray(id: 1, token: "Auto", name: "Auto"),
PrinterTray(id: 2, name: "Main"), PrinterTray(id: 2, token: "Main", name: "Main"),
PrinterTray(id: 3, name: "Photo"), PrinterTray(id: 3, token: "Photo", name: "Photo"),
PrinterTray(id: 4, name: "Rear"), PrinterTray(id: 4, token: "Rear", name: "Rear"),
]) ])
XCTAssertEqual(caps.trayKey, "InputSlot")
XCTAssertEqual(caps.trayDefault, "Main")
XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5")) XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5"))
XCTAssertEqual(caps.paperSizes.count, 10) XCTAssertEqual(caps.paperSizes.count, 10)
XCTAssertEqual(caps.mediaTypes.map(\.id), [ XCTAssertEqual(caps.mediaTypes.map(\.id), [
@@ -283,6 +285,82 @@ final class CupsParserTests: XCTestCase {
XCTAssertNil(pair(["PageSize"])) XCTAssertNil(pair(["PageSize"]))
} }
// MARK: - #218 paper-source roster
func testTrayKeyPrecedence() {
// Generic keys first; `EPIJ_FdSo` preferred over `EPIJ_PSrc`.
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"InputSlot", "EPIJ_FdSo", "EPIJ_PSrc"]), "InputSlot")
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"MediaSource", "EPIJ_FdSo", "EPIJ_PSrc"]), "MediaSource")
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"EPIJ_FdSo", "EPIJ_PSrc"]), "EPIJ_FdSo")
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"EPIJ_PSrc", "CNIJMediaSupply"]), "EPIJ_PSrc")
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"CNIJMediaSupply"]), "CNIJMediaSupply")
XCTAssertNil(CupsParsers.detectTrayKey(optionKeys: ["PageSize"]))
}
func testExtractTrayPreferredKeyAndRoster() {
let preferred = CupsParsers.extractTray(
fromOptionsString: "EPIJ_FdSo=Rear EPIJ_PSrc=Front",
preferredKey: "EPIJ_FdSo")
XCTAssertEqual(preferred, "Rear")
let fallback = CupsParsers.extractTray(
fromOptionsString: "EPIJ_PSrc=Front", preferredKey: "EPIJ_FdSo")
XCTAssertEqual(fallback, "Front")
let alias = CupsParsers.extractTray(
fromOptionsString: "AP_D_InputSlot=Auto")
XCTAssertEqual(alias, "Auto")
XCTAssertNil(CupsParsers.extractTray(
fromOptionsString: "PageSize=A4"))
XCTAssertNil(CupsParsers.extractTray(
fromOptionsString: "AP_D_InputSlot="))
}
func testTrayExtractionCaseInsensitive() {
XCTAssertEqual(CupsParsers.extractTray(
fromOptionsString: "epij_fdso=Rear", preferredKey: "EPIJ_FdSo"),
"Rear")
}
func testCapabilitiesTraysEpson() {
let service = CupsService()
let listings = CupsParsers.lpoptionsList("""
EPIJ_PSrc/Page Setup: *Front Rear Cassette
EPIJ_FdSo/Paper Source: Auto *Rear Front
""")
let caps = service.capabilities(from: listings, ppd: nil)
XCTAssertEqual(caps.trayKey, "EPIJ_FdSo")
XCTAssertEqual(caps.trayDefault, "Rear")
XCTAssertEqual(caps.trays.map(\.token), ["Auto", "Rear", "Front"])
XCTAssertEqual(caps.trays.map(\.name), ["Auto", "Rear", "Front"])
}
func testCapabilitiesTraysCanon() {
let ppd = """
*CNIJMediaSupply 1/Cassette (Upper): ""
*CNIJMediaSupply 2/Manual Feed: ""
*CNIJMediaSupply 4/Rear Tray: ""
"""
let service = CupsService()
let listings = CupsParsers.lpoptionsList("""
CNIJMediaSupply/Media Supply: *1 2 4
""")
let caps = service.capabilities(from: listings, ppd: ppd)
XCTAssertEqual(caps.trayKey, "CNIJMediaSupply")
XCTAssertEqual(caps.trays.map(\.token), ["1", "2", "4"])
XCTAssertEqual(caps.trays.map(\.name), [
"Cassette (Upper)", "Manual Feed", "Rear Tray"
])
}
// MARK: - #181 Canon media locale precedence + PPD encoding // MARK: - #181 Canon media locale precedence + PPD encoding
/// The 18 Canon Pro9500 media types named in the issue — the ids /// The 18 Canon Pro9500 media types named in the issue — the ids
@@ -455,4 +533,175 @@ final class CupsParserTests: XCTestCase {
processManager: ProcessManager(), processManager: ProcessManager(),
binaryDir: bin, ppdDir: ppdDir), root) binaryDir: bin, ppdDir: ppdDir), root)
} }
// MARK: - #202 AirPrint detection
/// `lpstat -v` — `device for <name>: <uri>` lines; a `network`
/// remote stub carries no URI and is skipped.
private let lpstatV = """
device for Canon_Pro9500_II_series_XPS: usb://Canon/PRO-9500%20II%20series?serial=1234AB
device for Epson_XP_55_LPD: lpd://192.168.1.50/queue
device for EPSON_XP_55_Series: ipp://EPSON%20XP-55%20Series._universal._sub._ipp._tcp.local./
device for Office_IPPS: ipps://print.example.com/ipp/print
network Remote_Queue
"""
func testLpstatDeviceURIs() {
let uris = CupsParsers.lpstatDeviceURIs(output: lpstatV)
XCTAssertEqual(uris.count, 4)
XCTAssertEqual(uris["Canon_Pro9500_II_series_XPS"],
"usb://Canon/PRO-9500%20II%20series?serial=1234AB")
XCTAssertEqual(uris["Epson_XP_55_LPD"], "lpd://192.168.1.50/queue")
XCTAssertEqual(uris["EPSON_XP_55_Series"],
"ipp://EPSON%20XP-55%20Series._universal._sub._ipp._tcp.local./")
XCTAssertEqual(uris["Office_IPPS"],
"ipps://print.example.com/ipp/print")
XCTAssertNil(uris["Remote_Queue"])
XCTAssertEqual(CupsParsers.lpstatDeviceURIs(output: ""), [:])
}
func testLpoptionsMakeAndModel() {
XCTAssertEqual(CupsParsers.lpoptionsMakeAndModel(
output: lpoptionsP), "EPSON EPSON XP-55 Series")
XCTAssertNil(CupsParsers.lpoptionsMakeAndModel(
output: "printer-type=42\n"))
}
/// Rule 1 — an `apple-airprint://` device URI is AirPrint on its
/// own; PPD and make-and-model are irrelevant.
func testAirPrintRuleAppleAirPrintScheme() {
XCTAssertTrue(CupsParsers.detectAirPrint(
deviceURI: "apple-airprint://DeskJet._ipps._tcp.local./",
makeAndModel: nil, ppd: ""))
}
/// Rule 2 — the PPD declares `*APAirPrint: True`.
func testAirPrintRulePPDFlag() {
let ppd = """
*PPD-Adobe: "4.3"
*APAirPrint: True
*OpenUI *PageSize/Media Size: PickOne
"""
XCTAssertTrue(CupsParsers.detectAirPrint(
deviceURI: "socket://10.0.0.9/", makeAndModel: nil, ppd: ppd))
}
/// Rule 3 — make-and-model contains "Apple" and "AirPrint".
func testAirPrintRuleMakeAndModel() {
XCTAssertTrue(CupsParsers.detectAirPrint(
deviceURI: "socket://10.0.0.9/",
makeAndModel: "Apple AirPrint", ppd: ""))
// Both tokens are required.
XCTAssertFalse(CupsParsers.detectAirPrint(
deviceURI: "socket://10.0.0.9/",
makeAndModel: "Apple LaserWriter", ppd: ""))
XCTAssertFalse(CupsParsers.detectAirPrint(
deviceURI: "socket://10.0.0.9/",
makeAndModel: "HP AirPrint-Ready", ppd: ""))
}
/// Rule 4 — `ipps://` URI **and** the PPD text mentions "airprint"
/// case-insensitively. `ipps://` alone is not enough.
func testAirPrintRuleIPPSWithPPDMention() {
XCTAssertTrue(CupsParsers.detectAirPrint(
deviceURI: "ipps://print.example.com/ipp/print",
makeAndModel: nil,
ppd: "*Foo: \"AIRPRINT enabled\"\n"))
XCTAssertFalse(CupsParsers.detectAirPrint(
deviceURI: "ipps://print.example.com/ipp/print",
makeAndModel: nil, ppd: "*PPD-Adobe: \"4.3\"\n"))
// An unencrypted ipp:// URI does not satisfy rule 4.
XCTAssertFalse(CupsParsers.detectAirPrint(
deviceURI: "ipp://print.example.com/ipp/print",
makeAndModel: nil,
ppd: "*Foo: \"airprint enabled\"\n"))
}
/// Rule 5 — unencrypted `ipp://` resolved via the AirPrint mDNS
/// subtype `_universal._sub._ipp._tcp`. A plain `ipp://` mDNS name
/// without the subtype is not AirPrint.
func testAirPrintRuleMDNSSubtype() {
XCTAssertTrue(CupsParsers.detectAirPrint(
deviceURI: "ipp://EPSON%20XP-55._universal._sub._ipp._tcp.local./",
makeAndModel: nil, ppd: ""))
XCTAssertFalse(CupsParsers.detectAirPrint(
deviceURI: "ipp://EPSON%20XP-55._ipp._tcp.local./",
makeAndModel: nil, ppd: ""))
}
/// Rule 6 — a `*cupsFilter2` rule whose destination MIME is
/// `image/urf` (the AirPrint-only raster). A PWG-raster filter is
/// not AirPrint.
func testAirPrintRuleCupsFilter2URF() {
let ppd = """
*cupsFilter2: "application/pdf image/urf 0 -"
*cupsFilter2: "image/urf image/urf 100 -"
"""
XCTAssertTrue(CupsParsers.detectAirPrint(
deviceURI: nil, makeAndModel: nil, ppd: ppd))
XCTAssertFalse(CupsParsers.detectAirPrint(
deviceURI: nil, makeAndModel: nil,
ppd: "*cupsFilter2: \"application/pdf image/pwg-raster 0 -\"\n"))
}
/// Negative — a standard USB raster-driver queue (Epson XP-55)
/// matches none of the six rules.
func testAirPrintNegativeUSBRaster() {
let ppd = """
*PPD-Adobe: "4.3"
*EPIJ_Qual 303/Normal: ""
*cupsFilter: "application/vnd.cups-raster 0 rastertoepson"
"""
XCTAssertFalse(CupsParsers.detectAirPrint(
deviceURI: "usb://EPSON/XP-55%20Series?serial=ABC123",
makeAndModel: "EPSON XP-55 Series", ppd: ppd))
}
/// `listPrinters` survives a failing `lpstat -v` — the failure is
/// tolerated, enumeration proceeds, every queue reports
/// `isAirPrint == false`, and there is **no** fallback respawn
/// (exactly one `-v` invocation, #202).
func testListPrintersToleratesLpstatVFailure() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-airprint-\(UUID().uuidString)")
let bin = root.appendingPathComponent("bin")
try FileManager.default.createDirectory(
at: bin, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let log = root.appendingPathComponent("lpstat.log")
let lpstat = """
#!/bin/sh
printf '%s\\n' "$1" >> '\(log.path)'
case "$1" in
-e) printf 'Mock_Epson\\n' ;;
-p) printf 'printer Mock_Epson is idle.\\n' ;;
-d) printf 'no system default destination\\n' ;;
-v) exit 1 ;;
esac
exit 0
"""
let lpoptions = """
#!/bin/sh
printf "printer-info='Mock'\\n"
"""
for (name, body) in [("lpstat", lpstat), ("lpoptions", lpoptions)] {
let url = bin.appendingPathComponent(name)
try body.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path)
}
let service = CupsService(
processManager: ProcessManager(), binaryDir: bin,
ppdDir: root.appendingPathComponent("ppd"))
let printers = try await service.listPrinters()
XCTAssertEqual(printers.map(\.name), ["Mock_Epson"])
XCTAssertFalse(printers[0].isAirPrint)
let calls = ((try? String(contentsOf: log, encoding: .utf8)) ?? "")
.split(separator: "\n")
XCTAssertEqual(calls.filter { $0 == "-v" }.count, 1,
"lpstat -v must be spawned exactly once: \(calls)")
}
} }
@@ -0,0 +1,412 @@
import XCTest
import Foundation
@testable import ICCeryCore
/// #214 — media → allowed-quality resolution: generic PPD
/// `*UIConstraints`, Epson `PDEData.dat` `*EPIJUIConstraint` rules,
/// and the Canon `cnb_*.tbl` record scan. All fixtures inline /
/// injected — no installed drivers touched.
final class MediaQualityConstraintsTests: XCTestCase {
/// Epson-flavoured `lpoptions -l` listings: the XP-55 advertises
/// the conditional LHS keys (`EPIJ_PSrc`/`EPIJ_FdSo`/`EPIJ_Ink_`)
/// with defaults 2/2/1.
private var epsonListings: [CupsOptionListing] {
CupsParsers.lpoptionsList("""
PageSize/Media Size: 4x6 5x7 *A4 Letter
EPIJ_Medi/Media Type: *0 92 13 15 145 12 2 75 26 76 93
EPIJ_PSrc/Page Setup: *2 3 25
EPIJ_FdSo/Paper Source: *2 3 12
EPIJ_Ink_/Grayscale: *1 0
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
""")
}
private var epsonQualityIDs: Set<String> {
["301", "302", "303", "304", "305", "307", "308"]
}
// MARK: - ppdKeyword
func testPPDKeywordQuotedAndBare() {
let ppd = """
*EPIJDriverBasePath: "/Library/Printers/EPSON/InkjetPrinter2"
*EPIJMachineBundleName: "EP14C0605W.data"
*CNIJTableID: 354
*CNIJTableIDFoo: 999
"""
XCTAssertEqual(
MediaQualityConstraints.ppdKeyword(ppd, "EPIJDriverBasePath"),
"/Library/Printers/EPSON/InkjetPrinter2")
XCTAssertEqual(
MediaQualityConstraints.ppdKeyword(ppd, "EPIJMachineBundleName"),
"EP14C0605W.data")
XCTAssertEqual(
MediaQualityConstraints.ppdKeyword(ppd, "CNIJTableID"), "354")
// Prefix-safety: CNIJTableIDFoo must not satisfy the lookup.
XCTAssertNil(MediaQualityConstraints.ppdKeyword(ppd, "CNIJTableIDX"))
XCTAssertNil(MediaQualityConstraints.ppdKeyword(ppd, "Missing"))
}
// MARK: - Path derivation
func testEPIJPDEDataPath() {
let ppd = """
*EPIJDriverBasePath: "/Library/Printers/EPSON/InkjetPrinter2"
*EPIJMachineBundleName: "EP14C0605W.data"
"""
XCTAssertEqual(
MediaQualityConstraints.epijPDEDataPath(ppd: ppd)?.path,
"/Library/Printers/EPSON/InkjetPrinter2/Machine/"
+ "EP14C0605W.data/Contents/Resources/PDEData.dat")
XCTAssertNil(MediaQualityConstraints.epijPDEDataPath(
ppd: "*CNIJTableID: 354\n"))
}
func testCNIJTablePath() {
let ppd = """
*CNIJNameTblPath: "/Library/Printers/Canon/BJPrinter/Resources/Database/CIJPro9500IIseries.db/Contents/Resources"
*CNIJTableID: 354
"""
XCTAssertEqual(
MediaQualityConstraints.cnijTablePath(ppd: ppd)?.path,
"/Library/Printers/Canon/BJPrinter/Resources/Database/"
+ "CIJPro9500IIseries.db/Contents/Resources/cnb_3540.tbl")
XCTAssertNil(MediaQualityConstraints.cnijTablePath(
ppd: "*EPIJMachineBundleName: \"x\"\n"))
}
// MARK: - Generic PPD UIConstraints
func testPPDUIConstraintsBothOrders() {
let ppd = """
*UIConstraints: *MediaType Glossy *PrintQuality Draft
*UIConstraints: *PrintQuality Draft *MediaType Matte
*Constraints: *MediaType Glossy *PrintQuality Low
*UIConstraints: *InputSlot Rear *PrintQuality Draft
"""
let forbidden = MediaQualityConstraints.ppdUIConstraints(
ppd, mediaKey: "MediaType", qualityKey: "PrintQuality",
mediaIDs: ["Glossy", "Matte"],
qualityIDs: ["Draft", "Low", "High"])
XCTAssertEqual(forbidden["Glossy"], ["Draft", "Low"])
XCTAssertEqual(forbidden["Matte"], ["Draft"])
}
// MARK: - Epson EPIJUIConstraint
/// Realistic XP-55 fragment: pure-media rules plus conditional
/// rules on `EPIJ_PSrc`/`EPIJ_Ink_` evaluated against defaults.
func testEPIJConstraintsAgainstDefaults() {
let dat = """
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 307
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 308
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 303
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 304
*EPIJUIConstraint: *EPIJ_PSrc 3 *EPIJ_Medi 0|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_PSrc 2 *EPIJ_Medi 0 *EPIJ_Ink_ 0|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_PSrc 3 *EPIJ_Medi 92|*EPIJ_Qual 999
"""
let forbidden = MediaQualityConstraints.epijUIConstraints(
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
mediaIDs: ["0", "15", "92"], qualityIDs: epsonQualityIDs,
defaults: ["EPIJ_PSrc": "2", "EPIJ_Ink_": "1"])
// Media 0: pure rules forbid 305/307/308; the PSrc=3 rule is
// inert at default PSrc=2 (301 survives); the PSrc=2 rule
// additionally requires Ink_=0 — default is 1, so inert too
// (302 survives).
XCTAssertEqual(forbidden["0"], ["305", "307", "308"])
XCTAssertEqual(forbidden["15"], ["301", "302", "303", "304"])
// 999 is not a listed quality id — ignored outright.
XCTAssertEqual(forbidden["92"], ["301"])
}
/// A conditional rule whose extra terms all match the defaults
/// fires like a pure-media rule.
func testEPIJConditionalRuleFiresAtDefaults() {
let dat = """
*EPIJUIConstraint: *EPIJ_PSrc 2 *EPIJ_Medi 0|*EPIJ_Qual 304
"""
let forbidden = MediaQualityConstraints.epijUIConstraints(
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
mediaIDs: ["0"], qualityIDs: epsonQualityIDs,
defaults: ["EPIJ_PSrc": "2"])
XCTAssertEqual(forbidden["0"], ["304"])
}
/// A rule with no media term applies to every listed media; a term
/// on a key the queue does not advertise counts as satisfied
/// (conservative-forbid — #214 semantics).
func testEPIJGlobalAndUnknownKeyRules() {
let dat = """
*EPIJUIConstraint: *EPIJ_Ink_ 1|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_Mode 9 *EPIJ_Medi 0|*EPIJ_Qual 307
"""
let forbidden = MediaQualityConstraints.epijUIConstraints(
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
mediaIDs: ["0", "15"], qualityIDs: epsonQualityIDs,
defaults: ["EPIJ_Ink_": "1"])
// Global rule: Ink_=1 matches default → 305 forbidden on both.
XCTAssertTrue(forbidden["0"]!.contains("305"))
XCTAssertTrue(forbidden["15"]!.contains("305"))
// EPIJ_Mode is unlisted → satisfied → (0, 307) forbidden too.
XCTAssertTrue(forbidden["0"]!.contains("307"))
}
/// The end-to-end XP-55 expectation through `resolve` — the exact
/// sets the user reported from the driver PDE.
func testResolveEpsonProducesDriverSets() {
let dat = """
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 307
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 308
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 303
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 304
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 308
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 303
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 304
"""
let ppd = """
*EPIJDriverBasePath: "/drivers/epson"
*EPIJMachineBundleName: "EP14C0605W.data"
"""
let datURL = URL(fileURLWithPath:
"/drivers/epson/Machine/EP14C0605W.data/"
+ "Contents/Resources/PDEData.dat")
let map = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
readFile: { $0 == datURL ? dat.data(using: .utf8) : nil })
// Plain paper: Fast Economy / Economy / Normal / Fine.
XCTAssertEqual(map["0"], ["301", "302", "303", "304"])
// Premium Semigloss: Quality / Best Quality / Draft.
XCTAssertEqual(map["15"], ["305", "307", "308"])
// Ultra Glossy drops Fast Economy…Fine and Draft.
XCTAssertEqual(map["92"], ["305", "307"])
// Unconstrained media (no rules) produce no entry.
XCTAssertNil(map["13"])
}
// MARK: - #218 tray-aware quality constraints
/// A conditional rule keyed by the selected tray/token must use the
/// supplied override rather than the queue's lpoptions default.
func testResolveWithTrayOverride() {
let dat = """
*EPIJUIConstraint: *EPIJ_FdSo 3 *EPIJ_Medi 0|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_FdSo 12 *EPIJ_Medi 0|*EPIJ_Qual 307
"""
// The queue default is EPIJ_FdSo=2 (from `epsonListings`), so
// the default map has no forbidden qualities for media "0".
let defaultMap = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: nil)
XCTAssertNil(defaultMap["0"])
// Override to tray "3" -> 305 forbidden; override to tray "12"
// -> 307 forbidden.
let ppd = """
*EPIJDriverBasePath: "/drivers/epson"
*EPIJMachineBundleName: "EP14C0605W.data"
"""
let datURL = URL(fileURLWithPath:
"/drivers/epson/Machine/EP14C0605W.data/"
+ "Contents/Resources/PDEData.dat")
let readFile: (URL) -> Data? = {
$0 == datURL ? dat.data(using: .utf8) : nil
}
let tray3 = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
optionOverrides: ["EPIJ_FdSo": "3"], readFile: readFile)
XCTAssertEqual(tray3["0"], epsonQualityIDs.subtracting(["305"]))
let tray12 = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
optionOverrides: ["EPIJ_FdSo": "12"], readFile: readFile)
XCTAssertEqual(tray12["0"], epsonQualityIDs.subtracting(["307"]))
}
func testResolveTrayOverrideCacheReadOnlyOnce() {
let dat = """
*EPIJUIConstraint: *EPIJ_FdSo 3 *EPIJ_Medi 0|*EPIJ_Qual 305
"""
let ppd = """
*EPIJDriverBasePath: "/drivers/epson"
*EPIJMachineBundleName: "EP14C0605W.data"
"""
let datURL = URL(fileURLWithPath:
"/drivers/epson/Machine/EP14C0605W.data/"
+ "Contents/Resources/PDEData.dat")
var reads = 0
var cache: [URL: Data] = [:]
let readFile: (URL) -> Data? = {
if let cached = cache[$0] { return cached }
reads += 1
let data = $0 == datURL ? dat.data(using: .utf8) : nil
cache[$0] = data
return data
}
_ = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
optionOverrides: ["EPIJ_FdSo": "3"], readFile: readFile)
_ = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
optionOverrides: ["EPIJ_FdSo": "12"], readFile: readFile)
XCTAssertEqual(reads, 1)
}
// MARK: - Canon cnb table
/// Builds one 20-byte Canon record: `30 00 01 00 00 00 00 00`,
/// `family:u16`, `flag:u16`, `media:u32`, `pad:u16`, `quality:u16`.
private func canonRecord(
family: UInt16 = 3, flag: UInt16 = 0,
media: UInt32, pad: UInt16 = 0, quality: UInt16
) -> Data {
var d = Data([0x30, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00])
d.append(contentsOf: [UInt8(family & 0xFF), UInt8(family >> 8)])
d.append(contentsOf: [UInt8(flag & 0xFF), UInt8(flag >> 8)])
d.append(contentsOf: [
UInt8(media & 0xFF), UInt8((media >> 8) & 0xFF),
UInt8((media >> 16) & 0xFF), UInt8((media >> 24) & 0xFF)])
d.append(contentsOf: [UInt8(pad & 0xFF), UInt8(pad >> 8)])
d.append(contentsOf: [UInt8(quality & 0xFF), UInt8(quality >> 8)])
return d
}
func testCNBTableScan() {
var data = Data()
// Junk prefix — even length keeps the records 2-byte aligned.
data.append(contentsOf: [0xFF, 0x00])
// media 0 → {0, 10, 15, 20} across flag 0/1 variants.
for q: UInt16 in [10, 15, 20, 0] {
data.append(canonRecord(media: 0, quality: q))
}
data.append(canonRecord(flag: 1, media: 0, quality: 10))
// media 50 → {0, 5, 10}; plus its borderless variant 0x10032.
for q: UInt16 in [0, 5, 10] {
data.append(canonRecord(media: 50, quality: q))
}
data.append(canonRecord(media: 0x10032, quality: 0))
// Noise: unknown media, unknown quality, nonzero pad, and a
// different record family that must lose the modal vote.
data.append(canonRecord(media: 999, quality: 5))
data.append(canonRecord(media: 50, quality: 7))
data.append(canonRecord(media: 50, pad: 1, quality: 15))
data.append(canonRecord(family: 9, media: 50, quality: 15))
data.append(Data([0x11, 0x22, 0x33]))
let map = MediaQualityConstraints.cnijMediaQualityTable(
data,
mediaIDs: ["0", "50", "42"],
qualityIDs: ["0", "5", "10", "15", "20"])
XCTAssertEqual(map["0"], ["0", "10", "15", "20"])
XCTAssertEqual(map["50"], ["0", "5", "10"])
XCTAssertNil(map["42"])
}
func testCNBGarbageFailsOpen() {
let map = MediaQualityConstraints.cnijMediaQualityTable(
Data((0..<4096).map { _ in UInt8.random(in: 0...255) }),
mediaIDs: ["0", "50"], qualityIDs: ["0", "5", "10"])
XCTAssertTrue(map.isEmpty)
}
// MARK: - resolve ordering / fallback
func testResolvePrefersPPDConstraints() {
let ppd = """
*UIConstraints: *EPIJ_Medi 0 *EPIJ_Qual 305
*EPIJDriverBasePath: "/should/not/be/read"
*EPIJMachineBundleName: "x.data"
"""
var reads = 0
let map = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
readFile: { _ in reads += 1; return nil })
XCTAssertEqual(map["0"], epsonQualityIDs.subtracting(["305"]))
// The Epson path is never touched once PPD constraints hit.
XCTAssertEqual(reads, 0)
}
func testResolveNoPPDOrKeysFailsOpen() {
XCTAssertTrue(MediaQualityConstraints.resolve(
listings: epsonListings, ppd: nil).isEmpty)
// No media key in the roster → no map.
XCTAssertTrue(MediaQualityConstraints.resolve(
listings: CupsParsers.lpoptionsList(
"PageSize/Media Size: *A4 Letter\n"),
ppd: "*CNIJTableID: 354\n").isEmpty)
}
// MARK: - PrinterCapabilities accessors
func testCapabilitiesAccessorFallbacks() {
var caps = PrinterCapabilities(
mediaTypes: [
PrinterMediaType(id: "0", name: "Plain"),
PrinterMediaType(id: "15", name: "Semigloss"),
],
qualities: ["301", "302", "303", "308", "304", "305", "307"]
.map { PrinterQuality(id: $0, name: "Q\($0)") })
// No map → everything, in driver order (#180).
XCTAssertEqual(caps.qualities(forMediaType: "0").map(\.id),
["301", "302", "303", "308", "304", "305", "307"])
XCTAssertTrue(caps.allowsQuality("305", forMediaType: "0"))
caps.qualityIDsByMediaType = [
"0": ["301", "302", "303", "304"],
"15": ["305", "307", "308"],
]
XCTAssertEqual(caps.qualities(forMediaType: "0").map(\.id),
["301", "302", "303", "304"])
// Driver order preserved — 308 sits before 304 in lpoptions.
XCTAssertEqual(caps.qualities(forMediaType: "15").map(\.id),
["308", "305", "307"])
XCTAssertTrue(caps.allowsQuality("308", forMediaType: "15"))
XCTAssertFalse(caps.allowsQuality("308", forMediaType: "0"))
// Unknown media → unconstrained.
XCTAssertEqual(caps.qualities(forMediaType: "99").count, 7)
XCTAssertTrue(caps.allowsQuality("305", forMediaType: nil))
}
func testTraySpecificQualityMap() {
var caps = PrinterCapabilities(
mediaTypes: [
PrinterMediaType(id: "0", name: "Plain"),
],
qualities: ["301", "302", "303", "304", "305"]
.map { PrinterQuality(id: $0, name: "Q\($0)") },
qualityIDsByMediaType: ["0": ["301", "302", "303"]])
caps.qualityIDsByMediaTypeByTray = [
"Rear": ["0": ["301", "302", "303"]],
"Front": ["0": ["303", "304", "305"]],
]
XCTAssertEqual(
caps.qualities(forMediaType: "0", trayToken: "Rear")
.map { $0.id }, ["301", "302", "303"])
XCTAssertEqual(
caps.qualities(forMediaType: "0", trayToken: "Front")
.map { $0.id }, ["303", "304", "305"])
// Unknown tray falls back to the default map.
XCTAssertEqual(
caps.qualities(forMediaType: "0", trayToken: "Unknown")
.map { $0.id }, ["301", "302", "303"])
// No tray token also falls back.
XCTAssertEqual(
caps.qualities(forMediaType: "0").map { $0.id },
["301", "302", "303"])
XCTAssertTrue(caps.allowsQuality(
"304", forMediaType: "0", trayToken: "Front"))
XCTAssertFalse(caps.allowsQuality(
"304", forMediaType: "0", trayToken: "Rear"))
}
}
+142 -37
View File
@@ -40,21 +40,8 @@ final class NativeSpoolPDFTests: XCTestCase {
pixelBytes: patchBytes, in: tempRoot) pixelBytes: patchBytes, in: tempRoot)
let raster = try TargetRasterLoader.load(tiff: tiff) 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") let pdfURL = tempRoot.appendingPathComponent("spool.pdf")
info.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = pdfURL let info = makeSaveInfo(pdfURL: pdfURL)
let canvas = TargetPageCanvasView( let canvas = TargetPageCanvasView(
pages: [raster, raster], paperSize: paperSize) pages: [raster, raster], paperSize: paperSize)
@@ -88,29 +75,7 @@ final class NativeSpoolPDFTests: XCTestCase {
) throws { ) throws {
let width = Int(ceil(mediaBox.width)) let width = Int(ceil(mediaBox.width))
let height = Int(ceil(mediaBox.height)) let height = Int(ceil(mediaBox.height))
let rep = try XCTUnwrap(NSBitmapImageRep( let rep = try renderPageToBitmap(page, mediaBox: mediaBox)
bitmapDataPlanes: nil,
pixelsWide: width,
pixelsHigh: height,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0))
let context = try XCTUnwrap(
NSGraphicsContext(bitmapImageRep: rep))
let cg = context.cgContext
let transform = page.getDrawingTransform(
.mediaBox,
rect: CGRect(x: 0, y: 0, width: width, height: height),
rotate: 0, preserveAspectRatio: true)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = context
cg.concatenate(transform)
cg.drawPDFPage(page)
NSGraphicsContext.restoreGraphicsState()
func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) { func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) {
let color = try XCTUnwrap( let color = try XCTUnwrap(
@@ -161,4 +126,144 @@ final class NativeSpoolPDFTests: XCTestCase {
XCTAssertEqual(outside.1, 1, accuracy: 0.04) XCTAssertEqual(outside.1, 1, accuracy: 0.04)
XCTAssertEqual(outside.2, 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
}
} }
+63 -1
View File
@@ -44,10 +44,16 @@ final class PrintPanelStubTests: XCTestCase {
"ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear", "ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear",
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue", "ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
]) { ]) {
let result = UITestHooks.printPanelResult(forQueue: "q") let trays = [
PrinterTray(id: 1, token: "Auto", name: "Auto"),
PrinterTray(id: 2, token: "Rear", name: "Rear"),
]
let result = UITestHooks.printPanelResult(
forQueue: "q", trays: trays, preferredTrayKey: "InputSlot")
XCTAssertEqual(result?.selectedPrinter, "Other_Queue") XCTAssertEqual(result?.selectedPrinter, "Other_Queue")
XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear") XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
XCTAssertEqual(result?.options.mediaType, "Photo") XCTAssertEqual(result?.options.mediaType, "Photo")
XCTAssertEqual(result?.options.paperSource, 2)
XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true) XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
} }
} }
@@ -99,4 +105,60 @@ final class PrintPanelStubTests: XCTestCase {
XCTAssertNil(result?.options.cupsOptions) XCTAssertNil(result?.options.cupsOptions)
} }
} }
/// #218 — vendor tray keys and the `AP_D_InputSlot` capture alias
/// map back through `paperSource` when the token matches the roster.
func testOkResultExtractsTrayToken() throws {
let trays = [
PrinterTray(id: 1, token: "Auto", name: "Auto"),
PrinterTray(id: 2, token: "Rear", name: "Rear Tray"),
PrinterTray(id: 3, token: "Manual", name: "Manual Feed"),
]
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS": "PageSize=A4 EPIJ_FdSo=Rear",
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(
forQueue: "q", trays: trays,
preferredTrayKey: "EPIJ_FdSo")
XCTAssertEqual(result?.options.paperSource, 2)
XCTAssertEqual(result?.options.cupsOptions,
"PageSize=A4 EPIJ_FdSo=Rear")
}
}
func testOkResultExtractsAPDInputSlotAlias() throws {
let trays = [
PrinterTray(id: 1, token: "Auto", name: "Auto"),
PrinterTray(id: 2, token: "Manual", name: "Manual Feed"),
]
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS": "AP_D_InputSlot=Manual",
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(
forQueue: "q", trays: trays)
XCTAssertEqual(result?.options.paperSource, 2)
}
}
func testOkResultUnknownTrayNoPaperSource() throws {
let trays = [
PrinterTray(id: 1, token: "Auto", name: "Auto"),
]
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS": "InputSlot=Unknown",
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(
forQueue: "q", trays: trays)
XCTAssertNil(result?.options.paperSource)
}
}
} }
@@ -0,0 +1,180 @@
import AppKit
import Foundation
import XCTest
@testable import ICCery
@testable import ICCeryCore
/// Issue #217 — the panel's pre-modal seam
/// (`PrintPanelService.configureBoundPanel`) must rehydrate the
/// captured `PrintTicket` before the Stage 2 writes, validate the
/// result, and refuse a foreign-queue ticket — all without driving
/// the modal or needing a real queue (`printer: nil` exercises
/// restore → selections → validate on a session-less `NSPrintInfo`).
@MainActor
final class PrintPanelTicketTests: XCTestCase {
private let service = PrintPanelService()
/// The queue a destination `NSPrintInfo` will report as bound —
/// the session's current printer, else a synthetic token for a
/// queue-less environment (same convention as PrintTicketTests).
private func boundQueue(of printInfo: NSPrintInfo) -> String {
PMTicketBridge.currentPrinterID(
session: PMTicketBridge.session(printInfo),
fallback: "UnboundQueue")
}
private func makeTicket(
queue: String,
key: String = "EPIJ_Qual",
value: String = "305"
) throws -> PrintTicket {
let source = NSPrintInfo()
XCTAssertTrue(
PMTicketBridge.setValue(
value, forKey: key, locked: false,
in: PMTicketBridge.settings(source),
context: "PrintPanelTicketTests"))
return try PMTicketBridge.serialise(source, queue: queue)
}
private func request(
ticket: PrintTicket?,
selections: PrintPanelInitialSelections =
PrintPanelInitialSelections(),
trays: [PrinterTray] = []
) -> PrintPanelRequest {
PrintPanelRequest(
queue: "Panel_Q", displayName: nil, optionKeys: [],
initialSelections: selections, ticket: ticket, trays: trays)
}
/// RC1 — a captured vendor key is present in the configured
/// printInfo before the modal would open.
func testTicketRestoredPreModal() throws {
let printInfo = NSPrintInfo()
let ticket = try makeTicket(queue: boundQueue(of: printInfo))
service.configureBoundPanel(
printInfo, request: request(ticket: ticket), printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"305")
}
/// D6 parity — Stage 2 selections win over the restored ticket.
func testStage2WinsOverTicket() throws {
let printInfo = NSPrintInfo()
let ticket = try makeTicket(queue: boundQueue(of: printInfo))
var selections = PrintPanelInitialSelections()
selections.qualityKey = "EPIJ_Qual"
selections.quality = "303"
service.configureBoundPanel(
printInfo,
request: request(ticket: ticket, selections: selections),
printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"303")
}
/// R3 — a ticket captured for another queue is refused; the
/// destination's settings stay untouched and nothing throws.
func testCrossQueueTicketRefusedAtPanel() throws {
let printInfo = NSPrintInfo()
let bound = boundQueue(of: printInfo)
let foreign = "DefinitelyNot_\(bound)"
try XCTSkipIf(
bound == foreign,
"destination resolved to the foreign queue")
XCTAssertTrue(
PMTicketBridge.setValue(
"999", forKey: "EPIJ_Qual", locked: false,
in: PMTicketBridge.settings(printInfo),
context: "PrintPanelTicketTests"))
let ticket = try makeTicket(queue: foreign)
service.configureBoundPanel(
printInfo, request: request(ticket: ticket), printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"999")
}
/// #218 — the Stage 2 tray selection lands after `PageSize` and
/// before the media key in the configured `NSPrintInfo`.
func testStage2TrayWrittenPreModal() {
let printInfo = NSPrintInfo()
var selections = PrintPanelInitialSelections()
selections.paperSize = "A4"
selections.trayKey = "EPIJ_FdSo"
selections.tray = "Rear"
selections.mediaType = "Photo"
var req = request(ticket: nil, selections: selections)
req = PrintPanelRequest(
queue: req.queue, displayName: req.displayName,
optionKeys: ["MediaType"], initialSelections: selections,
ticket: nil, trays: [])
service.configureBoundPanel(
printInfo, request: req, printer: nil)
let settings = PMTicketBridge.settings(printInfo)
XCTAssertEqual(
PMTicketBridge.stringValue(forKey: "PageSize", in: settings),
"A4")
XCTAssertEqual(
PMTicketBridge.stringValue(forKey: "EPIJ_FdSo", in: settings),
"Rear")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "MediaType", in: settings), "Photo")
}
/// First-ever open — no ticket, selections still land and the
/// post-selections validate keeps them readable.
func testNoTicketKeepsSelections() {
let printInfo = NSPrintInfo()
var selections = PrintPanelInitialSelections()
selections.paperSize = "A4"
selections.qualityKey = "EPIJ_Qual"
selections.quality = "303"
selections.orientation = "landscape"
service.configureBoundPanel(
printInfo,
request: request(ticket: nil, selections: selections),
printer: nil)
let settings = PMTicketBridge.settings(printInfo)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "PageSize", in: settings), "A4")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual", in: settings), "303")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "orientation-requested", in: settings), "4")
// The suppression writes still land on the ticket so the PDE
// sees the locked AP_* pair (③) and Quartz vocabulary (⑤′).
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "AP_ColorMatchingMode", in: settings),
"AP_ApplicationColorMatching")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "PMColorMatchingMode", in: settings),
"APCustomColorMatching")
}
}
@@ -50,7 +50,7 @@ final class PrintSessionViewModelTests: XCTestCase {
done done
if [ "$list" = "1" ]; then if [ "$list" = "1" ]; then
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal Custom.WIDTHxHEIGHT\\n' printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal Custom.WIDTHxHEIGHT\\n'
printf 'InputSlot/Media Source: Auto *Main Rear\\n' printf 'InputSlot/Media Source: Auto Main *Rear\\n'
printf 'MediaType/Media Type: *Stationery Glossy Matte\\n' printf 'MediaType/Media Type: *Stationery Glossy Matte\\n'
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\\n' printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\\n'
exit 0 exit 0
@@ -99,6 +99,10 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertEqual(workflow.pageSize, .a4) XCTAssertEqual(workflow.pageSize, .a4)
XCTAssertEqual(workflow.print.selectedPaperSize, 3) XCTAssertEqual(workflow.print.selectedPaperSize, 3)
// Tray seeds from the `*` default on caps load, not the first
// list element (#218).
XCTAssertEqual(workflow.print.selectedTray, 3)
XCTAssertEqual(workflow.print.selectedTrayToken, "Rear")
// Quality seeds from the `*` default on caps load. // Quality seeds from the `*` default on caps load.
XCTAssertEqual(workflow.print.selectedQuality, "303") XCTAssertEqual(workflow.print.selectedQuality, "303")
// All seven Epson codes enumerate in driver order (#180). // All seven Epson codes enumerate in driver order (#180).
@@ -209,6 +213,7 @@ final class PrintSessionViewModelTests: XCTestCase {
let log = await waitForFile(spoolLogURL) let log = await waitForFile(spoolLogURL)
XCTAssertTrue(log.contains("PageSize=Letter"), log) XCTAssertTrue(log.contains("PageSize=Letter"), log)
XCTAssertTrue(log.contains("EPIJ_Qual=301"), log) XCTAssertTrue(log.contains("EPIJ_Qual=301"), log)
XCTAssertTrue(log.contains("InputSlot=Rear"), log)
} }
/// #201 — the stubbed panel never produces a `PrintTicket`, so a /// #201 — the stubbed panel never produces a `PrintTicket`, so a
@@ -241,6 +246,7 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertTrue(log.contains("PageSize=A4"), log) XCTAssertTrue(log.contains("PageSize=A4"), log)
XCTAssertTrue(log.contains("EPIJ_Qual=303"), log) XCTAssertTrue(log.contains("EPIJ_Qual=303"), log)
XCTAssertTrue(log.contains("MediaType=Stationery"), log) XCTAssertTrue(log.contains("MediaType=Stationery"), log)
XCTAssertTrue(log.contains("InputSlot=Rear"), log)
XCTAssertTrue(log.contains("orientation-requested=3"), log) XCTAssertTrue(log.contains("orientation-requested=3"), log)
XCTAssertTrue(log.contains( XCTAssertTrue(log.contains(
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), log) "AP_ColorMatchingMode=AP_ApplicationColorMatching"), log)
@@ -307,6 +313,52 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertEqual(workflow.pageSize, .a4) XCTAssertEqual(workflow.pageSize, .a4)
} }
/// #218 — a captured `InputSlot=` (or any roster key) maps back to
/// `selectedTray` and populates `PrintOptions.paperSource`.
func testPanelResultAppliesBackTray() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "InputSlot=Auto", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
XCTAssertEqual(workflow.print.selectedTray, 3)
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.selectedTray, 1)
}
/// #218 — a captured tray token absent from the roster is appended
/// as a synthetic entry and selected.
func testPanelResultUnknownTrayAppended() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "InputSlot=OnlyInDialog", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
let originalCount = workflow.print.printerCaps.trays.count
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.printerCaps.trays.count,
originalCount + 1)
XCTAssertEqual(workflow.print.selectedTrayToken, "OnlyInDialog")
}
/// #186 — a captured `PageSize` token with no capability match /// #186 — a captured `PageSize` token with no capability match
/// leaves `selectedPaperSize` unchanged (never a guessed id). /// leaves `selectedPaperSize` unchanged (never a guessed id).
func testPanelResultUnknownPaperLeavesSelection() async throws { func testPanelResultUnknownPaperLeavesSelection() async throws {
@@ -408,6 +460,198 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty) XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty)
} }
// MARK: - Media-aware quality filtering (#214)
/// Constrains the loaded fixture caps: `Stationery` allows
/// {301,302,303,304}, `Glossy` allows {305,307}, `Matte` is
/// unconstrained. Mirrors the Epson `EPIJUIConstraint` matrix.
private func constrainCaps(_ vm: PrintSessionViewModel) {
var caps = vm.printerCaps
caps.qualityIDsByMediaType = [
"Stationery": ["301", "302", "303", "304"],
"Glossy": ["305", "307"],
]
vm.printerCaps = caps
}
/// Media switch → an invalid quality pick re-seeds to the first
/// allowed choice; the picker source shrinks to the media's set.
func testMediaChangeClampsInvalidQuality() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print)
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
XCTAssertEqual(workflow.print.selectedQuality, "303")
workflow.print.selectedMediaType = "Glossy"
XCTAssertEqual(workflow.print.selectedQuality, "305")
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
["305", "307"])
}
/// A still-valid pick survives a media switch; an unconstrained
/// media keeps the pick too.
func testMediaChangeKeepsValidQuality() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print)
workflow.print.selectedQuality = "302"
workflow.print.selectedMediaType = "Matte" // unconstrained
XCTAssertEqual(workflow.print.selectedQuality, "302")
workflow.print.selectedMediaType = "Glossy" // 302 invalid
XCTAssertEqual(workflow.print.selectedQuality, "305")
workflow.print.selectedQuality = "307"
workflow.print.selectedMediaType = "Stationery" // 307 invalid
// Driver default 303 is allowed there → preferred over first.
XCTAssertEqual(workflow.print.selectedQuality, "303")
}
/// #218 — a per-tray quality map is selected when the current tray
/// token has an entry; otherwise it falls back to the default map.
func testTraySpecificQualityMapSelected() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
var caps = workflow.print.printerCaps
caps.qualityIDsByMediaType = [
"Stationery": ["301", "302", "303"],
"Glossy": ["305", "307"],
]
caps.qualityIDsByMediaTypeByTray = [
"Auto": ["Stationery": ["301", "302"]],
"Rear": ["Stationery": ["301", "302", "303", "304"]],
]
workflow.print.printerCaps = caps
XCTAssertEqual(workflow.print.selectedTrayToken, "Rear")
// Non-empty tray map for Rear wins over the default media map.
XCTAssertEqual(workflow.print.availableQualities.map { $0.id },
["301", "302", "303", "304"])
workflow.print.selectedTray = 1 // Auto
XCTAssertEqual(workflow.print.availableQualities.map { $0.id },
["301", "302"])
workflow.print.selectedTray = 999 // invalid -> fallback
XCTAssertEqual(workflow.print.availableQualities.map { $0.id },
["301", "302", "303"])
}
/// Seeding lands inside the allowed set even when the driver
/// default quality is invalid for the first media — end to end
/// through `CupsService.capabilities(for:)` + a fixture
/// `PDEData.dat` (#214).
func testQualitySeedRespectsMediaMap() async throws {
let ppdDir = env.root.appendingPathComponent("ppd")
let epsonRoot = env.root.appendingPathComponent("epson-driver")
let datDir = epsonRoot.appendingPathComponent(
"Machine/M.data/Contents/Resources")
try FileManager.default.createDirectory(
at: ppdDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: datDir, withIntermediateDirectories: true)
try """
*EPIJDriverBasePath: "\(epsonRoot.path)"
*EPIJMachineBundleName: "M.data"
""".write(
to: ppdDir.appendingPathComponent("Mock_Q.ppd"),
atomically: true, encoding: .utf8)
// Stationery forbids everything except 305/307 — including the
// `lpoptions` default 303.
try """
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 301
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 302
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 303
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 308
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 304
""".write(
to: datDir.appendingPathComponent("PDEData.dat"),
atomically: true, encoding: .utf8)
var environment = env.environment
environment = AppEnvironment(
stateStore: environment.stateStore,
settingsStore: environment.settingsStore,
presetStore: environment.presetStore,
runner: environment.runner,
cupsService: CupsService(
processManager: ProcessManager(),
binaryDir: binDir, ppdDir: ppdDir),
historyStore: environment.historyStore,
mediaStore: environment.mediaStore,
recentProjectsStore: environment.recentProjectsStore)
let workflow = TargetWorkflowViewModel(environment: environment)
workflow.print.selectedPrinter = "Mock_Q"
await workflow.print.reloadSelectedCapabilities()
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
XCTAssertEqual(workflow.print.printerCaps
.qualityIDsByMediaType["Stationery"], ["305", "307"])
// 303 is the driver default but invalid on Stationery → 305.
XCTAssertEqual(workflow.print.selectedQuality, "305")
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
["305", "307"])
}
/// No constraint map → every quality stays selectable on every
/// media (the pre-#214 behaviour, by design for unknown drivers).
func testUnconstrainedDriverKeepsAllQualities() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
workflow.print.selectedMediaType = "Glossy"
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
["301", "302", "303", "308", "304", "305", "307"])
XCTAssertEqual(workflow.print.selectedQuality, "303")
}
/// A captured quality invalid for the captured media is dropped —
/// the clamped selection stands.
func testPanelResultInvalidQualityDropped() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "EPIJ_Qual=305", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print) // Stationery forbids 305
XCTAssertEqual(workflow.print.selectedQuality, "303")
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.selectedQuality, "303")
}
/// `makeRequest` substitutes a stale-invalid quality before the
/// ticket is written — the last gate before the driver (#214).
func testSpoolSubstitutesStaleQuality() async throws {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print)
workflow.print.selectedMediaType = "Glossy"
workflow.print.selectedQuality = "308" // stale, invalid
workflow.print.spooler = RecordingTargetSpooler(
logURL: spoolLogURL)
let tiff = env.root.appendingPathComponent("page1.tif")
try Data([0x49, 0x49]).write(to: tiff)
let page = GalleryPage(
index: 0,
page: PrinttargPage(
filename: "page1.tif", patches: 10,
widthMm: 210, heightMm: 297),
fileURL: tiff, previewPNG: nil, previewError: nil)
workflow.print.printPage(page)
let log = await waitForFile(spoolLogURL)
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
XCTAssertFalse(log.contains("EPIJ_Qual=308"), log)
}
/// Poll until the panel task posts a notice whose text contains /// Poll until the panel task posts a notice whose text contains
/// `fragment` (the Task-completion signal for `nil` results too). /// `fragment` (the Task-completion signal for `nil` results too).
private func waitForNotice( private func waitForNotice(
@@ -4,7 +4,7 @@ import Foundation
import XCTest import XCTest
@testable import ICCery @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 / /// snapped top-left anchoring, and the draw-time interpolation /
/// antialias contract asserted via `drawProbe`. /// antialias contract asserted via `drawProbe`.
@MainActor @MainActor
@@ -35,11 +35,13 @@ final class TargetCanvasGeometryTests: XCTestCase {
// MARK: - Page stacking // 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( let view = TargetPageCanvasView(
pages: [raster(), raster(), raster()], pages: [raster(), raster(), raster()],
paperSize: paperSize) paperSize: paperSize)
XCTAssertTrue(view.isFlipped) XCTAssertFalse(view.isFlipped)
let page1 = view.rectForPage(1) let page1 = view.rectForPage(1)
let page3 = view.rectForPage(3) let page3 = view.rectForPage(3)
@@ -68,18 +70,33 @@ final class TargetCanvasGeometryTests: XCTestCase {
pages: [raster(), raster()], pages: [raster(), raster()],
paperSize: paperSize) 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) let first = view.destinationRect(forPage: 1)
XCTAssertEqual(first.origin.x, 0, accuracy: 0.001) 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.width, 595.2, accuracy: 0.01)
XCTAssertEqual(first.height, 841.92, accuracy: 0.01) XCTAssertEqual(first.height, 841.92, accuracy: 0.01)
let second = view.destinationRect(forPage: 2) let second = view.destinationRect(forPage: 2)
XCTAssertEqual( 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) 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( XCTAssertEqual(
rect.origin.x * 1000, rect.origin.x * 1000,
(rect.origin.x * 1000).rounded(), (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` /// colour space for 1/3/4 components at 8 or 16 bpc. `alpha: true`
/// appends one alpha sample per pixel (`last`). `pixelBytes` is the /// appends one alpha sample per pixel (`last`). `pixelBytes` is the
/// repeating per-pixel pattern (big-endian for 16 bpc). /// 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( static func makeImage(
px: CGSize, px: CGSize,
components: Int, components: Int,
bitsPerComponent: Int, bitsPerComponent: Int,
alpha: Bool = false, alpha: Bool = false,
pixelBytes: [UInt8]? = nil pixelBytes: [UInt8]? = nil,
bottomHalfPixelBytes: [UInt8]? = nil
) -> CGImage? { ) -> CGImage? {
let width = Int(px.width) let width = Int(px.width)
let height = Int(px.height) let height = Int(px.height)
@@ -40,19 +44,31 @@ enum TargetTestFixtures {
let samples = components + (alpha ? 1 : 0) let samples = components + (alpha ? 1 : 0)
let bytesPerPixel = samples * bytesPerComponent let bytesPerPixel = samples * bytesPerComponent
let bytesPerRow = width * bytesPerPixel let bytesPerRow = width * bytesPerPixel
let pattern = pixelBytes func buildRow(_ pattern: [UInt8]) -> [UInt8] {
?? Array(0..<bytesPerPixel).map { UInt8(($0 * 37 + 11) & 0xff) }
var row = [UInt8](repeating: 0, count: bytesPerRow) var row = [UInt8](repeating: 0, count: bytesPerRow)
for offset in stride(from: 0, to: bytesPerRow, by: pattern.count) { for offset in stride(
from: 0, to: bytesPerRow, by: pattern.count) {
for (index, byte) in pattern.enumerated() for (index, byte) in pattern.enumerated()
where offset + index < bytesPerRow { where offset + index < bytesPerRow {
row[offset + index] = byte 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]() var bytes = [UInt8]()
bytes.reserveCapacity(bytesPerRow * height) bytes.reserveCapacity(bytesPerRow * height)
for _ in 0..<height { for rowIndex in 0..<height {
bytes.append(contentsOf: row) bytes.append(
contentsOf: rowIndex < height / 2 ? topRow : bottomRow)
} }
let data = Data(bytes) let data = Data(bytes)
@@ -88,6 +104,7 @@ enum TargetTestFixtures {
/// Writes `makeImage` output to a single-page TIFF. `dpi` nil /// Writes `makeImage` output to a single-page TIFF. `dpi` nil
/// produces a TIFF with no resolution tags (the 72-fallback path). /// produces a TIFF with no resolution tags (the 72-fallback path).
/// `bottomHalfPixelBytes` passes through to `makeImage` (#211).
static func makeTIFF( static func makeTIFF(
px: CGSize, px: CGSize,
dpi: Double?, dpi: Double?,
@@ -95,12 +112,14 @@ enum TargetTestFixtures {
bitsPerComponent: Int = 8, bitsPerComponent: Int = 8,
alpha: Bool = false, alpha: Bool = false,
pixelBytes: [UInt8]? = nil, pixelBytes: [UInt8]? = nil,
bottomHalfPixelBytes: [UInt8]? = nil,
in directory: URL in directory: URL
) throws -> URL { ) throws -> URL {
guard let image = makeImage( guard let image = makeImage(
px: px, components: components, px: px, components: components,
bitsPerComponent: bitsPerComponent, bitsPerComponent: bitsPerComponent,
alpha: alpha, pixelBytes: pixelBytes) alpha: alpha, pixelBytes: pixelBytes,
bottomHalfPixelBytes: bottomHalfPixelBytes)
else { throw FixtureError.imageNotCreated } else { throw FixtureError.imageNotCreated }
let url = directory.appendingPathComponent( let url = directory.appendingPathComponent(
"fixture-\(UUID().uuidString).tiff") "fixture-\(UUID().uuidString).tiff")
@@ -2,9 +2,9 @@ import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Issue #201 Phase 4 — the resolved ticket-write list that replaces /// Issue #201 Phase 4 / #218 — the resolved ticket-write list that
/// the `lp` argv goldens: locked order 1–10, D6 (Stage 2 always /// replaces the `lp` argv goldens: locked order 1–11, D6 (Stage 2
/// wins), `raw` can never appear. /// always wins), `raw` can never appear.
final class TicketWriteResolverTests: XCTestCase { final class TicketWriteResolverTests: XCTestCase {
private func resolve( private func resolve(
@@ -63,6 +63,7 @@ final class TicketWriteResolverTests: XCTestCase {
overrides: TargetPrintOverrides( overrides: TargetPrintOverrides(
paperSize: "raw", mediaType: "raw", paperSize: "raw", mediaType: "raw",
qualityKey: "raw", quality: "raw", qualityKey: "raw", quality: "raw",
trayKey: "raw", tray: "raw",
orientation: "raw"), orientation: "raw"),
optionKeys: optionKeys) optionKeys: optionKeys)
XCTAssertFalse(r.writes.contains { XCTAssertFalse(r.writes.contains {
@@ -144,8 +145,8 @@ final class TicketWriteResolverTests: XCTestCase {
XCTAssertNil(value("orientation-requested", in: resolve())) XCTAssertNil(value("orientation-requested", in: resolve()))
} }
/// A `nil` override emits no write for that key; quality needs /// A `nil` override emits no write for that key; quality and tray
/// **both** the detected key and the value. /// need **both** the detected key and the value.
func testNilOverridesEmitNoWrite() { func testNilOverridesEmitNoWrite() {
let r = resolve(optionKeys: ["MediaType", "EPIJ_Qual"]) let r = resolve(optionKeys: ["MediaType", "EPIJ_Qual"])
XCTAssertNil(value("PageSize", in: r)) XCTAssertNil(value("PageSize", in: r))
@@ -168,14 +169,42 @@ final class TicketWriteResolverTests: XCTestCase {
overrides: TargetPrintOverrides(paperSize: "")) overrides: TargetPrintOverrides(paperSize: ""))
XCTAssertNil(value("PageSize", in: emptyPaper)) XCTAssertNil(value("PageSize", in: emptyPaper))
XCTAssertNil(emptyPaper.paperToken) XCTAssertNil(emptyPaper.paperToken)
// Tray: key or value alone, or an empty value, emits no write.
let trayKeyOnly = resolve(
overrides: TargetPrintOverrides(trayKey: "EPIJ_FdSo"),
optionKeys: ["EPIJ_FdSo"])
XCTAssertNil(value("EPIJ_FdSo", in: trayKeyOnly))
let trayValueOnly = resolve(
overrides: TargetPrintOverrides(tray: "Rear"),
optionKeys: ["EPIJ_FdSo"])
XCTAssertNil(value("EPIJ_FdSo", in: trayValueOnly))
let emptyTray = resolve(
overrides: TargetPrintOverrides(trayKey: "EPIJ_FdSo", tray: ""),
optionKeys: ["EPIJ_FdSo"])
XCTAssertNil(value("EPIJ_FdSo", in: emptyTray))
} }
/// The write list equals the locked 1–10 order, exactly. /// #218 paper source is emitted with its canonical key and the raw
/// token; the display label must never reach the ticket.
func testTrayWrite() {
let r = resolve(
overrides: TargetPrintOverrides(
trayKey: "EPIJ_FdSo", tray: "Rear"),
optionKeys: ["EPIJ_FdSo"])
XCTAssertEqual(value("EPIJ_FdSo", in: r), "Rear")
XCTAssertEqual(r.writes.first { $0.key == "EPIJ_FdSo" }?.locked,
false)
XCTAssertNil(value("InputSlot", in: r))
}
/// The write list equals the locked 1–11 order, exactly.
func testLockedWriteOrder() { func testLockedWriteOrder() {
let r = resolve( let r = resolve(
overrides: TargetPrintOverrides( overrides: TargetPrintOverrides(
paperSize: "A4", mediaType: "Photo", paperSize: "A4", mediaType: "Photo",
qualityKey: "EPIJ_Qual", quality: "305", qualityKey: "EPIJ_Qual", quality: "305",
trayKey: "EPIJ_FdSo", tray: "Rear",
orientation: "landscape"), orientation: "landscape"),
optionKeys: ["MediaType", "EPIJ_Qual", "EPIJ_CMat"]) optionKeys: ["MediaType", "EPIJ_Qual", "EPIJ_CMat"])
XCTAssertEqual(keys(r), [ XCTAssertEqual(keys(r), [
@@ -185,6 +214,7 @@ final class TicketWriteResolverTests: XCTestCase {
"PMCustomColorMatchingProfile", "PMCustomColorMatchingProfile",
"com.apple.print.PrintSettings.PMColorMatchingMode", "com.apple.print.PrintSettings.PMColorMatchingMode",
"PageSize", "PageSize",
"EPIJ_FdSo",
"MediaType", "MediaType",
"EPIJ_Qual", "EPIJ_Qual",
"EPIJ_CMat", "EPIJ_CMat",
+2 -2
View File
@@ -15,12 +15,12 @@ for arg in "$@"; do
done done
if [ "$list" = "1" ]; then if [ "$list" = "1" ]; then
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n' printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n'
printf 'InputSlot/Media Source: Auto *Main Rear\n' printf 'EPIJ_FdSo/Paper Source: Auto *Main Rear\n'
printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n' printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n'
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n' printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n'
printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n' printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n'
printf 'ColorModel/Output Mode: *RGB Gray\n' printf 'ColorModel/Output Mode: *RGB Gray\n'
exit 0 exit 0
fi fi
printf "printer-info='Mock %s' printer-type=42\n" "$queue" printf "printer-info='Mock %s' printer-make-and-model='EPSON XP-55 Series' printer-type=42\n" "$queue"
exit 0 exit 0
+6
View File
@@ -12,6 +12,12 @@ case "$1" in
-d) -d)
printf 'system default destination: Mock_Epson_7450\n' printf 'system default destination: Mock_Epson_7450\n'
;; ;;
-v)
# Mock_Canon_Pro is the AirPrint fixture queue (#202): local
# unencrypted ipp:// resolved via the _universal mDNS subtype.
printf 'device for Mock_Epson_7450: usb://EPSON/XP-55%%20Series?serial=MOCK123\n'
printf 'device for Mock_Canon_Pro: ipp://Mock%%20Canon%%20Pro._universal._sub._ipp._tcp.local./\n'
;;
*) *)
exit 1 exit 1
;; ;;
@@ -124,8 +124,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
XCTAssertTrue(element("printerSelect").exists) XCTAssertTrue(element("printerSelect").exists)
XCTAssertTrue(element("printerTraySelect").exists) XCTAssertTrue(element("printerTraySelect").exists)
XCTAssertTrue(element("printerMediaTypeSelect").exists) XCTAssertTrue(element("printerMediaTypeSelect").exists)
XCTAssertTrue(element("btnOrientPortrait").exists) XCTAssertTrue(element("printerOrientationSelect").exists)
XCTAssertTrue(element("btnOrientLandscape").exists)
XCTAssertTrue(element("btnPrinterProperties").exists) XCTAssertTrue(element("btnPrinterProperties").exists)
} }
@@ -251,6 +250,87 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log) XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
} }
/// #214 — a fixture PPD pointing at a fixture `PDEData.dat` (via
/// `ICCERY_CUPS_PPD_DIR`) constrains the quality picker to the
/// media's allowed set; switching media re-filters and clamps the
/// selection.
func testQualityPickerFiltersByMediaConstraints() throws {
let ppdDir = testRoot.appendingPathComponent("ppd")
let epsonRoot = testRoot.appendingPathComponent("epson-driver")
let datDir = epsonRoot.appendingPathComponent(
"Machine/M.data/Contents/Resources")
try FileManager.default.createDirectory(
at: ppdDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: datDir, withIntermediateDirectories: true)
try """
*EPIJDriverBasePath: "\(epsonRoot.path)"
*EPIJMachineBundleName: "M.data"
""".write(
to: ppdDir.appendingPathComponent("Mock_Epson_7450.ppd"),
atomically: true, encoding: .utf8)
try """
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 305
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 307
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 308
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 301
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 302
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 303
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 304
""".write(
to: datDir.appendingPathComponent("PDEData.dat"),
atomically: true, encoding: .utf8)
app.launchEnvironment["ICCERY_CUPS_PPD_DIR"] = ppdDir.path
launchAppWithDefaults()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
let qualityPopup = app.popUpButtons["printerQualitySelect"]
XCTAssertTrue(qualityPopup.waitForExistence(timeout: 10))
// Default media Stationery → {301,302,303,304} only.
qualityPopup.click()
let stationeryExpected = ["301", "302", "303", "304"]
for token in stationeryExpected {
XCTAssertTrue(
app.menuItems[token].waitForExistence(timeout: 5),
"Missing quality menu item \(token)")
}
XCTAssertFalse(app.menuItems["307"].exists)
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
// Switch to PhotographicGlossy → {305,307,308} in driver
// order (308 first — lpoptions order), selection clamped.
let mediaPopup = element("printerMediaTypeSelect")
.descendants(matching: .popUpButton).firstMatch
XCTAssertTrue(mediaPopup.waitForExistence(timeout: 5))
mediaPopup.click()
let glossyItem = app.menuItems["PhotographicGlossy"]
XCTAssertTrue(glossyItem.waitForExistence(timeout: 5))
glossyItem.click()
qualityPopup.click()
let glossyExpected = ["308", "305", "307"]
for token in glossyExpected {
XCTAssertTrue(
app.menuItems[token].waitForExistence(timeout: 5),
"Missing quality menu item \(token)")
}
let titles = app.menuItems.allElementsBoundByIndex
.map(\.title)
.filter { glossyExpected.contains($0) }
XCTAssertEqual(titles, glossyExpected)
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
// The stale pick (303) clamped to the first allowed token.
let deadline = Date().addingTimeInterval(5)
while Date() < deadline,
selection(of: "printerQualitySelect") != "308" {
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
XCTAssertEqual(selection(of: "printerQualitySelect"), "308")
}
/// #201 D5 — per-page mode records one line per page; the /// #201 D5 — per-page mode records one line per page; the
/// `chkSingleSpoolJob` toggle collapses the job into a single /// `chkSingleSpoolJob` toggle collapses the job into a single
/// request logged once with `pages=N`. /// request logged once with `pages=N`.
@@ -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")
}
}
+20 -31
View File
@@ -114,18 +114,18 @@ final class Milestone3UITests: XCTestCase {
return recordedSpoolLog() return recordedSpoolLog()
} }
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button /// Scrolls `stage-2` with the synthesized scroll wheel so
/// moves up, clear of the Dock collision zone at the window's /// `identifier`'s button moves up, clear of the Dock collision
/// bottom edge (#132). /// zone at the window's bottom edge (#132).
/// ///
/// macOS overlay scrollbars are not in the AX tree — never use /// macOS overlay scrollbars are not in the AX tree — never use
/// `app.scrollBars` — and a synthesized scroll wheel is inert on /// `app.scrollBars` — and a click-drag does not scroll a macOS
/// this LazyVGrid, so the scroll is a real drag on the gallery /// ScrollView (content-drag scrolling is iOS-only); the scroll
/// cell's content. A stale/off-screen AX frame resolves to a screen /// wheel is the mechanism the platform supports (#215). A
/// point that can be a Dock icon — a coordinate click there once /// stale/off-screen AX frame resolves to a screen point that can
/// opened Calendar instead of Print. Callers must click only when /// be a Dock icon — a coordinate click there once opened Calendar
/// the returned element `isHittable`; never coordinate-click a /// instead of Print. Callers must click only when the returned
/// stale frame. /// element `isHittable`; never coordinate-click a stale frame.
@discardableResult @discardableResult
private func scrollStage2UntilHittable( private func scrollStage2UntilHittable(
_ identifier: String, _ identifier: String,
@@ -134,6 +134,8 @@ final class Milestone3UITests: XCTestCase {
var button = app.buttons[identifier] var button = app.buttons[identifier]
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0") XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0")
let scrollView = app.scrollViews["stage-2"]
XCTAssertTrue(scrollView.waitForExistence(timeout: 10), "stage-2")
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
@@ -142,25 +144,12 @@ final class Milestone3UITests: XCTestCase {
button.frame.maxY < windowBottom - 80 { button.frame.maxY < windowBottom - 80 {
return button return button
} }
// Grab the upper half of the cell (the TIFF, not the Print // Wheel-down inside the stage-2 viewport: content moves
// button / Dock) and drag toward the top of the window. // UP ⇒ Print leaves the Dock zone. Negative deltaY scrolls
// Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves // toward the document bottom (#215).
// the Dock zone.
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: scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.55)) CGVector(dx: 0.5, dy: 0.5))
.press(forDuration: 0.15, thenDragTo: .scroll(byDeltaX: 0, deltaY: -60)
scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.15)))
}
RunLoop.current.run(until: Date().addingTimeInterval(0.4)) RunLoop.current.run(until: Date().addingTimeInterval(0.4))
button = app.buttons[identifier] button = app.buttons[identifier]
} }
@@ -183,8 +172,7 @@ final class Milestone3UITests: XCTestCase {
.waitForExistence(timeout: 10)) .waitForExistence(timeout: 10))
XCTAssertTrue(element("printerTraySelect").exists) XCTAssertTrue(element("printerTraySelect").exists)
XCTAssertTrue(element("printerMediaTypeSelect").exists) XCTAssertTrue(element("printerMediaTypeSelect").exists)
XCTAssertTrue(element("btnOrientPortrait").exists) XCTAssertTrue(element("printerOrientationSelect").exists)
XCTAssertTrue(element("btnOrientLandscape").exists)
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled) XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
} }
@@ -213,7 +201,7 @@ final class Milestone3UITests: XCTestCase {
func testPanelSelectionsApplyBackToSpoolWrites() throws { func testPanelSelectionsApplyBackToSpoolWrites() throws {
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
"InputSlot=Rear MediaType=PhotographicGlossy" "EPIJ_FdSo=Rear MediaType=PhotographicGlossy"
launchApp() launchApp()
reachPrintPanel() reachPrintPanel()
_ = waitFor("printerStatusBadge") _ = waitFor("printerStatusBadge")
@@ -231,6 +219,7 @@ final class Milestone3UITests: XCTestCase {
XCTAssertTrue(log.contains( XCTAssertTrue(log.contains(
"AP.ColorMatchingMode=AP_ApplicationColorMatching"), log) "AP.ColorMatchingMode=AP_ApplicationColorMatching"), log)
XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log) XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log)
XCTAssertTrue(log.contains("EPIJ_FdSo=Rear"), log)
// Detected bypass for the mock queue (EPIJ_CMat present in // Detected bypass for the mock queue (EPIJ_CMat present in
// lpoptions -l) is always written. // lpoptions -l) is always written.
XCTAssertTrue(log.contains("EPIJ_CMat=3"), log) XCTAssertTrue(log.contains("EPIJ_CMat=3"), log)
+8 -4
View File
@@ -43,8 +43,9 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor
| Field | Type | Meaning | | Field | Type | Meaning |
|-------|------|---------| |-------|------|---------|
| `id` | `u16` | Windows: Win32 bin ID from `DC_BINS`. Unix: 1-based index of the `InputSlot` / `MediaSource` value (not a PPD code). | | `id` | `u16` | Windows: Win32 bin ID from `DC_BINS`. Unix/macOS: 1-based index of the detected tray listing. |
| `name` | `String` | Windows: 24-WCHAR `DC_BINNAMES` string, or `"Tray {id}"` if empty. Unix: PPD choice with leading `*` stripped (e.g. `Auto`, `Upper`). | | `token` | `String` | **v2** (#218): the exact CUPS/PPD machine value (`Auto`, `Rear`, `1`, ...). |
| `name` | `String` | Windows: 24-WCHAR `DC_BINNAMES` string, or `"Tray {id}"` if empty. Unix/macOS: PPD-enriched human label; if no PPD label is available, the raw token is used for display. |
### `PrinterPaperSize` (mod.rs:22–25) ### `PrinterPaperSize` (mod.rs:22–25)
@@ -71,11 +72,14 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor
| `media_types` | `Vec<PrinterMediaType>` | `#[serde(default)]`. Frontend fills `#printerMediaTypeSelect`. | | `media_types` | `Vec<PrinterMediaType>` | `#[serde(default)]`. Frontend fills `#printerMediaTypeSelect`. |
| `supports_orientation` | `bool` | Always `true` on both Windows and Unix. | | `supports_orientation` | `bool` | Always `true` on both Windows and Unix. |
### `PrintOptions` (mod.rs:42–55) — `Default` + `PartialEq + Eq` ### `PrintOptions` (mod.rs:42–55) — `Default` + `PartialEq + Eq` — historical v1
> The `lp`/`-o` columns below describe the v0.8.5 spool contract. v2.0
> replaced the macOS `lp` path with a headless `NSPrintOperation` (#201).
| Field | Type | Windows | Linux | macOS | | Field | Type | Windows | Linux | macOS |
|-------|------|---------|-------|-------| |-------|------|---------|-------|-------|
| `paper_source` | `Option<u16>` | `dmDefaultSource` (tray id) | **ignored** (unix `build_lp_args` never emits `InputSlot`) | **ignored** unless present in captured `cups_options` | | `paper_source` | `Option<u16>` | `dmDefaultSource` (tray id) | **ignored** (unix `build_lp_args` never emits `InputSlot`) | v2 (#218): 1-based tray id driven by the Stage 2 picker; written to the detected tray key (`InputSlot`/`MediaSource`/`EPIJ_FdSo`/`EPIJ_PSrc`/`CNIJMediaSupply`) on the native ticket. |
| `orientation` | `Option<String>` | `"landscape"` → `DMORIENT_LANDSCAPE` (2), else portrait (1) | `-o orientation-requested=4` (landscape) or `=3` (portrait) | same, skipped if `cups_options` already has the key | | `orientation` | `Option<String>` | `"landscape"` → `DMORIENT_LANDSCAPE` (2), else portrait (1) | `-o orientation-requested=4` (landscape) or `=3` (portrait) | same, skipped if `cups_options` already has the key |
| `paper_size` | `Option<String>` | **ignored** | `-o PageSize=<value>` | same, skipped if `pagesize` already added | | `paper_size` | `Option<String>` | **ignored** | `-o PageSize=<value>` | same, skipped if `pagesize` already added |
| `media_type` | `Option<String>` | parsed as `u32` → `dmMediaType` | `-o MediaType=<value>` (generic key only) | uses `detect_media_type_key` (`CNIJMediaType` / `EPIJ_Medi` / `StpMediaType` / `MediaType`); skipped if any of those keys already in `cups_options` | | `media_type` | `Option<String>` | parsed as `u32` → `dmMediaType` | `-o MediaType=<value>` (generic key only) | uses `detect_media_type_key` (`CNIJMediaType` / `EPIJ_Medi` / `StpMediaType` / `MediaType`); skipped if any of those keys already in `cups_options` |
+97 -48
View File
@@ -19,7 +19,7 @@ This is the most implementation-sensitive chapter. A rewrite that opens System S
- `NSWorkspace` open of the printer - `NSWorkspace` open of the printer
- `lpoptions` GUI - `lpoptions` GUI
Linux/Windows do not share this panel. Default button title is **"Use Settings"** (macos.rs:439) — this is a settings-capture dialog, not a print-now dialog. Actual spooling is a later `lp` invocation. Linux/Windows do not share this panel. Default button title is **"Use Settings"** (macos.rs:439) — this is a settings-capture dialog, not a print-now dialog. Actual spooling is a later, separate step — in v2.0 a headless `NSPrintOperation` (#201; v1 used `lp`).
Must run on the Cocoa main thread. `show_printer_properties` (macos.rs:689-710): Must run on the Cocoa main thread. `show_printer_properties` (macos.rs:689-710):
@@ -68,34 +68,31 @@ if pm_printer was created from ID:
`PMPrinter` from `PMPrinterCreateFromPrinterID` is released with `PMRelease` on all exit paths (cancel, error, success). The NSPrinter fallback path leaves `pm_printer` null so no release. `PMPrinter` from `PMPrinterCreateFromPrinterID` is released with `PMRelease` on all exit paths (cancel, error, success). The NSPrinter fallback path leaves `pm_printer` null so no release.
### ColorSync suppression strategy — UI click to `lp` ### ColorSync suppression strategy — UI click to `NSPrintOperation`
End-to-end, **six independent layers**. All of them exist because no single Apple API is sufficient across Epson PDE / Canon PDE / `cgpdftoraster` / CUPS. End-to-end, **seven independent layers**. All of them exist because no single Apple API is sufficient across Epson PDE / Canon PDE / `cgpdftoraster` / CUPS. v2.0 (#201) replaced the `lp` tail with a headless `NSPrintOperation` that replays a captured `PMPrintSettings`/`PMPageFormat` ticket — layer ⑦ is new.
``` ```
[Preferences click] [Preferences click]
show_printer_properties PrintSessionViewModel.openPrinterPreferences
run_on_main_thread PrintPanelService.showProperties
run_native_print_panel runNativePanel
① PMSessionSetCurrentPMPrinter bind queue ① PMPrinterCreateFromPrinterID + PMSessionSetCurrentPMPrinter bind queue
② set_session_color_matching_mode SPI gray out PDE Color Matching ② set_session_color_matching_mode SPI gray out PDE Color Matching
③ PMPrintSettingsSetValue AP_ColorMatchingMode + dotted ③ PMPrintSettingsSetValue AP_ColorMatchingMode + dotted
④ detect_driver_color_bypass → SetValue pre-select Canon/Epson/Gutenprint "off" ④ detect_driver_color_bypass → SetValue pre-select Canon/Epson/Gutenprint "off"
⑤ NSPrintInfo.printSettings dictionary same keys for AppKit PDEs ⑤ NSPrintInfo.printSettings dictionary same keys for AppKit PDEs
NSPrintPanel.runModalWithPrintInfo ⑤′ ColorSyncSuppressor.applyQuartzMode PMColorMatchingMode + legacy + nested mirror
NSPrintPanel.runModal("Use Settings")
user picks media / quality (color locked) user picks media / quality (color locked)
⑥ PMPrintSettingsToOptions → filter → PrintPropertiesResult ⑥ PMPrintSettingsToOptions → filter → Stage 2 mirror
⑦ PMTicketBridge.serialise(printInfo) → PrintTicket ticket capture
[frontend] [frontend]
capturedCupsOptions[printer] = cups_options capturedCupsOptions[queue] = mirror + capturedTickets[queue] = ticket
[Print Target] [Print Target]
print_target_native → macos::print_target → build_lp_args PrintSessionViewModel → TargetPrintRequest → NativeTargetSpooler.spool
ALWAYS -o AP_ColorMatchingMode=AP_ApplicationColorMatching restore ticket → Stage 2 writes → NSPrintOperation.runOperation
ALWAYS -o AP.ColorMatchingMode=AP_ApplicationColorMatching (S1–S14 below — 1:1, device colour space, panels off)
THEN captured cups_options as -o k=v
THEN media_type if not already present (detected key)
THEN detect_driver_color_bypass if no color-bypass key yet
THEN orientation / PageSize if not already present
lp -d <queue> -t "ICCery Target - …" … <tiff>
``` ```
Linux uses `-o raw` instead of AP_* flags. macOS **does not** use `-o raw`: a raw queue would skip the raster filter that actually understands `AP_ColorMatchingMode`. The macOS strategy is "tell the filter the application already matched color", not "skip the filter". Linux uses `-o raw` instead of AP_* flags. macOS **does not** use `-o raw`: a raw queue would skip the raster filter that actually understands `AP_ColorMatchingMode`. The macOS strategy is "tell the filter the application already matched color", not "skip the filter".
@@ -161,7 +158,7 @@ AGENTS.md:118, macos.rs:82-88:
| `AP_ColorSyncMatching` | **Avoided.** ColorSync applies the printer/display profile. Patches become color-managed. | | `AP_ColorSyncMatching` | **Avoided.** ColorSync applies the printer/display profile. Patches become color-managed. |
| `AP_VendorColorMatching` | **Avoided.** Epson/Canon driver color engine (ICM inside the PDE). Same corruption. | | `AP_VendorColorMatching` | **Avoided.** Epson/Canon driver color engine (ICM inside the PDE). Same corruption. |
ICCery-CPU uses a **different** vocabulary (`APCustomColorMatching` / `APColorSync` / `APPrinterExtension` on `PMColorMatchingMode`). See the CPU section. Do not mix the two dictionaries. ICCery-CPU uses a **different** vocabulary (`APCustomColorMatching` / `APColorSync` / `APPrinterExtension` on `PMColorMatchingMode`). See the CPU section. v1 kept the two dictionaries on separate paths (`lp` vs Quartz); v2.0 (#201, D2) has a single native path that writes **both** vocabularies — layer ⑤′ below.
### Layer ③ — `PMPrintSettingsSetValue` (macos.rs:357-375) ### Layer ③ — `PMPrintSettingsSetValue` (macos.rs:357-375)
@@ -204,6 +201,18 @@ print_settings.insert(<bypass_key>, <bypass_val>)
`NSString` is transmuted to `&AnyObject` for the dictionary (`macos.rs:414-416`). `NSString` is transmuted to `&AnyObject` for the dictionary (`macos.rs:414-416`).
### Layer ⑤′ — Quartz vocabulary (v2.0, #201 D2)
With `lp` gone there is a single native path, and it carries **both**
dictionaries. `ColorSyncSuppressor.applyQuartzMode` additionally writes the
Quartz/`NSPrintOperation` vocabulary (docs/14 §7):
- `PMColorMatchingMode` = `APCustomColorMatching`
- `PMCustomColorMatchingProfile` = `""`
- `com.apple.print.PrintSettings.PMColorMatchingMode` (legacy)
- the same keys inside the nested `com.apple.print.printSettings`
sub-dictionary of `printInfo.dictionary()`
### Panel options (macos.rs:434-449) ### Panel options (macos.rs:434-449)
``` ```
@@ -227,17 +236,36 @@ After OK:
4. Copy via `CStr`, `free(opts_ptr)`. 4. Copy via `CStr`, `free(opts_ptr)`.
5. `filter_cups_options_string` → `cups_options`. 5. `filter_cups_options_string` → `cups_options`.
6. `extract_media_type_from_options` → `media_type`. 6. `extract_media_type_from_options` → `media_type`.
7. Return `PrintPropertiesResult { selected_printer, options: PrintOptions { media_type, cups_options, ppd_uncorrected_passthrough: Some(true), ..Default } }`. 7. `extract_tray_from_options` → the selected tray token and a 1-based `paper_source` id when it matches the roster.
8. Return `PrintPropertiesResult { selected_printer, options: PrintOptions { media_type, cups_options, paper_source, ppd_uncorrected_passthrough: Some(true), ..Default } }`.
Failure of `PMPrintSettingsToOptions` is a hard `Err`. Failure of `PMPrintSettingsToOptions` is a hard `Err`.
### Layer ⑦ — ticket serialise/restore (v2.0, #201 D3)
The layer-⑥ flattening is what lost the ticket (#201 root cause 1): it kept
only a `key=value` string and `CupsOptionsFilter` drops every `com.apple.*`
key, so `com.apple.print.PrintSettings` never survived. After the layer-⑥
capture, `PMTicketBridge.serialise(printInfo, queue:)` now snapshots the
whole ticket:
1. `PMPrintSettingsCreateDataRepresentation(settings, &data, kPMDataFormatXMLDefault)` → `PrintTicket.printSettings`.
2. `PMPageFormatCreateDataRepresentation` → `PrintTicket.pageFormat`.
3. Binary-plist snapshot of `NSPrintInfo.dictionary()`, plist-filtered → `PrintTicket.printInfoPlist` — fallback only, never the primary restore path.
`PrintTicket` is in-memory, session-only, keyed by queue. On spool,
`PMTicketBridge.restore` replays it into the job's `NSPrintInfo`:
`PM*CreateWithDataRepresentation` → `PMCopy*` → `PMSessionValidate*` →
`updateFromPM*`. A ticket captured for queue A is never replayed onto
queue B.
### `RELEVANT_CUPS_OPTION_KEYS` (macos.rs:142-174) ### `RELEVANT_CUPS_OPTION_KEYS` (macos.rs:142-174)
Forwarded from the panel to `lp`: Captured from the panel into the Stage 2 mirror (v1 forwarded them to `lp`):
``` ```
Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType
Tray: InputSlot, AP_D_InputSlot Tray: InputSlot, MediaSource, AP_D_InputSlot, EPIJ_FdSo, EPIJ_PSrc, CNIJMediaSupply
Size: PageSize Size: PageSize
Color: CNIJIntent2, CNIJIntent, EPIJ_CMat, EPIJ_CCor, EPIJ_OSColMat, Color: CNIJIntent2, CNIJIntent, EPIJ_CMat, EPIJ_CCor, EPIJ_OSColMat,
ColorCorrection, StpColorCorrection, EpsonColorMode, ColorModel ColorCorrection, StpColorCorrection, EpsonColorMode, ColorModel
@@ -255,31 +283,39 @@ Duplex: Duplex, sides
- Drops `collate`, `copies`, `pserrorhandler-requested`, `job-sheets` - Drops `collate`, `copies`, `pserrorhandler-requested`, `job-sheets`
- **Keeps unknown non-`com.*` keys** (permissive: unknown driver keys survive) - **Keeps unknown non-`com.*` keys** (permissive: unknown driver keys survive)
### `build_lp_args` (macos.rs:518-643) ### Native spool — S1–S14 (v2.0, #201)
Always, even with `options=None`: `NativeTargetSpooler.spool(_:)` (app target, `Sources/ICCery/Print/` — `ICCeryCore` stays AppKit-free, D1). One `TargetPrintRequest` per page, or one per run when `singleJobForAllPages` is on (D5):
``` ```
lp -d <printer> -t "ICCery Target - <filename>" S1 NSPrintInfo()
-o AP_ColorMatchingMode=AP_ApplicationColorMatching S2 printInfo.printer = NSPrinter(name: queue) ?? NSPrinter(name: displayName) [best effort]
-o AP.ColorMatchingMode=AP_ApplicationColorMatching S3 PMTicketBridge.makePrinter(queue:) → bind(printer:to:) ① + PMSessionDefault*
… captured / detected options … S4 ticket != nil → PMTicketBridge.restore(ticket, into: printInfo) ⑦′
<tiff_path> S5 TicketWriteResolver.resolve(...) → apply to PMPrintSettings + mirror dict ③④⑤+D2
S6 paper override → PMTicketBridge.applyPaper(token:…)
S7 orientation → printInfo.orientation + orientation-requested
S8 suppressor.applySPIMode(session) ②
S9 Cocoa geometry: margins 0, pagination .clip, scaling 1.0, centering off,
jobDisposition .spool (or .save + jobSavingURL under the PDF harness)
S10 TargetRasterLoader.load(each page) → device-tagged CGImage + pointSize
S11 TargetPageCanvasView(pages:paperSize: printInfo.paperSize)
S12 NSPrintOperation(view:printInfo:) — panels off, jobTitle set
S13 operation.runOperation() → false ⇒ throw TargetSpoolError.operationFailed
S14 PMRelease the printer on every path (defer)
``` ```
Order after the two AP_* flags: `TicketWriteResolver` produces the resolved write list as a pure value, in a
locked order: both `AP_*` keys (locked) → the three Quartz keys (⑤′) →
1. Parse `opts.cups_options` into `-o k=v`, record lowercased keys in `added_keys`. `PageSize` → the detected tray key → the detected media key → the detected
2. If `media_type` set and none of `mediatype` / `cnijmediatype` / `epij_medi` / `stpmediatype` already added: `detect_media_type_key(lpoptions)` and add it. quality key → the driver colour bypass → `orientation-requested`. Stage 2
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`.** overrides **always win**
4. Orientation → `orientation-requested=4|3` unless already present. over the rehydrated ticket (D6 — the v1 captured-wins inversion is gone);
5. `PageSize=` unless `pagesize` already present. `raw` can never appear because there is no `lp`. Panels stay off
(`showsPrintPanel` / `showsProgressPanel` false, `canSpawnSeparateThread`
`ppd_uncorrected_passthrough` is stored from the panel but **does not change macOS lp flags**. There is no `-o raw` on macOS. false); a multi-page job is one `TargetPageCanvasView` driven by
`knowsPageRange` / `rectForPage`, drawing each page 1:1 top-left anchored
### `print_target` (macos.rs:646-677) with interpolation and antialiasing disabled (D9, docs/14 §6).
Exists-check, `build_lp_args`, `Command::new("lp").args(&args).output()`. Error wrapping same pattern as Unix (`"macOS CUPS print job failed: …"`).
### Cancellation as `None` ### Cancellation as `None`
@@ -295,19 +331,26 @@ The rewrite:
- `display_name` fallback for `NSPrinter::printerWithName`. - `display_name` fallback for `NSPrinter::printerWithName`.
- Private SPI to lock Color Matching. - Private SPI to lock Color Matching.
- Dual AP_* keys (underscore + dotted). - Dual AP_* keys (underscore + dotted).
- Driver-specific PPD bypass pre-selected and re-applied on `lp`. - Driver-specific PPD bypass pre-selected in the panel and re-applied on the spool ticket (v1 re-applied it on `lp`).
- Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions`. - Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions` (Stage 2 mirror) plus the `PrintTicket` serialise (layer ⑦, v2.0).
`PMPrinter` lifetime is explicit `PMRelease` on every path. SPI is `dlsym`'d so missing symbols on old OS X do not prevent launch. Panel **must** be main-thread (`MainThreadMarker::new().ok_or("Print panel must be invoked on the main thread")`). `PMPrinter` lifetime is explicit `PMRelease` on every path. SPI is `dlsym`'d so missing symbols on old OS X do not prevent launch. Panel **must** be main-thread (`MainThreadMarker::new().ok_or("Print panel must be invoked on the main thread")`).
### macOS tests (macos.rs:712-863 + tests.rs:278-318) ### macOS tests — historical v1 (macos.rs:712-863 + tests.rs:278-318)
- Filter drops `com.apple.*`, `collate`, `copies`, `AP_ColorMatchingMode`, empty `AP_D_InputSlot`; keeps `MediaType`, `EPIJ_CMat`, `PageSize`, `CNIJIntent2`, `ColorCorrection`. - Filter drops `com.apple.*`, `collate`, `copies`, `AP_ColorMatchingMode`, empty `AP_D_InputSlot`; keeps `MediaType`, `EPIJ_CMat`, `PageSize`, `CNIJIntent2`, `ColorCorrection`.
- `extract_media_type_from_options` prefers `MediaType` then `EPIJ_Medi`. - `extract_media_type_from_options` prefers `MediaType` then `EPIJ_Medi`.
- `build_lp_args` always contains both AP_* flags; captured options win over explicit `media_type` / orientation / auto color-bypass; last arg is the TIFF path. - `build_lp_args` always contains both AP_* flags; captured options win over explicit `media_type` / orientation / auto color-bypass; last arg is the TIFF path. (**Historical v1** — the captured-wins behaviour recorded here is #201 root cause 2; v2.0 inverts it: Stage 2 always wins, D6.)
- `detect_driver_color_bypass` Canon `4`, Epson `3`, Gutenprint `Uncorrected`. - `detect_driver_color_bypass` Canon `4`, Epson `3`, Gutenprint `Uncorrected`.
- Missing TIFF errors. - Missing TIFF errors.
v2.0 replacements (`TicketWriteResolverTests`, `PrintTicketTests`,
`TargetRasterTests`, `TargetCanvasGeometryTests`, `NativeSpoolPDFTests`):
both AP_* keys locked + all three Quartz keys always present; Stage 2
always wins (D6); `raw` never emitted; ticket serialise/restore
round-trips bytes; 1:1 geometry and draw flags asserted against a real
`.save`-to-PDF `NSPrintOperation`.
--- ---
## ColorSync suppression — complete key/SPI/flag roster ## ColorSync suppression — complete key/SPI/flag roster
@@ -338,7 +381,12 @@ PMRelease
AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPanelOptions::all` + `ShowsPageSetupAccessory`. AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPanelOptions::all` + `ShowsPageSetupAccessory`.
### CUPS / lp flags ### CUPS / lp flags — historical v1
> No `lp` invocation exists on the v2.0 target-print path (#201 — `LpArgs`,
> `CupsService.printTarget` and the `lp` fixture are deleted). This table
> records the v1 `lp -o` contract for reference only; the live write list is
> `TicketWriteResolver`'s locked order (§native spool above).
| Flag | Platform | When | | Flag | Platform | When |
|------|----------|------| |------|----------|------|
@@ -361,7 +409,8 @@ AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPan
### PPD keys (detection) ### PPD keys (detection)
``` ```
Media: CNIJMediaType, EPIJ_Medi, StpMediaType, MediaType, MediaSource, InputSlot, PageSize, MediaSize Media: CNIJMediaType, EPIJ_Medi, StpMediaType, MediaType, PageSize, MediaSize
Tray: InputSlot, MediaSource, EPIJ_FdSo, EPIJ_PSrc, CNIJMediaSupply (EPIJ_FdSo preferred over EPIJ_PSrc when both present)
Color: CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, EpsonColorMode Color: CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, EpsonColorMode
``` ```
+4 -1
View File
@@ -53,7 +53,10 @@ First-match order:
`CNIJMediaType` > `EPIJ_Medi` > `StpMediaType` > `MediaType`. `CNIJMediaType` > `EPIJ_Medi` > `StpMediaType` > `MediaType`.
### Linux `build_lp_args` / `print_target` (unix.rs:362-457) ### Linux `build_lp_args` / `print_target` (unix.rs:362-457) — historical v1
> v0.8.5 Linux spool contract, kept for reference. ICCery v2 is macOS-only
> and its native path spools via `NSPrintOperation`, not `lp` (#201).
``` ```
lp -d <printer> -t "ICCery Target - <filename>" lp -d <printer> -t "ICCery Target - <filename>"
+10 -10
View File
@@ -8,7 +8,7 @@ Separate native macOS AppKit app. Spec: `/tmp/ICCery-CPU/SPEC.md`. Binary name `
### Why it exists ### Why it exists
ICCery's Tauri path spools TIFF via `lp` and never goes through Quartz. That is correct for "don't let ColorSync touch the file", but: ICCery's Tauri path spooled TIFF via `lp` and never went through Quartz (v1; v2.0 spools via a headless `NSPrintOperation` in ICCery proper — #201). That is correct for "don't let ColorSync touch the file", but:
- No 1:1 physical-size preview - No 1:1 physical-size preview
- Windows-style `StretchDIBits` scaler (macOS `lp` may still scale inside the filter) - Windows-style `StretchDIBits` scaler (macOS `lp` may still scale inside the filter)
@@ -100,7 +100,7 @@ Injected into:
- `printInfo.dictionary()["com.apple.print.PrintSettings.PMColorMatchingMode"]` (legacy) - `printInfo.dictionary()["com.apple.print.PrintSettings.PMColorMatchingMode"]` (legacy)
- nested `com.apple.print.printSettings` dictionary, same keys - nested `com.apple.print.printSettings` dictionary, same keys
**This is not `AP_ApplicationColorMatching`.** TargetPrint talks to Quartz/`NSPrintOperation`. ICCery talks to the CUPS `lp` ticket / `cgpdftoraster`. A rewrite that unifies them must keep both vocabularies or prove one is honored on both paths. **This is not `AP_ApplicationColorMatching`.** TargetPrint talks to Quartz/`NSPrintOperation`. v1 ICCery talked to the CUPS `lp` ticket / `cgpdftoraster`; a rewrite that unifies them must keep both vocabularies or prove one is honored on both paths. **v2.0 (#201, D2) resolved this:** the Quartz vocabulary in this section is now live in ICCery proper — `ColorSyncSuppressor.applyQuartzMode` / `TicketWriteResolver` write `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, the legacy `com.apple.print.PrintSettings.PMColorMatchingMode` and the nested `com.apple.print.printSettings` mirror **alongside** the locked AP_* keys on the single native spool path.
Panel policy (`ColorMatching.configurePanel`): Panel policy (`ColorMatching.configurePanel`):
@@ -125,7 +125,7 @@ static inline const char *TPCupsGetPPD(const char *name) {
- AirPrint (SPEC §10.2) if any of: URI `apple-airprint://`; PPD `*APAirPrint: True`; make Apple + model contains AirPrint; `ipps://` **and** PPD text contains `airprint`. Persistent warning badge; unmanaged color cannot be trusted. Tests in `AirPrintTests.swift`. - AirPrint (SPEC §10.2) if any of: URI `apple-airprint://`; PPD `*APAirPrint: True`; make Apple + model contains AirPrint; `ipps://` **and** PPD text contains `airprint`. Persistent warning badge; unmanaged color cannot be trusted. Tests in `AirPrintTests.swift`.
- Vendor bypass (SPEC §10.3) — **different keys from ICCery's lpoptions detector:** - Vendor bypass (SPEC §10.3) — **different keys from ICCery's lpoptions detector:**
| Vendor | TargetPrint keys | ICCery macOS `lp` keys | | Vendor | TargetPrint keys | ICCery macOS `lpoptions`-detected keys (v1: `lp -o`) |
|--------|------------------|------------------------| |--------|------------------|------------------------|
| Epson | `ColorModel=RGB`, `EPSONColorControls=Off` | `EPIJ_CMat=3` / `EPIJ_CCor=0` / `EpsonColorMode=Off` | | Epson | `ColorModel=RGB`, `EPSONColorControls=Off` | `EPIJ_CMat=3` / `EPIJ_CCor=0` / `EpsonColorMode=Off` |
| Canon | `CNColorMatching=None` | `CNIJIntent2=4` / `CNIJIntent=4` | | Canon | `CNColorMatching=None` | `CNIJIntent2=4` / `CNIJIntent=4` |
@@ -176,9 +176,9 @@ Command::new("/Applications/TargetPrint.app/Contents/MacOS/TargetPrint")
CI publishes `vendor-iccery.zip` with `macos-x86_64` / `macos-aarch64` / `macos-universal` app bundles to drop into `src-tauri/targetprint/`. CI publishes `vendor-iccery.zip` with `macos-x86_64` / `macos-aarch64` / `macos-universal` app bundles to drop into `src-tauri/targetprint/`.
Suggested ICCery integration: Suggested ICCery integration (**superseded** — v2.0 took option 2's rendering model in-process instead; #201 adopted Quartz/`NSPrintOperation` spooling inside ICCery proper and removed `lp` entirely. The `--job` companion-app contract remains the v2.1 plan for `ICCeryPrintKit`, #16):
1. Keep current `lp` path as the headless/fast path (and the only path on Linux). 1. ~~Keep current `lp` path as the headless/fast path (and the only path on Linux).~~
2. On macOS, Preferences / Print can spawn TargetPrint with a `TargetJob` built from `PrintOptions` + TIFF list + `forceUnmanagedColor: true` + `lockColorManagement: true`. 2. On macOS, Preferences / Print can spawn TargetPrint with a `TargetJob` built from `PrintOptions` + TIFF list + `forceUnmanagedColor: true` + `lockColorManagement: true`.
3. Do not `CREATE_NO_WINDOW` (macOS); do not `wait()`. Cleanup of the JSON is ICCery's job after process exit, or leave in `/tmp` as an audit trail (SPEC §13). 3. Do not `CREATE_NO_WINDOW` (macOS); do not `wait()`. Cleanup of the JSON is ICCery's job after process exit, or leave in `/tmp` as an audit trail (SPEC §13).
@@ -186,14 +186,14 @@ Suggested ICCery integration:
| Concern | ICCery `macos.rs` | TargetPrint | Rewrite recommendation | | Concern | ICCery `macos.rs` | TargetPrint | Rewrite recommendation |
|---------|-------------------|-------------|------------------------| |---------|-------------------|-------------|------------------------|
| Spool | `lp` TIFF | Quartz `NSPrintOperation` | Keep `lp` for unattended; TargetPrint for preview+panel | | Spool | `lp` TIFF | Quartz `NSPrintOperation` | Quartz `NSPrintOperation` — **adopted in v2.0 via #201** (`NativeTargetSpooler`; `lp` removed from the target-print path) |
| ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** if using NSPrintOperation; keep AP_* on `lp` | | ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** — **adopted in v2.0 via #201** (D2: the single native path carries AP_* and the Quartz §7 vocabulary) |
| Lock PDE UI | private `PMSessionSetColorMatchingMode*` SPI | strip Color Matching accessories | Use SPI **and** strip; accessories API misses driver PDEs (the #188 failure mode) | | Lock PDE UI | private `PMSessionSetColorMatchingMode*` SPI | strip Color Matching accessories | Use SPI **and** strip; accessories API misses driver PDEs (the #188 failure mode) |
| Canon off | `CNIJIntent2=4` | `CNColorMatching=None` | Apply both | | Canon off | `CNIJIntent2=4` | `CNColorMatching=None` | Apply both |
| Epson off | `EPIJ_CMat=3` / `EPIJ_CCor=0` | `EPSONColorControls=Off` + `ColorModel=RGB` | Apply both; prefer captured panel values | | Epson off | `EPIJ_CMat=3` / `EPIJ_CCor=0` | `EPSONColorControls=Off` + `ColorModel=RGB` | Apply both; prefer captured panel values |
| Geometry | none (filter decides) | 1:1 pt from DPI | TargetPrint (or do not scale in GDI/`lp`) | | Geometry | none (filter decides) | 1:1 pt from DPI | 1:1 pt from DPI — **adopted in v2.0 via #201** (`TargetRasterLoader`/`TargetPageCanvasView`, docs/14 §6) |
| Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges | | Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges — **adopted in v2.0 via #201** (interpolation/antialias off in `TargetPageCanvasView.draw`) |
| AirPrint | none | detected + warned | Port detector into ICCery printer list | | AirPrint | none | detected + warned | Ported into ICCery — **adopted in v2.0 via #201/#202** (`lpstat -v` + PPD §10.2 rules, Stage 2 `airPrintWarningBadge`) |
| Linux | `-o raw` | n/a (macOS only) | Keep raw + PPD fallback | | Linux | `-o raw` | n/a (macOS only) | Keep raw + PPD fallback |
| Windows | GDI ICM_OFF | n/a | Keep GDI; do not route through TargetPrint | | Windows | GDI ICM_OFF | n/a | Keep GDI; do not route through TargetPrint |
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -130,7 +130,7 @@ Grouped by subsystem. Issue numbers are always cited.
**Sister-product traps (ICCery-CPU `#1`, `#2`, `#5`) that also apply to any CUPS rewrite:** **Sister-product traps (ICCery-CPU `#1`, `#2`, `#5`) that also apply to any CUPS rewrite:**
- PPD `ChoiceName/TranslationString` — UI must show the title, send the code (`MediaType=13` not `MediaType=Epson Premium Glossy`). - PPD `ChoiceName/TranslationString` — UI must show the title, send the code (`MediaType=13` not `MediaType=Epson Premium Glossy`).
- Tray keywords are vendor-specific (`InputSlot` vs Epson `EPIJ_FdSo` vs Canon `CNIJMediaSupply`). Empty arrays are not `nil` — coalescing `?? ["Auto"]` does not fire. - Tray keywords are vendor-specific (`InputSlot`/`MediaSource` vs Epson `EPIJ_FdSo`/`EPIJ_PSrc` vs Canon `CNIJMediaSupply`). When both Epson keys are present, `EPIJ_FdSo` is the feed-source control and takes precedence over `EPIJ_PSrc`. Empty arrays are not `nil` — coalescing `?? ["Auto"]` does not fire.
- Canon Super Fine is three coordinated keys (`CNIJPrintQuality=0`, `CNIJPrintMode2=5`, `CNIJPQualitySlider=5`), not `cupsPrintQuality=High`. - Canon Super Fine is three coordinated keys (`CNIJPrintQuality=0`, `CNIJPrintMode2=5`, `CNIJPQualitySlider=5`), not `cupsPrintQuality=High`.
--- ---
+1 -1
View File
@@ -85,7 +85,7 @@ Lock these before rewriting UI:
4. `parseProfcheckReport` JSON + legacy + empty→warning 4. `parseProfcheckReport` JSON + legacy + empty→warning
5. Color bypass detector: Canon/Epson/Gutenprint samples 5. Color bypass detector: Canon/Epson/Gutenprint samples
6. `filter_cups_options_string` drops `com.apple.*`, keeps `EPIJ_CMat` 6. `filter_cups_options_string` drops `com.apple.*`, keeps `EPIJ_CMat`
7. `build_lp_args` always emits both AP_* keys 7. `build_lp_args` always emits both AP_* keys (**historical v1** — superseded by `TicketWriteResolverTests`, #201)
8. Threshold validation `good < warning` 8. Threshold validation `good < warning`
9. `snapshot_ti3` 1-based and removes canonical 9. `snapshot_ti3` 1-based and removes canonical
10. DEVMODE round-trip size 10. DEVMODE round-trip size
+9 -3
View File
@@ -29,6 +29,12 @@
## Locked product decisions ## Locked product decisions
> **Errata (M12, #201):** item 4's `lp` spool was replaced in v2.0 by a
> headless `NSPrintOperation` replaying the captured `PMPrintSettings`
> ticket, and the Quartz vocabulary of item 5 is now written **alongside**
> AP_* on the single native path (D2). "Never mix" no longer applies inside
> ICCery proper; it still governs the future `ICCeryPrintKit` boundary.
1. **Stack:** SwiftUI (`@Observable`, `@MainActor` view models) + AppKit for printing/panels. No Tauri, no Rust, no WebView. 1. **Stack:** SwiftUI (`@Observable`, `@MainActor` view models) + AppKit for printing/panels. No Tauri, no Rust, no WebView.
2. **Floor:** macOS 14.0, universal `arm64` + `x86_64`. 2. **Floor:** macOS 14.0, universal `arm64` + `x86_64`.
3. **AGPL:** never link Argyll. Spawn with piped stdio + `ARGYLL_NOT_INTERACTIVE=1` on **every** child (streaming and captured). 3. **AGPL:** never link Argyll. Spawn with piped stdio + `ARGYLL_NOT_INTERACTIVE=1` on **every** child (streaming and captured).
@@ -120,7 +126,7 @@ Issues 1–6.
**Hardware:** none. **Hardware:** none.
Issues 7–11. Issues 7–11.
### M3 — macOS unmanaged printing (`lp` path) ### M3 — macOS unmanaged printing (`lp` path) — historical v1, superseded by #201
**CI/mock:** `lpstat`/`lpoptions` parsers; `build_lp_args` golden vectors (both `AP_*` always present); option filter; cancel → nil. **CI/mock:** `lpstat`/`lpoptions` parsers; `build_lp_args` golden vectors (both `AP_*` always present); option filter; cancel → nil.
**Hardware:** Preferences opens **driver PDE** on a real Epson or Canon queue; colour matching off/grayed; printed TIFF measures unmanaged (no ColorSync transform). **Hardware:** Preferences opens **driver PDE** on a real Epson or Canon queue; colour matching off/grayed; printed TIFF measures unmanaged (no ColorSync transform).
@@ -376,7 +382,7 @@ Six layers (spec [11](11-print-macos.md) roster):
- Deps: 12, 13. - Deps: 12, 13.
- Test CI: injectable dlsym order; filter fixtures. Hardware: PDE colour grayed/off on Epson **and** Canon. - Test CI: injectable dlsym order; filter fixtures. Hardware: PDE colour grayed/off on Epson **and** Canon.
**Issue 15 — `lp` spool path** **Issue 15 — `lp` spool path — historical v1, superseded by #201 native spool**
Labels: `Feature/Backend`, `Priority/High` Labels: `Feature/Backend`, `Priority/High`
Milestone: M3 Milestone: M3
@@ -583,7 +589,7 @@ Milestone: **Later**
- Separate Swift package `ICCeryPrintKit`. **Zero** deps on wizard types. - Separate Swift package `ICCeryPrintKit`. **Zero** deps on wizard types.
- Public API: `TargetJob` v1 JSON + `--job` CLI (fire-and-forget) **and** in-process `NSPrintOperation`. Preserve extractability to a standalone app. - Public API: `TargetJob` v1 JSON + `--job` CLI (fire-and-forget) **and** in-process `NSPrintOperation`. Preserve extractability to a standalone app.
- ColorSync vocabulary is **not** the `lp` path: `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, legacy `com.apple.print.PrintSettings.PMColorMatchingMode`. **Never mix with `AP_ApplicationColorMatching`.** - ColorSync vocabulary is **not** the `lp` path: `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, legacy `com.apple.print.PrintSettings.PMColorMatchingMode`. **Never mix with `AP_ApplicationColorMatching`** — amended by #201 (D2): with `lp` gone, ICCery's single native path writes **both** vocabularies; this constraint now governs only the future `ICCeryPrintKit` boundary.
- Vendor keys (separate table from issue 14): Epson `ColorModel=RGB` + `EPSONColorControls=Off`; Canon `CNColorMatching=None`; HP `ColorModel=RGB` + `HPColorControl=Off`. - Vendor keys (separate table from issue 14): Epson `ColorModel=RGB` + `EPSONColorControls=Off`; Canon `CNColorMatching=None`; HP `ColorModel=RGB` + `HPColorControl=Off`.
- Geometry: 72pt=1in, no `backingScaleFactor`, interpolation `.none`, antialias off, pixel-integrity seam test. Resolve SPEC contradiction: job JSON `"centered": true` vs draw “no centering” — **lock “no centering, scale 1.0” for profiling targets.** - Geometry: 72pt=1in, no `backingScaleFactor`, interpolation `.none`, antialias off, pixel-integrity seam test. Resolve SPEC contradiction: job JSON `"centered": true` vs draw “no centering” — **lock “no centering, scale 1.0” for profiling targets.**
- AirPrint detection → persistent warning. - AirPrint detection → persistent warning.
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
# Issue #201 — Printer quality and media type ignored when printing target
> Analysis doc referenced by issue **[Bug/Critical] Printer quality and media
> type settings ignored when printing target — lp path missing PMPrintSettings
> ticket** (#201). Line numbers refer to `develop` @ `0513b27` (pre-M12 code,
> verified 2026-09-17). The fix landed on `milestone/m12-native-spool` — see
> "Resolution" below.
## 0. Lifecycle trace — what the code did (pre-#201, historical v1)
```
Stage2View "btnPrinterProperties"
→ PrintSessionViewModel.openPrinterPreferences() :141
→ PrintPanelService.showProperties() :59
→ runNativePanel() :85
① PMPrinterCreateFromPrinterID + PMSessionSetCurrentPMPrinter :96–113
▸ applyInitialSelections (PMPrintSettingsSetValue ×4) :216
▸ applyPaperPageFormat (PMPaper match → PMCopyPageFormat) :258
②–⑤ ColorSyncSuppressor SPI / AP_* / bypass / mirror :152–163
NSPrintPanel.runModal("Use Settings") :173
⑥ PMPrintSettingsToOptions → CupsOptionsFilter → String :185–191
✗ THE TICKET IS DISCARDED HERE — only the flattened `k=v` string survives
→ capturedCupsOptions[queue] = String :174
Stage2View "btnPrintAll"
→ PrintSessionViewModel.printAllPages(from:) :205
→ spool(page, index:) :263
→ CupsService.printTarget() CupsService.swift:170
→ LpArgs.build() LpArgs.swift:42
→ ProcessManager.runCaptured("/usr/bin/lp", argv)
```
## Root causes (pre-#201, historical v1)
Two defects, both confirmed by reading the code:
### 1. Ticket loss
`ColorSyncSuppressor.captureOptions` (:133) was the only capture path. It
funneled the whole `PMPrintSettings` object through `PMPrintSettingsToOptions`
→ a space-separated `key=value` string, then `CupsOptionsFilter.filter`
**dropped every `com.apple.*` key** (`CupsOptionsFilter.swift:49`) — i.e. it
deliberately discarded `com.apple.print.PrintSettings`, the ticket the
Epson/Canon raster filter reads. `lp` cannot reconstruct it, so the driver
fell back to plain paper / normal quality.
### 2. Override inversion
`LpArgs.build` recorded every captured key in `addedKeys` (:67–73) and then
*suppressed* the explicit Stage 2 value when the key was already present —
media (:81), quality (:90), bypass (:102), orientation (:109), `PageSize`
(:117). A Stage 2 dropdown change after a panel capture was silently ignored.
### Dismissed hypothesis
**argv tokenisation is safe** — `ProcessManager.runCaptured` hands an argv
array to `Process`, never a shell. Space-containing `-o` values were
preserved; this was checked and dismissed in the issue investigation and
re-verified during the M12 audit.
## Locked decisions (D1–D13)
Decisions taken by the M12 megaplan; **locked — do not re-derive.**
(`lp`/`LpArgs` mentions in this table name the deleted historical v1 path.)
| # | Decision | Consequence |
|---|----------|-------------|
| D1 | Spooler lives in the **app target** (`Sources/ICCery/Print/`), not in `CupsService` | `ICCeryCore` stays AppKit-free (AGENTS §Package layout). `CupsService.printTarget` is **deleted**; `CupsService` keeps enumeration/capabilities/PPD only. The issue body's "in `CupsService`" is superseded — posted as errata on #201 |
| D2 | Write **both** ColorSync vocabularies on the native path | AP_* (locked) **and** Quartz `PMColorMatchingMode=APCustomColorMatching` + `PMCustomColorMatchingProfile=""` + legacy `com.apple.print.PrintSettings.PMColorMatchingMode` + the nested `com.apple.print.printSettings` mirror. AGENTS §"Private ColorSync SPI — never mix" is **amended**: with `lp` gone there is one path and it carries both dictionaries (docs/14 decision table, "Set both if using NSPrintOperation") |
| D3 | Ticket = `PMPrintSettings` **Data** + `PMPageFormat` **Data** + an `NSPrintInfo.dictionary()` plist fallback | `kPMDataFormatXMLDefault`. Restored via `PM*CreateWithDataRepresentation` → `PMCopy*` → `PMSessionValidate*` → `updateFromPM*`. Byte round-trip is unit-testable |
| D4 | Delete `lp`; **keep** `CupsParsers` + `CupsOptionsFilter` | Parsers still drive capabilities (#183/#180/#181), media/quality key detection, driver-bypass detection, and the panel→Stage-2 apply-back mirror (#186). Only `LpArgs` dies |
| D5 | Job granularity is **user-selectable**, default **one job per page** | New session-only `@Published var singleJobForAllPages = false`; Stage 2 checkbox `chkSingleSpoolJob`. Default preserves today's per-page notices and error attribution |
| D6 | **Stage 2 always wins** over the rehydrated ticket | Unconditional overwrite of paper / media / quality / orientation. Mitigation for vendor companion-key desync: only write when the value differs from the ticket's current value, and log both (R4) |
| D7 | Spool **silently** | `showsPrintPanel = false`, `showsProgressPanel = false`, `canSpawnSeparateThread = false`. Feedback stays on the existing `isPrinting` + `Notice`. No new system modal → XCUITest unaffected |
| D8 | Verification = **recorder seam + PDF harness** | DEBUG `RecordingTargetSpooler` writes resolved ticket lines to `ICCERY_TEST_SPOOL_LOG` (replaces `ICCERY_TEST_LP_ARGV`); a real `NSPrintOperation` with `jobDisposition = .save` produces a PDF for geometry assertions |
| D9 | Never scale; **warn + spool** | 1:1 always, anchored at the paper's top-left. `warn` when the DPI-derived size disagrees with the manifest `width_mm`/`height_mm` by >0.5 mm; warning `Notice` when the image exceeds the paper |
| D10 | Device colour spaces implemented **and verified** for 1/3/4 channels at 8 and 16 bpc | Re-tag the decoded `CGImage` into `DeviceGray`/`DeviceRGB`/`DeviceCMYK` reusing the source `dataProvider` — **no resample, no bit-depth change**. Hardware gate covers RGB-8, RGB-16, DeviceGray-8 and CMYK-16 |
| D11 | AirPrint detection + Stage 2 banner **in scope**, as its own issue | docs/14 §10.2 rules. Blocked-by #201 → filed as #202 |
| D12 | Extract a **shared** `PMTicketBridge` | Single `@MainActor enum` owning every `PM*` call, with one documented `PMRelease` rule. `PrintPanelService` migrates onto it |
| D13 | New milestone **M12 — Native print spool** | `milestone/m12-native-spool` from `develop`; #201 moves off the shipped M11 (id 34) |
## Resolution
Implemented on `milestone/m12-native-spool` (Phases 1–5, PRs #203–#207).
(`LpArgs`/`lp` references below name deleted historical v1 artefacts.)
- `PMTicketBridge` owns every `PM*` call; `PrintPanelService.showProperties`
returns `PanelCaptureResult` carrying a `PrintTicket` (layer ⑦ serialise).
- `NativeTargetSpooler` rehydrates the ticket into a fresh `NSPrintInfo`
(S1–S14, docs/11 §native spool) and spools a headless `NSPrintOperation`
over `TargetPageCanvasView` — 1:1, top-left anchored, interpolation off.
- `TicketWriteResolver` replaces `LpArgs.build`: Stage 2 always wins (D6),
both ColorSync vocabularies written (D2), no `raw` can ever appear.
- `LpArgs`, `CupsService.printTarget`, the `lp` fixture and
`ICCERY_TEST_LP_ARGV` are deleted; `RecordingTargetSpooler` writes to
`ICCERY_TEST_SPOOL_LOG` for tests (D8).
- AirPrint detection + Stage 2 warning badge shipped as #202 (D11).
## References
- `Sources/ICCery/Print/{PMTicketBridge,PrintTicket,NativeTargetSpooler,TicketWriteResolver,TargetRaster,TargetPageCanvasView}.swift`
- `Packages/ICCeryCore/Sources/ICCeryCore/Print/{CupsService,CupsParsers,CupsOptionsFilter,PrinterModels,ColorMatchingAttempts}.swift`
- `docs/11-print-macos.md` §ColorSync suppression — UI click to `NSPrintOperation`
- `docs/14-iccery-cpu-targetprint.md` §6–§7 (geometry + Quartz vocabulary)
- M12 megaplan (`plan-be303e6f3f5e89da`)
+1 -1
View File
@@ -61,7 +61,7 @@ targets:
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0" INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
INFOPLIST_KEY_NSPrincipalClass: NSApplication INFOPLIST_KEY_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3." INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.1" MARKETING_VERSION: "2.0.4"
CURRENT_PROJECT_VERSION: "1" CURRENT_PROJECT_VERSION: "1"
# Stamped by scripts/version.sh at packaging/CI time (release tag or # Stamped by scripts/version.sh at packaging/CI time (release tag or
# `git describe` string); About shows it next to the marketing version. # `git describe` string); About shows it next to the marketing version.
+1 -1
View File
@@ -150,7 +150,7 @@ if [ ! -f "$DMG_BACKGROUND" ]; then
fi fi
echo "==> Building DMG" echo "==> Building DMG"
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.0')" VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.3')"
BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')" BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')"
# Tagged/described builds carry the tag in the DMG name so prerelease # Tagged/described builds carry the tag in the DMG name so prerelease
# artefacts are self-describing (ICCery-2.0.0-pre2-grok-236.dmg); a plain # artefacts are self-describing (ICCery-2.0.0-pre2-grok-236.dmg); a plain