Compare commits

...
Author SHA1 Message Date
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
23 changed files with 1454 additions and 138 deletions
+23 -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`,
@@ -81,7 +96,12 @@ Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for rel
## Private ColorSync SPI ## Private ColorSync SPI
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`. 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`. Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix. One spool path remains (#201 D2): write **both** vocabularies on the native
path — locked AP_* (`AP_ColorMatchingMode` + `AP.ColorMatchingMode` =
`AP_ApplicationColorMatching`) **and** the Quartz dictionary
(`PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`,
legacy `com.apple.print.PrintSettings.PMColorMatchingMode`, nested
`com.apple.print.printSettings` mirror).
## Gitea issue dependencies ## Gitea issue dependencies
Use the `gitea` MCP (custom build with blocking support — verified working): Use the `gitea` MCP (custom build with blocking support — verified working):
@@ -115,9 +115,16 @@ 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)
caps.qualityIDsByMediaType = MediaQualityConstraints.resolve(
listings: listings, ppd: ppd)
return caps
} }
/// Pure mapping — extracted so fixture tests need no process. /// Pure mapping — extracted so fixture tests need no process.
@@ -0,0 +1,329 @@
import Foundation
/// Per-media quality validity (#214): which of the queue's quality
/// tokens the driver actually accepts for a given media type. The
/// answer is not in `lpoptions -l`, IPP, or standard PPD
/// `*UIConstraints` for the two driver families handled here —
///
/// - Epson InkjetPrinter2: `*EPIJUIConstraint: <cond>|<forbidden>`
/// lines in the machine bundle's
/// `…/Contents/Resources/PDEData.dat`. A rule forbids its RHS
/// choice while every LHS `*<key> <value>` term holds. LHS keys
/// other than the media key (`EPIJ_PSrc`, `EPIJ_FdSo`,
/// `EPIJ_Ink_`, …) are evaluated against the queue's `lpoptions`
/// defaults — reproducing the driver's PDE state when it opens.
/// - Canon BJPrinter: `*CNIJNameTblPath` + `*CNIJTableID` PPD keys
/// locate `cnb_<TableID>0.tbl`, a binary record DB whose 20-byte
/// `{u16 0x30, u16 1, u32 0, u16 family, u16 flag, u32 mediaID,
/// u16 0, u16 quality}` records enumerate the allowed
/// `CNIJPrintQuality` values per `CNIJMediaType` (`mediaID`
/// `| 0x10000` marks the borderless variant of the same media).
/// - Generic: standard PPD `*UIConstraints:`/`*Constraints:` pairs
/// (Gutenprint etc.) — first non-empty source wins.
///
/// Every parser is pure; file access is injected so tests need no
/// installed driver. Failure or absence yields an empty map — callers
/// treat that as "unconstrained" and keep the full quality list.
public enum MediaQualityConstraints {
/// media-type id → allowed quality ids. Sources tried in order
/// (PPD constraints → Epson `PDEData.dat` → Canon `cnb` table);
/// the first source producing any entries wins.
public static func resolve(
listings: [CupsOptionListing],
ppd: String?,
readFile: (URL) -> Data? = { try? Data(contentsOf: $0) }
) -> [String: Set<String>] {
let optionKeys = Set(listings.map(\.key))
guard let mediaKey = CupsParsers.detectMediaTypeKey(
optionKeys: optionKeys),
let qualityKey = CupsParsers.detectQualityKey(
optionKeys: optionKeys),
let mediaListing = listings.first(where: {
$0.key == mediaKey }),
let qualityListing = listings.first(where: {
$0.key == qualityKey })
else { return [:] }
let mediaIDs = Set(mediaListing.choices)
let qualityIDs = Set(qualityListing.choices)
let defaults = Dictionary(
listings.compactMap { l in l.defaultChoice.map { (l.key, $0) } },
uniquingKeysWith: { first, _ in first })
guard let ppd else { return [:] }
let ppdMap = allowedMap(
forbidden: ppdUIConstraints(
ppd, mediaKey: mediaKey, qualityKey: qualityKey,
mediaIDs: mediaIDs, qualityIDs: qualityIDs),
mediaIDs: mediaIDs, qualityIDs: qualityIDs)
if !ppdMap.isEmpty { return ppdMap }
if let url = epijPDEDataPath(ppd: ppd),
let data = readFile(url),
let dat = String(data: data, encoding: .utf8)
?? String(data: data, encoding: .isoLatin1) {
let map = allowedMap(
forbidden: epijUIConstraints(
dat, mediaKey: mediaKey, qualityKey: qualityKey,
mediaIDs: mediaIDs, qualityIDs: qualityIDs,
defaults: defaults),
mediaIDs: mediaIDs, qualityIDs: qualityIDs)
if !map.isEmpty { return map }
}
if let url = cnijTablePath(ppd: ppd),
let data = readFile(url) {
let map = cnijMediaQualityTable(
data, mediaIDs: mediaIDs, qualityIDs: qualityIDs)
if !map.isEmpty { return map }
}
return [:]
}
// MARK: - PPD keyword lookup
/// `*<key>: <value>` or `*<key>: "<value>"` → the value.
/// Exact key match — `*CNIJTableIDFoo:` must not satisfy a lookup
/// for `CNIJTableID`.
public static func ppdKeyword(_ ppd: String, _ key: String) -> String? {
for raw in ppd.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix("*\(key)") else { continue }
let rest = line.dropFirst(key.count + 1)
guard rest.first == ":" else { continue }
var value = rest.dropFirst()
.trimmingCharacters(in: .whitespaces)
if value.count >= 2,
value.hasPrefix("\""), value.hasSuffix("\"") {
value = String(value.dropFirst().dropLast())
}
if !value.isEmpty { return value }
}
return nil
}
// MARK: - Generic PPD constraints
/// `*UIConstraints:`/`*Constraints:` lines pair two conflicting
/// option choices; the ones naming both the media key and the
/// quality key produce forbidden (media, quality) pairs.
/// `*cupsUIConstraints` resolver triples do not match the
/// `*…Constraints:` prefixes, so they are skipped naturally.
public static func ppdUIConstraints(
_ ppd: String,
mediaKey: String,
qualityKey: String,
mediaIDs: Set<String>,
qualityIDs: Set<String>
) -> [String: Set<String>] {
var forbidden: [String: Set<String>] = [:]
for raw in ppd.split(separator: "\n") {
var line = raw.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("*UIConstraints:") {
line = String(line.dropFirst("*UIConstraints:".count))
} else if line.hasPrefix("*Constraints:") {
line = String(line.dropFirst("*Constraints:".count))
} else {
continue
}
let terms = constraintTerms(line)
guard let media = terms[mediaKey], mediaIDs.contains(media),
let quality = terms[qualityKey],
qualityIDs.contains(quality)
else { continue }
forbidden[media, default: []].insert(quality)
}
return forbidden
}
// MARK: - Epson PDEData.dat
/// `EPIJDriverBasePath` + `EPIJMachineBundleName` → the machine
/// bundle's `Contents/Resources/PDEData.dat` (uniform across the
/// InkjetPrinter2 family).
public static func epijPDEDataPath(ppd: String) -> URL? {
guard let base = ppdKeyword(ppd, "EPIJDriverBasePath"),
let bundle = ppdKeyword(ppd, "EPIJMachineBundleName")
else { return nil }
return URL(fileURLWithPath: base)
.appendingPathComponent("Machine")
.appendingPathComponent(bundle)
.appendingPathComponent("Contents/Resources/PDEData.dat")
}
/// `*EPIJUIConstraint:` forbidden-pair rules → (media, quality)
/// pairs the driver greys out. A rule *fires* when its LHS
/// `*<mediaKey>` term equals the media (absent media term →
/// applies to every media) and every other LHS term's value
/// equals that key's `lpoptions` default. A term whose key the
/// queue does not advertise counts as satisfied — hiding a usable
/// quality is a soft restriction, while showing an invalid one
/// re-creates the reported print failure.
public static func epijUIConstraints(
_ dat: String,
mediaKey: String,
qualityKey: String,
mediaIDs: Set<String>,
qualityIDs: Set<String>,
defaults: [String: String]
) -> [String: Set<String>] {
var forbidden: [String: Set<String>] = [:]
for raw in dat.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix("*EPIJUIConstraint:"),
let bar = line.firstIndex(of: "|")
else { continue }
let rhs = constraintTerms(
String(line[line.index(after: bar)...]))
guard let quality = rhs[qualityKey],
qualityIDs.contains(quality)
else { continue }
let lhs = constraintTerms(
String(line[
line.index(
line.startIndex,
offsetBy: "*EPIJUIConstraint:".count)..<bar]))
// Non-media terms must hold at the queue's defaults;
// a key absent from `defaults` is satisfied (see above).
let fires = lhs.allSatisfy { key, value in
key == mediaKey || value.isEmpty
|| (defaults[key].map { $0 == value } ?? true)
}
guard fires else { continue }
if let media = lhs[mediaKey] {
guard mediaIDs.contains(media) else { continue }
forbidden[media, default: []].insert(quality)
} else {
for media in mediaIDs {
forbidden[media, default: []].insert(quality)
}
}
}
return forbidden
}
// MARK: - Canon cnb table
/// `CNIJNameTblPath` + `CNIJTableID` → `cnb_<TableID>0.tbl`.
public static func cnijTablePath(ppd: String) -> URL? {
guard let dir = ppdKeyword(ppd, "CNIJNameTblPath"),
let tableID = ppdKeyword(ppd, "CNIJTableID")
else { return nil }
return URL(fileURLWithPath: dir)
.appendingPathComponent("cnb_\(tableID)0.tbl")
}
/// Scans the Canon table for 20-byte LE records anchored on
/// `30 00 01 00 00 00 00 00`: `{u16 family, u16 flag,
/// u32 mediaID, u16 pad, u16 quality}` follows the anchor.
/// A row counts only when `pad == 0`, `mediaID & 0xFFFF` is a
/// listed media id, and `quality` is a listed quality id — so
/// unrelated tables cannot inject false entries. Records carry a
/// `family` field (0x03 on the Pro9500 II); the modal family is
/// used so sibling record layouts in the same file are ignored.
/// Returns allowed (not forbidden) sets directly.
public static func cnijMediaQualityTable(
_ data: Data,
mediaIDs: Set<String>,
qualityIDs: Set<String>
) -> [String: Set<String>] {
let mediaNums = Set(mediaIDs.compactMap { UInt32($0) })
let qualityNums = Set(qualityIDs.compactMap { UInt16($0) })
guard !mediaNums.isEmpty, !qualityNums.isEmpty else {
return [:]
}
let anchor: [UInt8] = [0x30, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00]
var rows: [UInt16: [(media: UInt32, quality: UInt16)]] = [:]
data.withUnsafeBytes { buffer in
guard let base = buffer.baseAddress else { return }
var i = 0
while i + 20 <= buffer.count {
if memcmp(base + i, anchor, anchor.count) == 0 {
let family = readU16(base, i + 8)
let media = readU32(base, i + 12)
let pad = readU16(base, i + 16)
let quality = readU16(base, i + 18)
let baseMedia = media & 0xFFFF
if pad == 0,
mediaNums.contains(baseMedia),
qualityNums.contains(quality) {
rows[family, default: []].append(
(baseMedia, quality))
}
}
i += 2
}
}
guard let dominant = rows.max(by: { $0.value.count < $1.value.count })
else { return [:] }
var map: [String: Set<String>] = [:]
for row in dominant.value {
map[String(row.media), default: []].insert(String(row.quality))
}
// Keep strict subsets only — a media with the full set (or an
// empty one) is unconstrained as far as the UI is concerned.
map = map.filter { _, ids in
!ids.isEmpty && ids != qualityIDs
}
return map
}
// MARK: - Shared
/// `*<key> <value>` term extraction for both PPD and Epson
/// constraint syntax. A `*<key>` not followed by a value is a
/// wildcard term (empty-string value → always satisfied).
private static func constraintTerms(_ text: String) -> [String: String] {
var terms: [String: String] = [:]
let tokens = text.split(separator: " ").map(String.init)
var index = 0
while index < tokens.count {
guard tokens[index].hasPrefix("*") else {
index += 1
continue
}
let key = String(tokens[index].dropFirst())
if index + 1 < tokens.count,
!tokens[index + 1].hasPrefix("*") {
terms[key] = tokens[index + 1]
index += 2
} else {
terms[key] = ""
index += 1
}
}
return terms
}
/// forbidden (media → quality ids) → allowed map entries,
/// emitting only strict non-empty subsets: a media whose allowed
/// set is empty or equals the full roster is unconstrained as far
/// as the picker is concerned.
private static func allowedMap(
forbidden: [String: Set<String>],
mediaIDs: Set<String>,
qualityIDs: Set<String>
) -> [String: Set<String>] {
var map: [String: Set<String>] = [:]
for media in mediaIDs {
let allowed = qualityIDs.subtracting(forbidden[media] ?? [])
if !allowed.isEmpty, allowed != qualityIDs {
map[media] = allowed
}
}
return map
}
private static func readU16(_ base: UnsafeRawPointer, _ offset: Int) -> UInt16 {
UInt16(base.load(fromByteOffset: offset, as: UInt8.self))
| UInt16(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8
}
private static func readU32(_ base: UnsafeRawPointer, _ offset: Int) -> UInt32 {
UInt32(base.load(fromByteOffset: offset, as: UInt8.self))
| UInt32(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8
| UInt32(base.load(fromByteOffset: offset + 2, as: UInt8.self)) << 16
| UInt32(base.load(fromByteOffset: offset + 3, as: UInt8.self)) << 24
}
}
@@ -107,6 +107,11 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
public var qualities: [PrinterQuality] public var qualities: [PrinterQuality]
/// The `*`-marked default choice from `lpoptions -l`, if any. /// The `*`-marked default choice from `lpoptions -l`, if any.
public var qualityDefault: String? public var qualityDefault: String?
/// Media-type id → the quality ids the driver accepts for it
/// (#214 — `MediaQualityConstraints`). Empty when the driver
/// exposes no per-media quality matrix; a missing entry means
/// "unconstrained" — show everything.
public var qualityIDsByMediaType: [String: Set<String>]
public init( public init(
trays: [PrinterTray] = [], trays: [PrinterTray] = [],
@@ -115,7 +120,8 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
supportsOrientation: Bool = true, supportsOrientation: Bool = true,
qualityKey: String? = nil, qualityKey: String? = nil,
qualities: [PrinterQuality] = [], qualities: [PrinterQuality] = [],
qualityDefault: String? = nil qualityDefault: String? = nil,
qualityIDsByMediaType: [String: Set<String>] = [:]
) { ) {
self.trays = trays self.trays = trays
self.paperSizes = paperSizes self.paperSizes = paperSizes
@@ -124,6 +130,35 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
self.qualityKey = qualityKey self.qualityKey = qualityKey
self.qualities = qualities self.qualities = qualities
self.qualityDefault = qualityDefault self.qualityDefault = qualityDefault
self.qualityIDsByMediaType = qualityIDsByMediaType
}
/// Qualities valid for `mediaID`, preserving the driver's listing
/// order (#180). Falls back to the full list when the driver has
/// no per-media data, the media is absent from the map, or the
/// filter would empty the picker (#214).
public func qualities(forMediaType mediaID: String?) -> [PrinterQuality] {
guard let mediaID,
let allowed = qualityIDsByMediaType[mediaID],
!allowed.isEmpty
else { return qualities }
let filtered = qualities.filter { allowed.contains($0.id) }
return filtered.isEmpty ? qualities : filtered
}
/// Whether `qualityID` is usable with `mediaID` — `true` whenever
/// the driver exposes no constraint for that media (#214). A
/// quality absent from `qualities` entirely is still allowed: a
/// driver-captured token can be legitimate even when `lpoptions`
/// never listed it.
public func allowsQuality(
_ qualityID: String, forMediaType mediaID: String?
) -> Bool {
guard let mediaID,
let allowed = qualityIDsByMediaType[mediaID],
!allowed.isEmpty
else { return true }
return allowed.contains(qualityID)
} }
} }
+5 -4
View File
@@ -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).
+7 -1
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()
@@ -25,7 +25,15 @@ final class PrintSessionViewModel: ObservableObject {
} }
@Published var printerCaps = PrinterCapabilities() @Published var printerCaps = PrinterCapabilities()
@Published var selectedTray: Int? @Published var selectedTray: Int?
@Published var selectedMediaType: String? @Published var selectedMediaType: String? {
didSet {
// A media switch can invalidate the current quality pick —
// re-clamp into the driver's allowed set (#214).
if selectedMediaType != oldValue {
clampQualityToMedia()
}
}
}
/// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183). /// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183).
@Published var selectedPaperSize: Int? @Published var selectedPaperSize: Int?
/// Print-quality option token, e.g. `"303"` (#183). /// Print-quality option token, e.g. `"303"` (#183).
@@ -123,9 +131,16 @@ final class PrintSessionViewModel: ObservableObject {
if selectedTray == nil { if selectedTray == nil {
selectedTray = printerCaps.trays.first?.id selectedTray = printerCaps.trays.first?.id
} }
// Quality seeds inside the selected media's allowed set
// (#214): driver default when valid there, else the first
// allowed choice.
if selectedQuality == nil { if selectedQuality == nil {
selectedQuality = printerCaps.qualityDefault let allowed = availableQualities
?? printerCaps.qualities.first?.id selectedQuality = printerCaps.qualityDefault.flatMap { d in
allowed.contains(where: { $0.id == d }) ? d : nil
} ?? allowed.first?.id
} else {
clampQualityToMedia()
} }
// Caps reload is a re-mirror trigger for the paper picker // Caps reload is a re-mirror trigger for the paper picker
// (#183 E4) — pageSize + printer changes route here too. // (#183 E4) — pageSize + printer changes route here too.
@@ -137,6 +152,32 @@ final class PrintSessionViewModel: ObservableObject {
// MARK: - Paper / quality selection (#183) // MARK: - Paper / quality selection (#183)
/// The quality picker's source (#214): `printerCaps.qualities`
/// filtered to what the driver accepts for `selectedMediaType`.
/// Equals the full list whenever the queue exposes no per-media
/// quality matrix — and never empty while `qualities` is not.
var availableQualities: [PrinterQuality] {
printerCaps.qualities(forMediaType: selectedMediaType)
}
/// Keep `selectedQuality` inside the allowed set for the current
/// media: keep the pick when still valid, else the driver default
/// when valid, else the first allowed choice (#214).
private func clampQualityToMedia() {
let allowed = availableQualities
guard !allowed.isEmpty else { return }
if let quality = selectedQuality,
allowed.contains(where: { $0.id == quality }) {
return
}
if let fallback = printerCaps.qualityDefault,
allowed.contains(where: { $0.id == fallback }) {
selectedQuality = fallback
} else {
selectedQuality = allowed.first?.id
}
}
/// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`: /// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
/// a capability whose name matches `pageSize.rawValue` → its id; /// a capability whose name matches `pageSize.rawValue` → its id;
/// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`); /// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
@@ -230,7 +271,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 +404,15 @@ 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) {
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 +428,7 @@ final class PrintSessionViewModel: ObservableObject {
paperSize: selectedPaperSizeToken, paperSize: selectedPaperSizeToken,
mediaType: selectedMediaType, mediaType: selectedMediaType,
qualityKey: printerCaps.qualityKey, qualityKey: printerCaps.qualityKey,
quality: selectedQuality, quality: quality,
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)
} }
+4 -2
View File
@@ -410,11 +410,13 @@ struct Stage2View: View {
.accessibilityIdentifier("paperSizeGroup") .accessibilityIdentifier("paperSizeGroup")
} }
/// Quality picker — driver tokens with PPD-enriched labels (#183). /// Quality picker — driver tokens with PPD-enriched labels (#183),
/// filtered to what the driver accepts for the selected media
/// (#214).
private var qualityGroup: some View { private var qualityGroup: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Picker("Quality", selection: $workflow.print.selectedQuality) { Picker("Quality", selection: $workflow.print.selectedQuality) {
ForEach(workflow.print.printerCaps.qualities, id: \.id) { ForEach(workflow.print.availableQualities, id: \.id) {
Text($0.name).tag(Optional($0.id)) Text($0.name).tag(Optional($0.id))
} }
} }
@@ -0,0 +1,311 @@
import XCTest
import Foundation
@testable import ICCeryCore
/// #214 — media → allowed-quality resolution: generic PPD
/// `*UIConstraints`, Epson `PDEData.dat` `*EPIJUIConstraint` rules,
/// and the Canon `cnb_*.tbl` record scan. All fixtures inline /
/// injected — no installed drivers touched.
final class MediaQualityConstraintsTests: XCTestCase {
/// Epson-flavoured `lpoptions -l` listings: the XP-55 advertises
/// the conditional LHS keys (`EPIJ_PSrc`/`EPIJ_FdSo`/`EPIJ_Ink_`)
/// with defaults 2/2/1.
private var epsonListings: [CupsOptionListing] {
CupsParsers.lpoptionsList("""
PageSize/Media Size: 4x6 5x7 *A4 Letter
EPIJ_Medi/Media Type: *0 92 13 15 145 12 2 75 26 76 93
EPIJ_PSrc/Page Setup: *2 3 25
EPIJ_FdSo/Paper Source: *2 3 12
EPIJ_Ink_/Grayscale: *1 0
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
""")
}
private var epsonQualityIDs: Set<String> {
["301", "302", "303", "304", "305", "307", "308"]
}
// MARK: - ppdKeyword
func testPPDKeywordQuotedAndBare() {
let ppd = """
*EPIJDriverBasePath: "/Library/Printers/EPSON/InkjetPrinter2"
*EPIJMachineBundleName: "EP14C0605W.data"
*CNIJTableID: 354
*CNIJTableIDFoo: 999
"""
XCTAssertEqual(
MediaQualityConstraints.ppdKeyword(ppd, "EPIJDriverBasePath"),
"/Library/Printers/EPSON/InkjetPrinter2")
XCTAssertEqual(
MediaQualityConstraints.ppdKeyword(ppd, "EPIJMachineBundleName"),
"EP14C0605W.data")
XCTAssertEqual(
MediaQualityConstraints.ppdKeyword(ppd, "CNIJTableID"), "354")
// Prefix-safety: CNIJTableIDFoo must not satisfy the lookup.
XCTAssertNil(MediaQualityConstraints.ppdKeyword(ppd, "CNIJTableIDX"))
XCTAssertNil(MediaQualityConstraints.ppdKeyword(ppd, "Missing"))
}
// MARK: - Path derivation
func testEPIJPDEDataPath() {
let ppd = """
*EPIJDriverBasePath: "/Library/Printers/EPSON/InkjetPrinter2"
*EPIJMachineBundleName: "EP14C0605W.data"
"""
XCTAssertEqual(
MediaQualityConstraints.epijPDEDataPath(ppd: ppd)?.path,
"/Library/Printers/EPSON/InkjetPrinter2/Machine/"
+ "EP14C0605W.data/Contents/Resources/PDEData.dat")
XCTAssertNil(MediaQualityConstraints.epijPDEDataPath(
ppd: "*CNIJTableID: 354\n"))
}
func testCNIJTablePath() {
let ppd = """
*CNIJNameTblPath: "/Library/Printers/Canon/BJPrinter/Resources/Database/CIJPro9500IIseries.db/Contents/Resources"
*CNIJTableID: 354
"""
XCTAssertEqual(
MediaQualityConstraints.cnijTablePath(ppd: ppd)?.path,
"/Library/Printers/Canon/BJPrinter/Resources/Database/"
+ "CIJPro9500IIseries.db/Contents/Resources/cnb_3540.tbl")
XCTAssertNil(MediaQualityConstraints.cnijTablePath(
ppd: "*EPIJMachineBundleName: \"x\"\n"))
}
// MARK: - Generic PPD UIConstraints
func testPPDUIConstraintsBothOrders() {
let ppd = """
*UIConstraints: *MediaType Glossy *PrintQuality Draft
*UIConstraints: *PrintQuality Draft *MediaType Matte
*Constraints: *MediaType Glossy *PrintQuality Low
*UIConstraints: *InputSlot Rear *PrintQuality Draft
"""
let forbidden = MediaQualityConstraints.ppdUIConstraints(
ppd, mediaKey: "MediaType", qualityKey: "PrintQuality",
mediaIDs: ["Glossy", "Matte"],
qualityIDs: ["Draft", "Low", "High"])
XCTAssertEqual(forbidden["Glossy"], ["Draft", "Low"])
XCTAssertEqual(forbidden["Matte"], ["Draft"])
}
// MARK: - Epson EPIJUIConstraint
/// Realistic XP-55 fragment: pure-media rules plus conditional
/// rules on `EPIJ_PSrc`/`EPIJ_Ink_` evaluated against defaults.
func testEPIJConstraintsAgainstDefaults() {
let dat = """
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 307
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 308
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 303
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 304
*EPIJUIConstraint: *EPIJ_PSrc 3 *EPIJ_Medi 0|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_PSrc 2 *EPIJ_Medi 0 *EPIJ_Ink_ 0|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_PSrc 3 *EPIJ_Medi 92|*EPIJ_Qual 999
"""
let forbidden = MediaQualityConstraints.epijUIConstraints(
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
mediaIDs: ["0", "15", "92"], qualityIDs: epsonQualityIDs,
defaults: ["EPIJ_PSrc": "2", "EPIJ_Ink_": "1"])
// Media 0: pure rules forbid 305/307/308; the PSrc=3 rule is
// inert at default PSrc=2 (301 survives); the PSrc=2 rule
// additionally requires Ink_=0 — default is 1, so inert too
// (302 survives).
XCTAssertEqual(forbidden["0"], ["305", "307", "308"])
XCTAssertEqual(forbidden["15"], ["301", "302", "303", "304"])
// 999 is not a listed quality id — ignored outright.
XCTAssertEqual(forbidden["92"], ["301"])
}
/// A conditional rule whose extra terms all match the defaults
/// fires like a pure-media rule.
func testEPIJConditionalRuleFiresAtDefaults() {
let dat = """
*EPIJUIConstraint: *EPIJ_PSrc 2 *EPIJ_Medi 0|*EPIJ_Qual 304
"""
let forbidden = MediaQualityConstraints.epijUIConstraints(
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
mediaIDs: ["0"], qualityIDs: epsonQualityIDs,
defaults: ["EPIJ_PSrc": "2"])
XCTAssertEqual(forbidden["0"], ["304"])
}
/// A rule with no media term applies to every listed media; a term
/// on a key the queue does not advertise counts as satisfied
/// (conservative-forbid — #214 semantics).
func testEPIJGlobalAndUnknownKeyRules() {
let dat = """
*EPIJUIConstraint: *EPIJ_Ink_ 1|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_Mode 9 *EPIJ_Medi 0|*EPIJ_Qual 307
"""
let forbidden = MediaQualityConstraints.epijUIConstraints(
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
mediaIDs: ["0", "15"], qualityIDs: epsonQualityIDs,
defaults: ["EPIJ_Ink_": "1"])
// Global rule: Ink_=1 matches default → 305 forbidden on both.
XCTAssertTrue(forbidden["0"]!.contains("305"))
XCTAssertTrue(forbidden["15"]!.contains("305"))
// EPIJ_Mode is unlisted → satisfied → (0, 307) forbidden too.
XCTAssertTrue(forbidden["0"]!.contains("307"))
}
/// The end-to-end XP-55 expectation through `resolve` — the exact
/// sets the user reported from the driver PDE.
func testResolveEpsonProducesDriverSets() {
let dat = """
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 307
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 308
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 303
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 304
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 308
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 303
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 304
"""
let ppd = """
*EPIJDriverBasePath: "/drivers/epson"
*EPIJMachineBundleName: "EP14C0605W.data"
"""
let datURL = URL(fileURLWithPath:
"/drivers/epson/Machine/EP14C0605W.data/"
+ "Contents/Resources/PDEData.dat")
let map = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
readFile: { $0 == datURL ? dat.data(using: .utf8) : nil })
// Plain paper: Fast Economy / Economy / Normal / Fine.
XCTAssertEqual(map["0"], ["301", "302", "303", "304"])
// Premium Semigloss: Quality / Best Quality / Draft.
XCTAssertEqual(map["15"], ["305", "307", "308"])
// Ultra Glossy drops Fast Economy…Fine and Draft.
XCTAssertEqual(map["92"], ["305", "307"])
// Unconstrained media (no rules) produce no entry.
XCTAssertNil(map["13"])
}
// MARK: - Canon cnb table
/// Builds one 20-byte Canon record: `30 00 01 00 00 00 00 00`,
/// `family:u16`, `flag:u16`, `media:u32`, `pad:u16`, `quality:u16`.
private func canonRecord(
family: UInt16 = 3, flag: UInt16 = 0,
media: UInt32, pad: UInt16 = 0, quality: UInt16
) -> Data {
var d = Data([0x30, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00])
d.append(contentsOf: [UInt8(family & 0xFF), UInt8(family >> 8)])
d.append(contentsOf: [UInt8(flag & 0xFF), UInt8(flag >> 8)])
d.append(contentsOf: [
UInt8(media & 0xFF), UInt8((media >> 8) & 0xFF),
UInt8((media >> 16) & 0xFF), UInt8((media >> 24) & 0xFF)])
d.append(contentsOf: [UInt8(pad & 0xFF), UInt8(pad >> 8)])
d.append(contentsOf: [UInt8(quality & 0xFF), UInt8(quality >> 8)])
return d
}
func testCNBTableScan() {
var data = Data()
// Junk prefix — even length keeps the records 2-byte aligned.
data.append(contentsOf: [0xFF, 0x00])
// media 0 → {0, 10, 15, 20} across flag 0/1 variants.
for q: UInt16 in [10, 15, 20, 0] {
data.append(canonRecord(media: 0, quality: q))
}
data.append(canonRecord(flag: 1, media: 0, quality: 10))
// media 50 → {0, 5, 10}; plus its borderless variant 0x10032.
for q: UInt16 in [0, 5, 10] {
data.append(canonRecord(media: 50, quality: q))
}
data.append(canonRecord(media: 0x10032, quality: 0))
// Noise: unknown media, unknown quality, nonzero pad, and a
// different record family that must lose the modal vote.
data.append(canonRecord(media: 999, quality: 5))
data.append(canonRecord(media: 50, quality: 7))
data.append(canonRecord(media: 50, pad: 1, quality: 15))
data.append(canonRecord(family: 9, media: 50, quality: 15))
data.append(Data([0x11, 0x22, 0x33]))
let map = MediaQualityConstraints.cnijMediaQualityTable(
data,
mediaIDs: ["0", "50", "42"],
qualityIDs: ["0", "5", "10", "15", "20"])
XCTAssertEqual(map["0"], ["0", "10", "15", "20"])
XCTAssertEqual(map["50"], ["0", "5", "10"])
XCTAssertNil(map["42"])
}
func testCNBGarbageFailsOpen() {
let map = MediaQualityConstraints.cnijMediaQualityTable(
Data((0..<4096).map { _ in UInt8.random(in: 0...255) }),
mediaIDs: ["0", "50"], qualityIDs: ["0", "5", "10"])
XCTAssertTrue(map.isEmpty)
}
// MARK: - resolve ordering / fallback
func testResolvePrefersPPDConstraints() {
let ppd = """
*UIConstraints: *EPIJ_Medi 0 *EPIJ_Qual 305
*EPIJDriverBasePath: "/should/not/be/read"
*EPIJMachineBundleName: "x.data"
"""
var reads = 0
let map = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
readFile: { _ in reads += 1; return nil })
XCTAssertEqual(map["0"], epsonQualityIDs.subtracting(["305"]))
// The Epson path is never touched once PPD constraints hit.
XCTAssertEqual(reads, 0)
}
func testResolveNoPPDOrKeysFailsOpen() {
XCTAssertTrue(MediaQualityConstraints.resolve(
listings: epsonListings, ppd: nil).isEmpty)
// No media key in the roster → no map.
XCTAssertTrue(MediaQualityConstraints.resolve(
listings: CupsParsers.lpoptionsList(
"PageSize/Media Size: *A4 Letter\n"),
ppd: "*CNIJTableID: 354\n").isEmpty)
}
// MARK: - PrinterCapabilities accessors
func testCapabilitiesAccessorFallbacks() {
var caps = PrinterCapabilities(
mediaTypes: [
PrinterMediaType(id: "0", name: "Plain"),
PrinterMediaType(id: "15", name: "Semigloss"),
],
qualities: ["301", "302", "303", "308", "304", "305", "307"]
.map { PrinterQuality(id: $0, name: "Q\($0)") })
// No map → everything, in driver order (#180).
XCTAssertEqual(caps.qualities(forMediaType: "0").map(\.id),
["301", "302", "303", "308", "304", "305", "307"])
XCTAssertTrue(caps.allowsQuality("305", forMediaType: "0"))
caps.qualityIDsByMediaType = [
"0": ["301", "302", "303", "304"],
"15": ["305", "307", "308"],
]
XCTAssertEqual(caps.qualities(forMediaType: "0").map(\.id),
["301", "302", "303", "304"])
// Driver order preserved — 308 sits before 304 in lpoptions.
XCTAssertEqual(caps.qualities(forMediaType: "15").map(\.id),
["308", "305", "307"])
XCTAssertTrue(caps.allowsQuality("308", forMediaType: "15"))
XCTAssertFalse(caps.allowsQuality("308", forMediaType: "0"))
// Unknown media → unconstrained.
XCTAssertEqual(caps.qualities(forMediaType: "99").count, 7)
XCTAssertTrue(caps.allowsQuality("305", forMediaType: nil))
}
}
+142 -37
View File
@@ -40,21 +40,8 @@ final class NativeSpoolPDFTests: XCTestCase {
pixelBytes: patchBytes, in: tempRoot) 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
}
} }
@@ -408,6 +408,169 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty) XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty)
} }
// MARK: - Media-aware quality filtering (#214)
/// Constrains the loaded fixture caps: `Stationery` allows
/// {301,302,303,304}, `Glossy` allows {305,307}, `Matte` is
/// unconstrained. Mirrors the Epson `EPIJUIConstraint` matrix.
private func constrainCaps(_ vm: PrintSessionViewModel) {
var caps = vm.printerCaps
caps.qualityIDsByMediaType = [
"Stationery": ["301", "302", "303", "304"],
"Glossy": ["305", "307"],
]
vm.printerCaps = caps
}
/// Media switch → an invalid quality pick re-seeds to the first
/// allowed choice; the picker source shrinks to the media's set.
func testMediaChangeClampsInvalidQuality() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print)
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
XCTAssertEqual(workflow.print.selectedQuality, "303")
workflow.print.selectedMediaType = "Glossy"
XCTAssertEqual(workflow.print.selectedQuality, "305")
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
["305", "307"])
}
/// A still-valid pick survives a media switch; an unconstrained
/// media keeps the pick too.
func testMediaChangeKeepsValidQuality() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print)
workflow.print.selectedQuality = "302"
workflow.print.selectedMediaType = "Matte" // unconstrained
XCTAssertEqual(workflow.print.selectedQuality, "302")
workflow.print.selectedMediaType = "Glossy" // 302 invalid
XCTAssertEqual(workflow.print.selectedQuality, "305")
workflow.print.selectedQuality = "307"
workflow.print.selectedMediaType = "Stationery" // 307 invalid
// Driver default 303 is allowed there → preferred over first.
XCTAssertEqual(workflow.print.selectedQuality, "303")
}
/// Seeding lands inside the allowed set even when the driver
/// default quality is invalid for the first media — end to end
/// through `CupsService.capabilities(for:)` + a fixture
/// `PDEData.dat` (#214).
func testQualitySeedRespectsMediaMap() async throws {
let ppdDir = env.root.appendingPathComponent("ppd")
let epsonRoot = env.root.appendingPathComponent("epson-driver")
let datDir = epsonRoot.appendingPathComponent(
"Machine/M.data/Contents/Resources")
try FileManager.default.createDirectory(
at: ppdDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: datDir, withIntermediateDirectories: true)
try """
*EPIJDriverBasePath: "\(epsonRoot.path)"
*EPIJMachineBundleName: "M.data"
""".write(
to: ppdDir.appendingPathComponent("Mock_Q.ppd"),
atomically: true, encoding: .utf8)
// Stationery forbids everything except 305/307 — including the
// `lpoptions` default 303.
try """
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 301
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 302
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 303
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 308
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 304
""".write(
to: datDir.appendingPathComponent("PDEData.dat"),
atomically: true, encoding: .utf8)
var environment = env.environment
environment = AppEnvironment(
stateStore: environment.stateStore,
settingsStore: environment.settingsStore,
presetStore: environment.presetStore,
runner: environment.runner,
cupsService: CupsService(
processManager: ProcessManager(),
binaryDir: binDir, ppdDir: ppdDir),
historyStore: environment.historyStore,
mediaStore: environment.mediaStore,
recentProjectsStore: environment.recentProjectsStore)
let workflow = TargetWorkflowViewModel(environment: environment)
workflow.print.selectedPrinter = "Mock_Q"
await workflow.print.reloadSelectedCapabilities()
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
XCTAssertEqual(workflow.print.printerCaps
.qualityIDsByMediaType["Stationery"], ["305", "307"])
// 303 is the driver default but invalid on Stationery → 305.
XCTAssertEqual(workflow.print.selectedQuality, "305")
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
["305", "307"])
}
/// No constraint map → every quality stays selectable on every
/// media (the pre-#214 behaviour, by design for unknown drivers).
func testUnconstrainedDriverKeepsAllQualities() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
workflow.print.selectedMediaType = "Glossy"
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
["301", "302", "303", "308", "304", "305", "307"])
XCTAssertEqual(workflow.print.selectedQuality, "303")
}
/// A captured quality invalid for the captured media is dropped —
/// the clamped selection stands.
func testPanelResultInvalidQualityDropped() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "EPIJ_Qual=305", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print) // Stationery forbids 305
XCTAssertEqual(workflow.print.selectedQuality, "303")
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.selectedQuality, "303")
}
/// `makeRequest` substitutes a stale-invalid quality before the
/// ticket is written — the last gate before the driver (#214).
func testSpoolSubstitutesStaleQuality() async throws {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print)
workflow.print.selectedMediaType = "Glossy"
workflow.print.selectedQuality = "308" // stale, invalid
workflow.print.spooler = RecordingTargetSpooler(
logURL: spoolLogURL)
let tiff = env.root.appendingPathComponent("page1.tif")
try Data([0x49, 0x49]).write(to: tiff)
let page = GalleryPage(
index: 0,
page: PrinttargPage(
filename: "page1.tif", patches: 10,
widthMm: 210, heightMm: 297),
fileURL: tiff, previewPNG: nil, previewError: nil)
workflow.print.printPage(page)
let log = await waitForFile(spoolLogURL)
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
XCTAssertFalse(log.contains("EPIJ_Qual=308"), log)
}
/// Poll until the panel task posts a notice whose text contains /// Poll until the panel task posts a notice whose text contains
/// `fragment` (the Task-completion signal for `nil` results too). /// `fragment` (the Task-completion signal for `nil` results too).
private func waitForNotice( private func waitForNotice(
@@ -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(
for offset in stride(from: 0, to: bytesPerRow, by: pattern.count) { 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")
@@ -251,6 +251,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`.
+4 -1
View File
@@ -71,7 +71,10 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor
| `media_types` | `Vec<PrinterMediaType>` | `#[serde(default)]`. Frontend fills `#printerMediaTypeSelect`. | | `media_types` | `Vec<PrinterMediaType>` | `#[serde(default)]`. Frontend fills `#printerMediaTypeSelect`. |
| `supports_orientation` | `bool` | Always `true` on both Windows and Unix. | | `supports_orientation` | `bool` | Always `true` on both Windows and Unix. |
### `PrintOptions` (mod.rs:42–55) — `Default` + `PartialEq + Eq` ### `PrintOptions` (mod.rs:42–55) — `Default` + `PartialEq + Eq` — historical v1
> The `lp`/`-o` columns below describe the v0.8.5 spool contract. v2.0
> replaced the macOS `lp` path with a headless `NSPrintOperation` (#201).
| Field | Type | Windows | Linux | macOS | | Field | Type | Windows | Linux | macOS |
|-------|------|---------|-------|-------| |-------|------|---------|-------|-------|
+91 -45
View File
@@ -19,7 +19,7 @@ This is the most implementation-sensitive chapter. A rewrite that opens System S
- `NSWorkspace` open of the printer - `NSWorkspace` open of the printer
- `lpoptions` GUI - `lpoptions` GUI
Linux/Windows do not share this panel. Default button title is **"Use Settings"** (macos.rs:439) — this is a settings-capture dialog, not a print-now dialog. Actual spooling is a later `lp` invocation. Linux/Windows do not share this panel. Default button title is **"Use Settings"** (macos.rs:439) — this is a settings-capture dialog, not a print-now dialog. Actual spooling is a later, separate step — in v2.0 a headless `NSPrintOperation` (#201; v1 used `lp`).
Must run on the Cocoa main thread. `show_printer_properties` (macos.rs:689-710): Must run on the Cocoa main thread. `show_printer_properties` (macos.rs:689-710):
@@ -68,34 +68,31 @@ if pm_printer was created from ID:
`PMPrinter` from `PMPrinterCreateFromPrinterID` is released with `PMRelease` on all exit paths (cancel, error, success). The NSPrinter fallback path leaves `pm_printer` null so no release. `PMPrinter` from `PMPrinterCreateFromPrinterID` is released with `PMRelease` on all exit paths (cancel, error, success). The NSPrinter fallback path leaves `pm_printer` null so no release.
### ColorSync suppression strategy — UI click to `lp` ### ColorSync suppression strategy — UI click to `NSPrintOperation`
End-to-end, **six independent layers**. All of them exist because no single Apple API is sufficient across Epson PDE / Canon PDE / `cgpdftoraster` / CUPS. End-to-end, **seven independent layers**. All of them exist because no single Apple API is sufficient across Epson PDE / Canon PDE / `cgpdftoraster` / CUPS. v2.0 (#201) replaced the `lp` tail with a headless `NSPrintOperation` that replays a captured `PMPrintSettings`/`PMPageFormat` ticket — layer ⑦ is new.
``` ```
[Preferences click] [Preferences click]
show_printer_properties PrintSessionViewModel.openPrinterPreferences
run_on_main_thread PrintPanelService.showProperties
run_native_print_panel runNativePanel
① PMSessionSetCurrentPMPrinter bind queue ① PMPrinterCreateFromPrinterID + PMSessionSetCurrentPMPrinter bind queue
② set_session_color_matching_mode SPI gray out PDE Color Matching ② set_session_color_matching_mode SPI gray out PDE Color Matching
③ PMPrintSettingsSetValue AP_ColorMatchingMode + dotted ③ PMPrintSettingsSetValue AP_ColorMatchingMode + dotted
④ detect_driver_color_bypass → SetValue pre-select Canon/Epson/Gutenprint "off" ④ detect_driver_color_bypass → SetValue pre-select Canon/Epson/Gutenprint "off"
⑤ NSPrintInfo.printSettings dictionary same keys for AppKit PDEs ⑤ NSPrintInfo.printSettings dictionary same keys for AppKit PDEs
NSPrintPanel.runModalWithPrintInfo ⑤′ ColorSyncSuppressor.applyQuartzMode PMColorMatchingMode + legacy + nested mirror
NSPrintPanel.runModal("Use Settings")
user picks media / quality (color locked) user picks media / quality (color locked)
⑥ PMPrintSettingsToOptions → filter → PrintPropertiesResult ⑥ PMPrintSettingsToOptions → filter → Stage 2 mirror
⑦ PMTicketBridge.serialise(printInfo) → PrintTicket ticket capture
[frontend] [frontend]
capturedCupsOptions[printer] = cups_options capturedCupsOptions[queue] = mirror + capturedTickets[queue] = ticket
[Print Target] [Print Target]
print_target_native → macos::print_target → build_lp_args PrintSessionViewModel → TargetPrintRequest → NativeTargetSpooler.spool
ALWAYS -o AP_ColorMatchingMode=AP_ApplicationColorMatching restore ticket → Stage 2 writes → NSPrintOperation.runOperation
ALWAYS -o AP.ColorMatchingMode=AP_ApplicationColorMatching (S1–S14 below — 1:1, device colour space, panels off)
THEN captured cups_options as -o k=v
THEN media_type if not already present (detected key)
THEN detect_driver_color_bypass if no color-bypass key yet
THEN orientation / PageSize if not already present
lp -d <queue> -t "ICCery Target - …" … <tiff>
``` ```
Linux uses `-o raw` instead of AP_* flags. macOS **does not** use `-o raw`: a raw queue would skip the raster filter that actually understands `AP_ColorMatchingMode`. The macOS strategy is "tell the filter the application already matched color", not "skip the filter". Linux uses `-o raw` instead of AP_* flags. macOS **does not** use `-o raw`: a raw queue would skip the raster filter that actually understands `AP_ColorMatchingMode`. The macOS strategy is "tell the filter the application already matched color", not "skip the filter".
@@ -161,7 +158,7 @@ AGENTS.md:118, macos.rs:82-88:
| `AP_ColorSyncMatching` | **Avoided.** ColorSync applies the printer/display profile. Patches become color-managed. | | `AP_ColorSyncMatching` | **Avoided.** ColorSync applies the printer/display profile. Patches become color-managed. |
| `AP_VendorColorMatching` | **Avoided.** Epson/Canon driver color engine (ICM inside the PDE). Same corruption. | | `AP_VendorColorMatching` | **Avoided.** Epson/Canon driver color engine (ICM inside the PDE). Same corruption. |
ICCery-CPU uses a **different** vocabulary (`APCustomColorMatching` / `APColorSync` / `APPrinterExtension` on `PMColorMatchingMode`). See the CPU section. Do not mix the two dictionaries. ICCery-CPU uses a **different** vocabulary (`APCustomColorMatching` / `APColorSync` / `APPrinterExtension` on `PMColorMatchingMode`). See the CPU section. v1 kept the two dictionaries on separate paths (`lp` vs Quartz); v2.0 (#201, D2) has a single native path that writes **both** vocabularies — layer ⑤′ below.
### Layer ③ — `PMPrintSettingsSetValue` (macos.rs:357-375) ### Layer ③ — `PMPrintSettingsSetValue` (macos.rs:357-375)
@@ -204,6 +201,18 @@ print_settings.insert(<bypass_key>, <bypass_val>)
`NSString` is transmuted to `&AnyObject` for the dictionary (`macos.rs:414-416`). `NSString` is transmuted to `&AnyObject` for the dictionary (`macos.rs:414-416`).
### Layer ⑤′ — Quartz vocabulary (v2.0, #201 D2)
With `lp` gone there is a single native path, and it carries **both**
dictionaries. `ColorSyncSuppressor.applyQuartzMode` additionally writes the
Quartz/`NSPrintOperation` vocabulary (docs/14 §7):
- `PMColorMatchingMode` = `APCustomColorMatching`
- `PMCustomColorMatchingProfile` = `""`
- `com.apple.print.PrintSettings.PMColorMatchingMode` (legacy)
- the same keys inside the nested `com.apple.print.printSettings`
sub-dictionary of `printInfo.dictionary()`
### Panel options (macos.rs:434-449) ### Panel options (macos.rs:434-449)
``` ```
@@ -231,9 +240,27 @@ After OK:
Failure of `PMPrintSettingsToOptions` is a hard `Err`. Failure of `PMPrintSettingsToOptions` is a hard `Err`.
### Layer ⑦ — ticket serialise/restore (v2.0, #201 D3)
The layer-⑥ flattening is what lost the ticket (#201 root cause 1): it kept
only a `key=value` string and `CupsOptionsFilter` drops every `com.apple.*`
key, so `com.apple.print.PrintSettings` never survived. After the layer-⑥
capture, `PMTicketBridge.serialise(printInfo, queue:)` now snapshots the
whole ticket:
1. `PMPrintSettingsCreateDataRepresentation(settings, &data, kPMDataFormatXMLDefault)` → `PrintTicket.printSettings`.
2. `PMPageFormatCreateDataRepresentation` → `PrintTicket.pageFormat`.
3. Binary-plist snapshot of `NSPrintInfo.dictionary()`, plist-filtered → `PrintTicket.printInfoPlist` — fallback only, never the primary restore path.
`PrintTicket` is in-memory, session-only, keyed by queue. On spool,
`PMTicketBridge.restore` replays it into the job's `NSPrintInfo`:
`PM*CreateWithDataRepresentation` → `PMCopy*` → `PMSessionValidate*` →
`updateFromPM*`. A ticket captured for queue A is never replayed onto
queue B.
### `RELEVANT_CUPS_OPTION_KEYS` (macos.rs:142-174) ### `RELEVANT_CUPS_OPTION_KEYS` (macos.rs:142-174)
Forwarded from the panel to `lp`: Captured from the panel into the Stage 2 mirror (v1 forwarded them to `lp`):
``` ```
Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType
@@ -255,31 +282,38 @@ Duplex: Duplex, sides
- Drops `collate`, `copies`, `pserrorhandler-requested`, `job-sheets` - Drops `collate`, `copies`, `pserrorhandler-requested`, `job-sheets`
- **Keeps unknown non-`com.*` keys** (permissive: unknown driver keys survive) - **Keeps unknown non-`com.*` keys** (permissive: unknown driver keys survive)
### `build_lp_args` (macos.rs:518-643) ### Native spool — S1–S14 (v2.0, #201)
Always, even with `options=None`: `NativeTargetSpooler.spool(_:)` (app target, `Sources/ICCery/Print/` — `ICCeryCore` stays AppKit-free, D1). One `TargetPrintRequest` per page, or one per run when `singleJobForAllPages` is on (D5):
``` ```
lp -d <printer> -t "ICCery Target - <filename>" S1 NSPrintInfo()
-o AP_ColorMatchingMode=AP_ApplicationColorMatching S2 printInfo.printer = NSPrinter(name: queue) ?? NSPrinter(name: displayName) [best effort]
-o AP.ColorMatchingMode=AP_ApplicationColorMatching S3 PMTicketBridge.makePrinter(queue:) → bind(printer:to:) ① + PMSessionDefault*
… captured / detected options … S4 ticket != nil → PMTicketBridge.restore(ticket, into: printInfo) ⑦′
<tiff_path> S5 TicketWriteResolver.resolve(...) → apply to PMPrintSettings + mirror dict ③④⑤+D2
S6 paper override → PMTicketBridge.applyPaper(token:…)
S7 orientation → printInfo.orientation + orientation-requested
S8 suppressor.applySPIMode(session) ②
S9 Cocoa geometry: margins 0, pagination .clip, scaling 1.0, centering off,
jobDisposition .spool (or .save + jobSavingURL under the PDF harness)
S10 TargetRasterLoader.load(each page) → device-tagged CGImage + pointSize
S11 TargetPageCanvasView(pages:paperSize: printInfo.paperSize)
S12 NSPrintOperation(view:printInfo:) — panels off, jobTitle set
S13 operation.runOperation() → false ⇒ throw TargetSpoolError.operationFailed
S14 PMRelease the printer on every path (defer)
``` ```
Order after the two AP_* flags: `TicketWriteResolver` produces the resolved write list as a pure value, in a
locked order: both `AP_*` keys (locked) → the three Quartz keys (⑤′) →
1. Parse `opts.cups_options` into `-o k=v`, record lowercased keys in `added_keys`. `PageSize` → the detected media key → the detected quality key → the driver
2. If `media_type` set and none of `mediatype` / `cnijmediatype` / `epij_medi` / `stpmediatype` already added: `detect_media_type_key(lpoptions)` and add it. colour bypass → `orientation-requested`. Stage 2 overrides **always win**
3. If no color-bypass key yet (`cnijintent2`, `cnijintent`, `epij_cmat`, `epij_ccor`, `epij_oscolmat`, `colorcorrection`, `stpcolorcorrection`, `epsoncolormode`): `detect_driver_color_bypass` and add. **Not gated on `ppd_uncorrected_passthrough`.** over the rehydrated ticket (D6 — the v1 captured-wins inversion is gone);
4. Orientation → `orientation-requested=4|3` unless already present. `raw` can never appear because there is no `lp`. Panels stay off
5. `PageSize=` unless `pagesize` already present. (`showsPrintPanel` / `showsProgressPanel` false, `canSpawnSeparateThread`
false); a multi-page job is one `TargetPageCanvasView` driven by
`ppd_uncorrected_passthrough` is stored from the panel but **does not change macOS lp flags**. There is no `-o raw` on macOS. `knowsPageRange` / `rectForPage`, drawing each page 1:1 top-left anchored
with interpolation and antialiasing disabled (D9, docs/14 §6).
### `print_target` (macos.rs:646-677)
Exists-check, `build_lp_args`, `Command::new("lp").args(&args).output()`. Error wrapping same pattern as Unix (`"macOS CUPS print job failed: …"`).
### Cancellation as `None` ### Cancellation as `None`
@@ -295,19 +329,26 @@ The rewrite:
- `display_name` fallback for `NSPrinter::printerWithName`. - `display_name` fallback for `NSPrinter::printerWithName`.
- Private SPI to lock Color Matching. - Private SPI to lock Color Matching.
- Dual AP_* keys (underscore + dotted). - Dual AP_* keys (underscore + dotted).
- Driver-specific PPD bypass pre-selected and re-applied on `lp`. - Driver-specific PPD bypass pre-selected in the panel and re-applied on the spool ticket (v1 re-applied it on `lp`).
- Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions`. - Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions` (Stage 2 mirror) plus the `PrintTicket` serialise (layer ⑦, v2.0).
`PMPrinter` lifetime is explicit `PMRelease` on every path. SPI is `dlsym`'d so missing symbols on old OS X do not prevent launch. Panel **must** be main-thread (`MainThreadMarker::new().ok_or("Print panel must be invoked on the main thread")`). `PMPrinter` lifetime is explicit `PMRelease` on every path. SPI is `dlsym`'d so missing symbols on old OS X do not prevent launch. Panel **must** be main-thread (`MainThreadMarker::new().ok_or("Print panel must be invoked on the main thread")`).
### macOS tests (macos.rs:712-863 + tests.rs:278-318) ### macOS tests — historical v1 (macos.rs:712-863 + tests.rs:278-318)
- Filter drops `com.apple.*`, `collate`, `copies`, `AP_ColorMatchingMode`, empty `AP_D_InputSlot`; keeps `MediaType`, `EPIJ_CMat`, `PageSize`, `CNIJIntent2`, `ColorCorrection`. - Filter drops `com.apple.*`, `collate`, `copies`, `AP_ColorMatchingMode`, empty `AP_D_InputSlot`; keeps `MediaType`, `EPIJ_CMat`, `PageSize`, `CNIJIntent2`, `ColorCorrection`.
- `extract_media_type_from_options` prefers `MediaType` then `EPIJ_Medi`. - `extract_media_type_from_options` prefers `MediaType` then `EPIJ_Medi`.
- `build_lp_args` always contains both AP_* flags; captured options win over explicit `media_type` / orientation / auto color-bypass; last arg is the TIFF path. - `build_lp_args` always contains both AP_* flags; captured options win over explicit `media_type` / orientation / auto color-bypass; last arg is the TIFF path. (**Historical v1** — the captured-wins behaviour recorded here is #201 root cause 2; v2.0 inverts it: Stage 2 always wins, D6.)
- `detect_driver_color_bypass` Canon `4`, Epson `3`, Gutenprint `Uncorrected`. - `detect_driver_color_bypass` Canon `4`, Epson `3`, Gutenprint `Uncorrected`.
- Missing TIFF errors. - Missing TIFF errors.
v2.0 replacements (`TicketWriteResolverTests`, `PrintTicketTests`,
`TargetRasterTests`, `TargetCanvasGeometryTests`, `NativeSpoolPDFTests`):
both AP_* keys locked + all three Quartz keys always present; Stage 2
always wins (D6); `raw` never emitted; ticket serialise/restore
round-trips bytes; 1:1 geometry and draw flags asserted against a real
`.save`-to-PDF `NSPrintOperation`.
--- ---
## ColorSync suppression — complete key/SPI/flag roster ## ColorSync suppression — complete key/SPI/flag roster
@@ -338,7 +379,12 @@ PMRelease
AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPanelOptions::all` + `ShowsPageSetupAccessory`. AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPanelOptions::all` + `ShowsPageSetupAccessory`.
### CUPS / lp flags ### CUPS / lp flags — historical v1
> No `lp` invocation exists on the v2.0 target-print path (#201 — `LpArgs`,
> `CupsService.printTarget` and the `lp` fixture are deleted). This table
> records the v1 `lp -o` contract for reference only; the live write list is
> `TicketWriteResolver`'s locked order (§native spool above).
| Flag | Platform | When | | Flag | Platform | When |
|------|----------|------| |------|----------|------|
+4 -1
View File
@@ -53,7 +53,10 @@ First-match order:
`CNIJMediaType` > `EPIJ_Medi` > `StpMediaType` > `MediaType`. `CNIJMediaType` > `EPIJ_Medi` > `StpMediaType` > `MediaType`.
### Linux `build_lp_args` / `print_target` (unix.rs:362-457) ### Linux `build_lp_args` / `print_target` (unix.rs:362-457) — historical v1
> v0.8.5 Linux spool contract, kept for reference. ICCery v2 is macOS-only
> and its native path spools via `NSPrintOperation`, not `lp` (#201).
``` ```
lp -d <printer> -t "ICCery Target - <filename>" lp -d <printer> -t "ICCery Target - <filename>"
+10 -10
View File
@@ -8,7 +8,7 @@ Separate native macOS AppKit app. Spec: `/tmp/ICCery-CPU/SPEC.md`. Binary name `
### Why it exists ### Why it exists
ICCery's Tauri path spools TIFF via `lp` and never goes through Quartz. That is correct for "don't let ColorSync touch the file", but: ICCery's Tauri path spooled TIFF via `lp` and never went through Quartz (v1; v2.0 spools via a headless `NSPrintOperation` in ICCery proper — #201). That is correct for "don't let ColorSync touch the file", but:
- No 1:1 physical-size preview - No 1:1 physical-size preview
- Windows-style `StretchDIBits` scaler (macOS `lp` may still scale inside the filter) - Windows-style `StretchDIBits` scaler (macOS `lp` may still scale inside the filter)
@@ -100,7 +100,7 @@ Injected into:
- `printInfo.dictionary()["com.apple.print.PrintSettings.PMColorMatchingMode"]` (legacy) - `printInfo.dictionary()["com.apple.print.PrintSettings.PMColorMatchingMode"]` (legacy)
- nested `com.apple.print.printSettings` dictionary, same keys - nested `com.apple.print.printSettings` dictionary, same keys
**This is not `AP_ApplicationColorMatching`.** TargetPrint talks to Quartz/`NSPrintOperation`. ICCery talks to the CUPS `lp` ticket / `cgpdftoraster`. A rewrite that unifies them must keep both vocabularies or prove one is honored on both paths. **This is not `AP_ApplicationColorMatching`.** TargetPrint talks to Quartz/`NSPrintOperation`. v1 ICCery talked to the CUPS `lp` ticket / `cgpdftoraster`; a rewrite that unifies them must keep both vocabularies or prove one is honored on both paths. **v2.0 (#201, D2) resolved this:** the Quartz vocabulary in this section is now live in ICCery proper — `ColorSyncSuppressor.applyQuartzMode` / `TicketWriteResolver` write `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, the legacy `com.apple.print.PrintSettings.PMColorMatchingMode` and the nested `com.apple.print.printSettings` mirror **alongside** the locked AP_* keys on the single native spool path.
Panel policy (`ColorMatching.configurePanel`): Panel policy (`ColorMatching.configurePanel`):
@@ -125,7 +125,7 @@ static inline const char *TPCupsGetPPD(const char *name) {
- AirPrint (SPEC §10.2) if any of: URI `apple-airprint://`; PPD `*APAirPrint: True`; make Apple + model contains AirPrint; `ipps://` **and** PPD text contains `airprint`. Persistent warning badge; unmanaged color cannot be trusted. Tests in `AirPrintTests.swift`. - AirPrint (SPEC §10.2) if any of: URI `apple-airprint://`; PPD `*APAirPrint: True`; make Apple + model contains AirPrint; `ipps://` **and** PPD text contains `airprint`. Persistent warning badge; unmanaged color cannot be trusted. Tests in `AirPrintTests.swift`.
- Vendor bypass (SPEC §10.3) — **different keys from ICCery's lpoptions detector:** - Vendor bypass (SPEC §10.3) — **different keys from ICCery's lpoptions detector:**
| Vendor | TargetPrint keys | ICCery macOS `lp` keys | | Vendor | TargetPrint keys | ICCery macOS `lpoptions`-detected keys (v1: `lp -o`) |
|--------|------------------|------------------------| |--------|------------------|------------------------|
| Epson | `ColorModel=RGB`, `EPSONColorControls=Off` | `EPIJ_CMat=3` / `EPIJ_CCor=0` / `EpsonColorMode=Off` | | Epson | `ColorModel=RGB`, `EPSONColorControls=Off` | `EPIJ_CMat=3` / `EPIJ_CCor=0` / `EpsonColorMode=Off` |
| Canon | `CNColorMatching=None` | `CNIJIntent2=4` / `CNIJIntent=4` | | Canon | `CNColorMatching=None` | `CNIJIntent2=4` / `CNIJIntent=4` |
@@ -176,9 +176,9 @@ Command::new("/Applications/TargetPrint.app/Contents/MacOS/TargetPrint")
CI publishes `vendor-iccery.zip` with `macos-x86_64` / `macos-aarch64` / `macos-universal` app bundles to drop into `src-tauri/targetprint/`. CI publishes `vendor-iccery.zip` with `macos-x86_64` / `macos-aarch64` / `macos-universal` app bundles to drop into `src-tauri/targetprint/`.
Suggested ICCery integration: Suggested ICCery integration (**superseded** — v2.0 took option 2's rendering model in-process instead; #201 adopted Quartz/`NSPrintOperation` spooling inside ICCery proper and removed `lp` entirely. The `--job` companion-app contract remains the v2.1 plan for `ICCeryPrintKit`, #16):
1. Keep current `lp` path as the headless/fast path (and the only path on Linux). 1. ~~Keep current `lp` path as the headless/fast path (and the only path on Linux).~~
2. On macOS, Preferences / Print can spawn TargetPrint with a `TargetJob` built from `PrintOptions` + TIFF list + `forceUnmanagedColor: true` + `lockColorManagement: true`. 2. On macOS, Preferences / Print can spawn TargetPrint with a `TargetJob` built from `PrintOptions` + TIFF list + `forceUnmanagedColor: true` + `lockColorManagement: true`.
3. Do not `CREATE_NO_WINDOW` (macOS); do not `wait()`. Cleanup of the JSON is ICCery's job after process exit, or leave in `/tmp` as an audit trail (SPEC §13). 3. Do not `CREATE_NO_WINDOW` (macOS); do not `wait()`. Cleanup of the JSON is ICCery's job after process exit, or leave in `/tmp` as an audit trail (SPEC §13).
@@ -186,14 +186,14 @@ Suggested ICCery integration:
| Concern | ICCery `macos.rs` | TargetPrint | Rewrite recommendation | | Concern | ICCery `macos.rs` | TargetPrint | Rewrite recommendation |
|---------|-------------------|-------------|------------------------| |---------|-------------------|-------------|------------------------|
| Spool | `lp` TIFF | Quartz `NSPrintOperation` | Keep `lp` for unattended; TargetPrint for preview+panel | | Spool | `lp` TIFF | Quartz `NSPrintOperation` | Quartz `NSPrintOperation` — **adopted in v2.0 via #201** (`NativeTargetSpooler`; `lp` removed from the target-print path) |
| ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** if using NSPrintOperation; keep AP_* on `lp` | | ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** — **adopted in v2.0 via #201** (D2: the single native path carries AP_* and the Quartz §7 vocabulary) |
| Lock PDE UI | private `PMSessionSetColorMatchingMode*` SPI | strip Color Matching accessories | Use SPI **and** strip; accessories API misses driver PDEs (the #188 failure mode) | | Lock PDE UI | private `PMSessionSetColorMatchingMode*` SPI | strip Color Matching accessories | Use SPI **and** strip; accessories API misses driver PDEs (the #188 failure mode) |
| Canon off | `CNIJIntent2=4` | `CNColorMatching=None` | Apply both | | Canon off | `CNIJIntent2=4` | `CNColorMatching=None` | Apply both |
| Epson off | `EPIJ_CMat=3` / `EPIJ_CCor=0` | `EPSONColorControls=Off` + `ColorModel=RGB` | Apply both; prefer captured panel values | | Epson off | `EPIJ_CMat=3` / `EPIJ_CCor=0` | `EPSONColorControls=Off` + `ColorModel=RGB` | Apply both; prefer captured panel values |
| Geometry | none (filter decides) | 1:1 pt from DPI | TargetPrint (or do not scale in GDI/`lp`) | | Geometry | none (filter decides) | 1:1 pt from DPI | 1:1 pt from DPI — **adopted in v2.0 via #201** (`TargetRasterLoader`/`TargetPageCanvasView`, docs/14 §6) |
| Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges | | Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges — **adopted in v2.0 via #201** (interpolation/antialias off in `TargetPageCanvasView.draw`) |
| AirPrint | none | detected + warned | Port detector into ICCery printer list | | AirPrint | none | detected + warned | Ported into ICCery — **adopted in v2.0 via #201/#202** (`lpstat -v` + PPD §10.2 rules, Stage 2 `airPrintWarningBadge`) |
| Linux | `-o raw` | n/a (macOS only) | Keep raw + PPD fallback | | Linux | `-o raw` | n/a (macOS only) | Keep raw + PPD fallback |
| Windows | GDI ICM_OFF | n/a | Keep GDI; do not route through TargetPrint | | Windows | GDI ICM_OFF | n/a | Keep GDI; do not route through TargetPrint |
+1 -1
View File
@@ -85,7 +85,7 @@ Lock these before rewriting UI:
4. `parseProfcheckReport` JSON + legacy + empty→warning 4. `parseProfcheckReport` JSON + legacy + empty→warning
5. Color bypass detector: Canon/Epson/Gutenprint samples 5. Color bypass detector: Canon/Epson/Gutenprint samples
6. `filter_cups_options_string` drops `com.apple.*`, keeps `EPIJ_CMat` 6. `filter_cups_options_string` drops `com.apple.*`, keeps `EPIJ_CMat`
7. `build_lp_args` always emits both AP_* keys 7. `build_lp_args` always emits both AP_* keys (**historical v1** — superseded by `TicketWriteResolverTests`, #201)
8. Threshold validation `good < warning` 8. Threshold validation `good < warning`
9. `snapshot_ti3` 1-based and removes canonical 9. `snapshot_ti3` 1-based and removes canonical
10. DEVMODE round-trip size 10. DEVMODE round-trip size
+9 -3
View File
@@ -29,6 +29,12 @@
## Locked product decisions ## Locked product decisions
> **Errata (M12, #201):** item 4's `lp` spool was replaced in v2.0 by a
> headless `NSPrintOperation` replaying the captured `PMPrintSettings`
> ticket, and the Quartz vocabulary of item 5 is now written **alongside**
> AP_* on the single native path (D2). "Never mix" no longer applies inside
> ICCery proper; it still governs the future `ICCeryPrintKit` boundary.
1. **Stack:** SwiftUI (`@Observable`, `@MainActor` view models) + AppKit for printing/panels. No Tauri, no Rust, no WebView. 1. **Stack:** SwiftUI (`@Observable`, `@MainActor` view models) + AppKit for printing/panels. No Tauri, no Rust, no WebView.
2. **Floor:** macOS 14.0, universal `arm64` + `x86_64`. 2. **Floor:** macOS 14.0, universal `arm64` + `x86_64`.
3. **AGPL:** never link Argyll. Spawn with piped stdio + `ARGYLL_NOT_INTERACTIVE=1` on **every** child (streaming and captured). 3. **AGPL:** never link Argyll. Spawn with piped stdio + `ARGYLL_NOT_INTERACTIVE=1` on **every** child (streaming and captured).
@@ -120,7 +126,7 @@ Issues 1–6.
**Hardware:** none. **Hardware:** none.
Issues 7–11. Issues 7–11.
### M3 — macOS unmanaged printing (`lp` path) ### M3 — macOS unmanaged printing (`lp` path) — historical v1, superseded by #201
**CI/mock:** `lpstat`/`lpoptions` parsers; `build_lp_args` golden vectors (both `AP_*` always present); option filter; cancel → nil. **CI/mock:** `lpstat`/`lpoptions` parsers; `build_lp_args` golden vectors (both `AP_*` always present); option filter; cancel → nil.
**Hardware:** Preferences opens **driver PDE** on a real Epson or Canon queue; colour matching off/grayed; printed TIFF measures unmanaged (no ColorSync transform). **Hardware:** Preferences opens **driver PDE** on a real Epson or Canon queue; colour matching off/grayed; printed TIFF measures unmanaged (no ColorSync transform).
@@ -376,7 +382,7 @@ Six layers (spec [11](11-print-macos.md) roster):
- Deps: 12, 13. - Deps: 12, 13.
- Test CI: injectable dlsym order; filter fixtures. Hardware: PDE colour grayed/off on Epson **and** Canon. - Test CI: injectable dlsym order; filter fixtures. Hardware: PDE colour grayed/off on Epson **and** Canon.
**Issue 15 — `lp` spool path** **Issue 15 — `lp` spool path — historical v1, superseded by #201 native spool**
Labels: `Feature/Backend`, `Priority/High` Labels: `Feature/Backend`, `Priority/High`
Milestone: M3 Milestone: M3
@@ -583,7 +589,7 @@ Milestone: **Later**
- Separate Swift package `ICCeryPrintKit`. **Zero** deps on wizard types. - Separate Swift package `ICCeryPrintKit`. **Zero** deps on wizard types.
- Public API: `TargetJob` v1 JSON + `--job` CLI (fire-and-forget) **and** in-process `NSPrintOperation`. Preserve extractability to a standalone app. - Public API: `TargetJob` v1 JSON + `--job` CLI (fire-and-forget) **and** in-process `NSPrintOperation`. Preserve extractability to a standalone app.
- ColorSync vocabulary is **not** the `lp` path: `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, legacy `com.apple.print.PrintSettings.PMColorMatchingMode`. **Never mix with `AP_ApplicationColorMatching`.** - ColorSync vocabulary is **not** the `lp` path: `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, legacy `com.apple.print.PrintSettings.PMColorMatchingMode`. **Never mix with `AP_ApplicationColorMatching`** — amended by #201 (D2): with `lp` gone, ICCery's single native path writes **both** vocabularies; this constraint now governs only the future `ICCeryPrintKit` boundary.
- Vendor keys (separate table from issue 14): Epson `ColorModel=RGB` + `EPSONColorControls=Off`; Canon `CNColorMatching=None`; HP `ColorModel=RGB` + `HPColorControl=Off`. - Vendor keys (separate table from issue 14): Epson `ColorModel=RGB` + `EPSONColorControls=Off`; Canon `CNColorMatching=None`; HP `ColorModel=RGB` + `HPColorControl=Off`.
- Geometry: 72pt=1in, no `backingScaleFactor`, interpolation `.none`, antialias off, pixel-integrity seam test. Resolve SPEC contradiction: job JSON `"centered": true` vs draw “no centering” — **lock “no centering, scale 1.0” for profiling targets.** - Geometry: 72pt=1in, no `backingScaleFactor`, interpolation `.none`, antialias off, pixel-integrity seam test. Resolve SPEC contradiction: job JSON `"centered": true` vs draw “no centering” — **lock “no centering, scale 1.0” for profiling targets.**
- AirPrint detection → persistent warning. - AirPrint detection → persistent warning.
+105
View File
@@ -0,0 +1,105 @@
# Issue #201 — Printer quality and media type ignored when printing target
> Analysis doc referenced by issue **[Bug/Critical] Printer quality and media
> type settings ignored when printing target — lp path missing PMPrintSettings
> ticket** (#201). Line numbers refer to `develop` @ `0513b27` (pre-M12 code,
> verified 2026-09-17). The fix landed on `milestone/m12-native-spool` — see
> "Resolution" below.
## 0. Lifecycle trace — what the code did (pre-#201, historical v1)
```
Stage2View "btnPrinterProperties"
→ PrintSessionViewModel.openPrinterPreferences() :141
→ PrintPanelService.showProperties() :59
→ runNativePanel() :85
① PMPrinterCreateFromPrinterID + PMSessionSetCurrentPMPrinter :96–113
▸ applyInitialSelections (PMPrintSettingsSetValue ×4) :216
▸ applyPaperPageFormat (PMPaper match → PMCopyPageFormat) :258
②–⑤ ColorSyncSuppressor SPI / AP_* / bypass / mirror :152–163
NSPrintPanel.runModal("Use Settings") :173
⑥ PMPrintSettingsToOptions → CupsOptionsFilter → String :185–191
✗ THE TICKET IS DISCARDED HERE — only the flattened `k=v` string survives
→ capturedCupsOptions[queue] = String :174
Stage2View "btnPrintAll"
→ PrintSessionViewModel.printAllPages(from:) :205
→ spool(page, index:) :263
→ CupsService.printTarget() CupsService.swift:170
→ LpArgs.build() LpArgs.swift:42
→ ProcessManager.runCaptured("/usr/bin/lp", argv)
```
## Root causes (pre-#201, historical v1)
Two defects, both confirmed by reading the code:
### 1. Ticket loss
`ColorSyncSuppressor.captureOptions` (:133) was the only capture path. It
funneled the whole `PMPrintSettings` object through `PMPrintSettingsToOptions`
→ a space-separated `key=value` string, then `CupsOptionsFilter.filter`
**dropped every `com.apple.*` key** (`CupsOptionsFilter.swift:49`) — i.e. it
deliberately discarded `com.apple.print.PrintSettings`, the ticket the
Epson/Canon raster filter reads. `lp` cannot reconstruct it, so the driver
fell back to plain paper / normal quality.
### 2. Override inversion
`LpArgs.build` recorded every captured key in `addedKeys` (:67–73) and then
*suppressed* the explicit Stage 2 value when the key was already present —
media (:81), quality (:90), bypass (:102), orientation (:109), `PageSize`
(:117). A Stage 2 dropdown change after a panel capture was silently ignored.
### Dismissed hypothesis
**argv tokenisation is safe** — `ProcessManager.runCaptured` hands an argv
array to `Process`, never a shell. Space-containing `-o` values were
preserved; this was checked and dismissed in the issue investigation and
re-verified during the M12 audit.
## Locked decisions (D1–D13)
Decisions taken by the M12 megaplan; **locked — do not re-derive.**
(`lp`/`LpArgs` mentions in this table name the deleted historical v1 path.)
| # | Decision | Consequence |
|---|----------|-------------|
| D1 | Spooler lives in the **app target** (`Sources/ICCery/Print/`), not in `CupsService` | `ICCeryCore` stays AppKit-free (AGENTS §Package layout). `CupsService.printTarget` is **deleted**; `CupsService` keeps enumeration/capabilities/PPD only. The issue body's "in `CupsService`" is superseded — posted as errata on #201 |
| D2 | Write **both** ColorSync vocabularies on the native path | AP_* (locked) **and** Quartz `PMColorMatchingMode=APCustomColorMatching` + `PMCustomColorMatchingProfile=""` + legacy `com.apple.print.PrintSettings.PMColorMatchingMode` + the nested `com.apple.print.printSettings` mirror. AGENTS §"Private ColorSync SPI — never mix" is **amended**: with `lp` gone there is one path and it carries both dictionaries (docs/14 decision table, "Set both if using NSPrintOperation") |
| D3 | Ticket = `PMPrintSettings` **Data** + `PMPageFormat` **Data** + an `NSPrintInfo.dictionary()` plist fallback | `kPMDataFormatXMLDefault`. Restored via `PM*CreateWithDataRepresentation` → `PMCopy*` → `PMSessionValidate*` → `updateFromPM*`. Byte round-trip is unit-testable |
| D4 | Delete `lp`; **keep** `CupsParsers` + `CupsOptionsFilter` | Parsers still drive capabilities (#183/#180/#181), media/quality key detection, driver-bypass detection, and the panel→Stage-2 apply-back mirror (#186). Only `LpArgs` dies |
| D5 | Job granularity is **user-selectable**, default **one job per page** | New session-only `@Published var singleJobForAllPages = false`; Stage 2 checkbox `chkSingleSpoolJob`. Default preserves today's per-page notices and error attribution |
| D6 | **Stage 2 always wins** over the rehydrated ticket | Unconditional overwrite of paper / media / quality / orientation. Mitigation for vendor companion-key desync: only write when the value differs from the ticket's current value, and log both (R4) |
| D7 | Spool **silently** | `showsPrintPanel = false`, `showsProgressPanel = false`, `canSpawnSeparateThread = false`. Feedback stays on the existing `isPrinting` + `Notice`. No new system modal → XCUITest unaffected |
| D8 | Verification = **recorder seam + PDF harness** | DEBUG `RecordingTargetSpooler` writes resolved ticket lines to `ICCERY_TEST_SPOOL_LOG` (replaces `ICCERY_TEST_LP_ARGV`); a real `NSPrintOperation` with `jobDisposition = .save` produces a PDF for geometry assertions |
| D9 | Never scale; **warn + spool** | 1:1 always, anchored at the paper's top-left. `warn` when the DPI-derived size disagrees with the manifest `width_mm`/`height_mm` by >0.5 mm; warning `Notice` when the image exceeds the paper |
| D10 | Device colour spaces implemented **and verified** for 1/3/4 channels at 8 and 16 bpc | Re-tag the decoded `CGImage` into `DeviceGray`/`DeviceRGB`/`DeviceCMYK` reusing the source `dataProvider` — **no resample, no bit-depth change**. Hardware gate covers RGB-8, RGB-16, DeviceGray-8 and CMYK-16 |
| D11 | AirPrint detection + Stage 2 banner **in scope**, as its own issue | docs/14 §10.2 rules. Blocked-by #201 → filed as #202 |
| D12 | Extract a **shared** `PMTicketBridge` | Single `@MainActor enum` owning every `PM*` call, with one documented `PMRelease` rule. `PrintPanelService` migrates onto it |
| D13 | New milestone **M12 — Native print spool** | `milestone/m12-native-spool` from `develop`; #201 moves off the shipped M11 (id 34) |
## Resolution
Implemented on `milestone/m12-native-spool` (Phases 1–5, PRs #203–#207).
(`LpArgs`/`lp` references below name deleted historical v1 artefacts.)
- `PMTicketBridge` owns every `PM*` call; `PrintPanelService.showProperties`
returns `PanelCaptureResult` carrying a `PrintTicket` (layer ⑦ serialise).
- `NativeTargetSpooler` rehydrates the ticket into a fresh `NSPrintInfo`
(S1–S14, docs/11 §native spool) and spools a headless `NSPrintOperation`
over `TargetPageCanvasView` — 1:1, top-left anchored, interpolation off.
- `TicketWriteResolver` replaces `LpArgs.build`: Stage 2 always wins (D6),
both ColorSync vocabularies written (D2), no `raw` can ever appear.
- `LpArgs`, `CupsService.printTarget`, the `lp` fixture and
`ICCERY_TEST_LP_ARGV` are deleted; `RecordingTargetSpooler` writes to
`ICCERY_TEST_SPOOL_LOG` for tests (D8).
- AirPrint detection + Stage 2 warning badge shipped as #202 (D11).
## References
- `Sources/ICCery/Print/{PMTicketBridge,PrintTicket,NativeTargetSpooler,TicketWriteResolver,TargetRaster,TargetPageCanvasView}.swift`
- `Packages/ICCeryCore/Sources/ICCeryCore/Print/{CupsService,CupsParsers,CupsOptionsFilter,PrinterModels,ColorMatchingAttempts}.swift`
- `docs/11-print-macos.md` §ColorSync suppression — UI click to `NSPrintOperation`
- `docs/14-iccery-cpu-targetprint.md` §6–§7 (geometry + Quartz vocabulary)
- M12 megaplan (`plan-be303e6f3f5e89da`)
+1 -1
View File
@@ -61,7 +61,7 @@ targets:
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0" INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
INFOPLIST_KEY_NSPrincipalClass: NSApplication INFOPLIST_KEY_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3." INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.1" MARKETING_VERSION: "2.0.2"
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.