Compare commits

..
2 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
13 changed files with 1199 additions and 67 deletions
@@ -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)
} }
} }
+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,8 +27,10 @@ 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).
/// Page 1 is the BOTTOM band of the frame, the non-flipped
/// pagination convention.
override var isFlipped: Bool { false } override var isFlipped: Bool { false }
override var isOpaque: Bool { true } override var isOpaque: Bool { true }
@@ -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,7 +35,9 @@ 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)
@@ -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`.