Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddb9245f44 | ||
|
|
226e7b2a78 | ||
|
|
2407abfd9e | ||
|
|
356ae0bfa9 | ||
|
|
60f3aa3991 | ||
|
|
b89d9777ad | ||
|
|
7a3a0886be | ||
|
|
5389e4c2bd | ||
|
|
972368aa85 |
@@ -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):
|
||||||
|
|||||||
@@ -73,6 +73,26 @@ public enum CupsParsers {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `lpstat -v` — `device for <name>: <uri>` → queue → device URI.
|
||||||
|
/// Entries with no URI (e.g. `network <name>` remote stubs) are
|
||||||
|
/// skipped (#202 AirPrint detection).
|
||||||
|
public static func lpstatDeviceURIs(output: String) -> [String: String] {
|
||||||
|
var result: [String: String] = [:]
|
||||||
|
for line in output.split(separator: "\n") {
|
||||||
|
let text = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard text.hasPrefix("device for ") else { continue }
|
||||||
|
let rest = text.dropFirst("device for ".count)
|
||||||
|
guard let colon = rest.firstIndex(of: ":") else { continue }
|
||||||
|
let name = String(rest[..<colon])
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
let uri = String(rest[rest.index(after: colon)...])
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !name.isEmpty, !uri.isEmpty else { continue }
|
||||||
|
result[name] = uri
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - lpoptions -p <queue>
|
// MARK: - lpoptions -p <queue>
|
||||||
|
|
||||||
/// `lpoptions -p` — `key=value` pairs, values may be
|
/// `lpoptions -p` — `key=value` pairs, values may be
|
||||||
@@ -137,6 +157,16 @@ public enum CupsParsers {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Make-and-model from `printer-make-and-model` in `lpoptions -p`
|
||||||
|
/// output — an AirPrint detection input (#202).
|
||||||
|
public static func lpoptionsMakeAndModel(output: String) -> String? {
|
||||||
|
guard let value = lpoptions(output)
|
||||||
|
.first(where: { $0.key == "printer-make-and-model" })?.value,
|
||||||
|
!value.isEmpty
|
||||||
|
else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - lpoptions -l
|
// MARK: - lpoptions -l
|
||||||
|
|
||||||
/// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`.
|
/// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`.
|
||||||
@@ -352,4 +382,62 @@ public enum CupsParsers {
|
|||||||
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
||||||
"EpsonColorMode",
|
"EpsonColorMode",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// MARK: - AirPrint detection (#202, docs/14 §10.2)
|
||||||
|
|
||||||
|
/// AirPrint queues always colour-manage the URF raster path, so
|
||||||
|
/// unmanaged device colour cannot be guaranteed — Stage 2 warns
|
||||||
|
/// via `airPrintWarningBadge`. `true` when ANY rule matches:
|
||||||
|
///
|
||||||
|
/// 1. device URI contains `apple-airprint://`
|
||||||
|
/// 2. PPD declares `*APAirPrint: True`
|
||||||
|
/// 3. make-and-model contains "Apple" **and** "AirPrint"
|
||||||
|
/// 4. `ipps://` URI **and** the PPD text mentions "airprint"
|
||||||
|
/// (case-insensitive)
|
||||||
|
/// 5. local unencrypted `ipp://` URI resolved via an AirPrint mDNS
|
||||||
|
/// subtype (`_universal._sub._ipp._tcp`)
|
||||||
|
/// 6. PPD carries a `*cupsFilter2` rule whose destination MIME
|
||||||
|
/// type is `image/urf` — the raster only AirPrint consumes
|
||||||
|
public static func detectAirPrint(
|
||||||
|
deviceURI: String?, makeAndModel: String?, ppd: String
|
||||||
|
) -> Bool {
|
||||||
|
if let uri = deviceURI {
|
||||||
|
let lower = uri.lowercased()
|
||||||
|
if lower.contains("apple-airprint://") { return true }
|
||||||
|
if lower.hasPrefix("ipps://"),
|
||||||
|
ppd.range(of: "airprint", options: .caseInsensitive) != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if lower.hasPrefix("ipp://"),
|
||||||
|
lower.contains("_universal._sub._ipp._tcp") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ppd.contains("*APAirPrint: True") { return true }
|
||||||
|
if let model = makeAndModel,
|
||||||
|
model.contains("Apple"), model.contains("AirPrint") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return ppdCarriesURFFilter(ppd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 6 — `*cupsFilter2: "src dst cost program"` (quotes
|
||||||
|
/// optional); `image/urf` as the destination token means the queue
|
||||||
|
/// consumes the AirPrint raster.
|
||||||
|
private static func ppdCarriesURFFilter(_ ppd: String) -> Bool {
|
||||||
|
for rawLine in ppd.split(separator: "\n") {
|
||||||
|
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard line.lowercased().hasPrefix("*cupsfilter2"),
|
||||||
|
let colon = line.firstIndex(of: ":")
|
||||||
|
else { continue }
|
||||||
|
let tokens = line[line.index(after: colon)...]
|
||||||
|
.replacingOccurrences(of: "\"", with: "")
|
||||||
|
.split(separator: " ")
|
||||||
|
if tokens.count >= 2,
|
||||||
|
tokens[1].lowercased() == "image/urf" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,19 +57,29 @@ public struct CupsService: Sendable {
|
|||||||
"lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true)
|
"lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true)
|
||||||
let defaultOut = try await run(
|
let defaultOut = try await run(
|
||||||
"lpstat", ["-d"], id: ProcessID.cupsLpstat("d"), tolerateFailure: true)
|
"lpstat", ["-d"], id: ProcessID.cupsLpstat("d"), tolerateFailure: true)
|
||||||
|
// One-shot device-URI fetch for AirPrint detection (#202). A
|
||||||
|
// failed `lpstat -v` is tolerated — enumeration proceeds with
|
||||||
|
// no URIs and no fallback respawn.
|
||||||
|
let deviceOut = try await run(
|
||||||
|
"lpstat", ["-v"], id: ProcessID.cupsLpstat("v"), tolerateFailure: true)
|
||||||
|
|
||||||
let names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
|
let names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
|
||||||
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
|
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
|
||||||
let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout)
|
let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout)
|
||||||
|
let deviceURIs = CupsParsers.lpstatDeviceURIs(output: deviceOut.stdout)
|
||||||
|
|
||||||
var printers: [Printer] = []
|
var printers: [Printer] = []
|
||||||
for name in names {
|
for name in names {
|
||||||
let displayName = try? await displayName(for: name)
|
let identity = try? await queueIdentity(for: name)
|
||||||
printers.append(Printer(
|
printers.append(Printer(
|
||||||
name: name,
|
name: name,
|
||||||
status: statuses[name] ?? .unknown,
|
status: statuses[name] ?? .unknown,
|
||||||
isDefault: name == defaultName,
|
isDefault: name == defaultName,
|
||||||
displayName: displayName
|
displayName: identity?.displayName,
|
||||||
|
isAirPrint: CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: deviceURIs[name],
|
||||||
|
makeAndModel: identity?.makeAndModel,
|
||||||
|
ppd: loadPPD(for: name) ?? "")
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return printers
|
return printers
|
||||||
@@ -78,9 +88,19 @@ public struct CupsService: Sendable {
|
|||||||
/// `lpoptions -p <queue>` → `printer-info` (the NSPrinter fallback
|
/// `lpoptions -p <queue>` → `printer-info` (the NSPrinter fallback
|
||||||
/// display name, docs/11 §binding).
|
/// display name, docs/11 §binding).
|
||||||
public func displayName(for queue: String) async throws -> String? {
|
public func displayName(for queue: String) async throws -> String? {
|
||||||
|
try await queueIdentity(for: queue).displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `lpoptions -p <queue>` spawn yields both identity fields —
|
||||||
|
/// `printer-info` (display name) and `printer-make-and-model`
|
||||||
|
/// (AirPrint rule 3, #202).
|
||||||
|
private func queueIdentity(
|
||||||
|
for queue: String
|
||||||
|
) async throws -> (displayName: String?, makeAndModel: String?) {
|
||||||
let result = try await run(
|
let result = try await run(
|
||||||
"lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue))
|
"lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue))
|
||||||
return CupsParsers.lpoptionsDisplayName(result.stdout)
|
return (CupsParsers.lpoptionsDisplayName(result.stdout),
|
||||||
|
CupsParsers.lpoptionsMakeAndModel(output: result.stdout))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Capabilities (lpoptions -l + PPD)
|
// MARK: - Capabilities (lpoptions -l + PPD)
|
||||||
|
|||||||
@@ -17,17 +17,33 @@ public struct Printer: Codable, Equatable, Sendable {
|
|||||||
public var status: PrinterStatus
|
public var status: PrinterStatus
|
||||||
public var isDefault: Bool
|
public var isDefault: Bool
|
||||||
public var displayName: String?
|
public var displayName: String?
|
||||||
|
/// AirPrint queue (#202, docs/14 §10.2) — the URF pipeline is always
|
||||||
|
/// colour-managed, so Stage 2 shows a persistent warning badge.
|
||||||
|
public var isAirPrint: Bool = false
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
name: String,
|
name: String,
|
||||||
status: PrinterStatus = .unknown,
|
status: PrinterStatus = .unknown,
|
||||||
isDefault: Bool = false,
|
isDefault: Bool = false,
|
||||||
displayName: String? = nil
|
displayName: String? = nil,
|
||||||
|
isAirPrint: Bool = false
|
||||||
) {
|
) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.status = status
|
self.status = status
|
||||||
self.isDefault = isDefault
|
self.isDefault = isDefault
|
||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
|
self.isAirPrint = isAirPrint
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `isAirPrint` predates #202 payloads — a missing key decodes as
|
||||||
|
/// `false` instead of failing the whole decode.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
status = try c.decode(PrinterStatus.self, forKey: .status)
|
||||||
|
isDefault = try c.decode(Bool.self, forKey: .isDefault)
|
||||||
|
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
|
||||||
|
isAirPrint = try c.decodeIfPresent(Bool.self, forKey: .isAirPrint) ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +144,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
|||||||
public var ppdUncorrectedPassthrough: Bool?
|
public var ppdUncorrectedPassthrough: Bool?
|
||||||
/// Space-separated `key=value` captured from
|
/// Space-separated `key=value` captured from
|
||||||
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) —
|
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) —
|
||||||
/// the Stage 2 mirror only, never a spool payload.
|
/// the Stage 2 mirror only, never an `lp` payload.
|
||||||
public var cupsOptions: String?
|
public var cupsOptions: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,12 +266,19 @@ struct Stage2View: View {
|
|||||||
.background(Theme.background)
|
.background(Theme.background)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
.accessibilityIdentifier("printerStatusBadge")
|
.accessibilityIdentifier("printerStatusBadge")
|
||||||
|
// AirPrint queues colour-manage the URF path —
|
||||||
|
// unmanaged colour cannot be guaranteed (#202).
|
||||||
|
if selected.isAirPrint {
|
||||||
|
Text("AirPrint queue — unmanaged colour "
|
||||||
|
+ "cannot be guaranteed.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||||
|
.background(Color.orange.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.accessibilityIdentifier("airPrintWarningBadge")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Placeholder — Phase 5 (AirPrint detection, M12)
|
|
||||||
// replaces this with the live unmanaged-colour
|
|
||||||
// warning badge for AirPrint queues.
|
|
||||||
EmptyView()
|
|
||||||
.accessibilityIdentifier("airPrintWarningBadge")
|
|
||||||
Button(action: workflow.print.refreshPrinters) {
|
Button(action: workflow.print.refreshPrinters) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -455,4 +455,175 @@ final class CupsParserTests: XCTestCase {
|
|||||||
processManager: ProcessManager(),
|
processManager: ProcessManager(),
|
||||||
binaryDir: bin, ppdDir: ppdDir), root)
|
binaryDir: bin, ppdDir: ppdDir), root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - #202 AirPrint detection
|
||||||
|
|
||||||
|
/// `lpstat -v` — `device for <name>: <uri>` lines; a `network`
|
||||||
|
/// remote stub carries no URI and is skipped.
|
||||||
|
private let lpstatV = """
|
||||||
|
device for Canon_Pro9500_II_series_XPS: usb://Canon/PRO-9500%20II%20series?serial=1234AB
|
||||||
|
device for Epson_XP_55_LPD: lpd://192.168.1.50/queue
|
||||||
|
device for EPSON_XP_55_Series: ipp://EPSON%20XP-55%20Series._universal._sub._ipp._tcp.local./
|
||||||
|
device for Office_IPPS: ipps://print.example.com/ipp/print
|
||||||
|
network Remote_Queue
|
||||||
|
"""
|
||||||
|
|
||||||
|
func testLpstatDeviceURIs() {
|
||||||
|
let uris = CupsParsers.lpstatDeviceURIs(output: lpstatV)
|
||||||
|
XCTAssertEqual(uris.count, 4)
|
||||||
|
XCTAssertEqual(uris["Canon_Pro9500_II_series_XPS"],
|
||||||
|
"usb://Canon/PRO-9500%20II%20series?serial=1234AB")
|
||||||
|
XCTAssertEqual(uris["Epson_XP_55_LPD"], "lpd://192.168.1.50/queue")
|
||||||
|
XCTAssertEqual(uris["EPSON_XP_55_Series"],
|
||||||
|
"ipp://EPSON%20XP-55%20Series._universal._sub._ipp._tcp.local./")
|
||||||
|
XCTAssertEqual(uris["Office_IPPS"],
|
||||||
|
"ipps://print.example.com/ipp/print")
|
||||||
|
XCTAssertNil(uris["Remote_Queue"])
|
||||||
|
XCTAssertEqual(CupsParsers.lpstatDeviceURIs(output: ""), [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLpoptionsMakeAndModel() {
|
||||||
|
XCTAssertEqual(CupsParsers.lpoptionsMakeAndModel(
|
||||||
|
output: lpoptionsP), "EPSON EPSON XP-55 Series")
|
||||||
|
XCTAssertNil(CupsParsers.lpoptionsMakeAndModel(
|
||||||
|
output: "printer-type=42\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 1 — an `apple-airprint://` device URI is AirPrint on its
|
||||||
|
/// own; PPD and make-and-model are irrelevant.
|
||||||
|
func testAirPrintRuleAppleAirPrintScheme() {
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "apple-airprint://DeskJet._ipps._tcp.local./",
|
||||||
|
makeAndModel: nil, ppd: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 2 — the PPD declares `*APAirPrint: True`.
|
||||||
|
func testAirPrintRulePPDFlag() {
|
||||||
|
let ppd = """
|
||||||
|
*PPD-Adobe: "4.3"
|
||||||
|
*APAirPrint: True
|
||||||
|
*OpenUI *PageSize/Media Size: PickOne
|
||||||
|
"""
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "socket://10.0.0.9/", makeAndModel: nil, ppd: ppd))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 3 — make-and-model contains "Apple" and "AirPrint".
|
||||||
|
func testAirPrintRuleMakeAndModel() {
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "socket://10.0.0.9/",
|
||||||
|
makeAndModel: "Apple AirPrint", ppd: ""))
|
||||||
|
// Both tokens are required.
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "socket://10.0.0.9/",
|
||||||
|
makeAndModel: "Apple LaserWriter", ppd: ""))
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "socket://10.0.0.9/",
|
||||||
|
makeAndModel: "HP AirPrint-Ready", ppd: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 4 — `ipps://` URI **and** the PPD text mentions "airprint"
|
||||||
|
/// case-insensitively. `ipps://` alone is not enough.
|
||||||
|
func testAirPrintRuleIPPSWithPPDMention() {
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipps://print.example.com/ipp/print",
|
||||||
|
makeAndModel: nil,
|
||||||
|
ppd: "*Foo: \"AIRPRINT enabled\"\n"))
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipps://print.example.com/ipp/print",
|
||||||
|
makeAndModel: nil, ppd: "*PPD-Adobe: \"4.3\"\n"))
|
||||||
|
// An unencrypted ipp:// URI does not satisfy rule 4.
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipp://print.example.com/ipp/print",
|
||||||
|
makeAndModel: nil,
|
||||||
|
ppd: "*Foo: \"airprint enabled\"\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 5 — unencrypted `ipp://` resolved via the AirPrint mDNS
|
||||||
|
/// subtype `_universal._sub._ipp._tcp`. A plain `ipp://` mDNS name
|
||||||
|
/// without the subtype is not AirPrint.
|
||||||
|
func testAirPrintRuleMDNSSubtype() {
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipp://EPSON%20XP-55._universal._sub._ipp._tcp.local./",
|
||||||
|
makeAndModel: nil, ppd: ""))
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipp://EPSON%20XP-55._ipp._tcp.local./",
|
||||||
|
makeAndModel: nil, ppd: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 6 — a `*cupsFilter2` rule whose destination MIME is
|
||||||
|
/// `image/urf` (the AirPrint-only raster). A PWG-raster filter is
|
||||||
|
/// not AirPrint.
|
||||||
|
func testAirPrintRuleCupsFilter2URF() {
|
||||||
|
let ppd = """
|
||||||
|
*cupsFilter2: "application/pdf image/urf 0 -"
|
||||||
|
*cupsFilter2: "image/urf image/urf 100 -"
|
||||||
|
"""
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: nil, makeAndModel: nil, ppd: ppd))
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: nil, makeAndModel: nil,
|
||||||
|
ppd: "*cupsFilter2: \"application/pdf image/pwg-raster 0 -\"\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Negative — a standard USB raster-driver queue (Epson XP-55)
|
||||||
|
/// matches none of the six rules.
|
||||||
|
func testAirPrintNegativeUSBRaster() {
|
||||||
|
let ppd = """
|
||||||
|
*PPD-Adobe: "4.3"
|
||||||
|
*EPIJ_Qual 303/Normal: ""
|
||||||
|
*cupsFilter: "application/vnd.cups-raster 0 rastertoepson"
|
||||||
|
"""
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "usb://EPSON/XP-55%20Series?serial=ABC123",
|
||||||
|
makeAndModel: "EPSON XP-55 Series", ppd: ppd))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `listPrinters` survives a failing `lpstat -v` — the failure is
|
||||||
|
/// tolerated, enumeration proceeds, every queue reports
|
||||||
|
/// `isAirPrint == false`, and there is **no** fallback respawn
|
||||||
|
/// (exactly one `-v` invocation, #202).
|
||||||
|
func testListPrintersToleratesLpstatVFailure() async throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-airprint-\(UUID().uuidString)")
|
||||||
|
let bin = root.appendingPathComponent("bin")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: bin, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let log = root.appendingPathComponent("lpstat.log")
|
||||||
|
|
||||||
|
let lpstat = """
|
||||||
|
#!/bin/sh
|
||||||
|
printf '%s\\n' "$1" >> '\(log.path)'
|
||||||
|
case "$1" in
|
||||||
|
-e) printf 'Mock_Epson\\n' ;;
|
||||||
|
-p) printf 'printer Mock_Epson is idle.\\n' ;;
|
||||||
|
-d) printf 'no system default destination\\n' ;;
|
||||||
|
-v) exit 1 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
"""
|
||||||
|
let lpoptions = """
|
||||||
|
#!/bin/sh
|
||||||
|
printf "printer-info='Mock'\\n"
|
||||||
|
"""
|
||||||
|
for (name, body) in [("lpstat", lpstat), ("lpoptions", lpoptions)] {
|
||||||
|
let url = bin.appendingPathComponent(name)
|
||||||
|
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
let service = CupsService(
|
||||||
|
processManager: ProcessManager(), binaryDir: bin,
|
||||||
|
ppdDir: root.appendingPathComponent("ppd"))
|
||||||
|
let printers = try await service.listPrinters()
|
||||||
|
XCTAssertEqual(printers.map(\.name), ["Mock_Epson"])
|
||||||
|
XCTAssertFalse(printers[0].isAirPrint)
|
||||||
|
|
||||||
|
let calls = ((try? String(contentsOf: log, encoding: .utf8)) ?? "")
|
||||||
|
.split(separator: "\n")
|
||||||
|
XCTAssertEqual(calls.filter { $0 == "-v" }.count, 1,
|
||||||
|
"lpstat -v must be spawned exactly once: \(calls)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -22,5 +22,5 @@ if [ "$list" = "1" ]; then
|
|||||||
printf 'ColorModel/Output Mode: *RGB Gray\n'
|
printf 'ColorModel/Output Mode: *RGB Gray\n'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
printf "printer-info='Mock %s' printer-type=42\n" "$queue"
|
printf "printer-info='Mock %s' printer-make-and-model='EPSON XP-55 Series' printer-type=42\n" "$queue"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ case "$1" in
|
|||||||
-d)
|
-d)
|
||||||
printf 'system default destination: Mock_Epson_7450\n'
|
printf 'system default destination: Mock_Epson_7450\n'
|
||||||
;;
|
;;
|
||||||
|
-v)
|
||||||
|
# Mock_Canon_Pro is the AirPrint fixture queue (#202): local
|
||||||
|
# unencrypted ipp:// resolved via the _universal mDNS subtype.
|
||||||
|
printf 'device for Mock_Epson_7450: usb://EPSON/XP-55%%20Series?serial=MOCK123\n'
|
||||||
|
printf 'device for Mock_Canon_Pro: ipp://Mock%%20Canon%%20Pro._universal._sub._ipp._tcp.local./\n'
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Milestone 12 UI tests — #202 AirPrint queue detection. The mock
|
||||||
|
/// `lpstat -v` fixture reports `Mock_Canon_Pro` as a local unencrypted
|
||||||
|
/// `ipp://` queue resolved via the `_universal._sub._ipp._tcp` mDNS
|
||||||
|
/// subtype (rule 5), while `Mock_Epson_7450` is a plain USB raster
|
||||||
|
/// queue — so the badge must track the picker selection.
|
||||||
|
@MainActor
|
||||||
|
final class Milestone12AirPrintUITests: XCTestCase {
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private var testRoot: URL!
|
||||||
|
private var binDir: URL!
|
||||||
|
private var workDir: URL!
|
||||||
|
private var spoolLogURL: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
testRoot = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ui12-\(UUID().uuidString)")
|
||||||
|
binDir = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Fixtures/bin")
|
||||||
|
workDir = testRoot.appendingPathComponent("work")
|
||||||
|
spoolLogURL = testRoot.appendingPathComponent("spool.log")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: workDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchEnvironment = [
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_ROOT": testRoot.path,
|
||||||
|
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||||
|
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||||
|
"ICCERY_TEST_SAVE_TARGET":
|
||||||
|
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||||
|
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||||
|
"ICCERY_TEST_SPOOL_LOG": spoolLogURL.path,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
if let testRoot {
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
testRoot = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func element(_ id: String) -> XCUIElement {
|
||||||
|
let inApp = app.descendants(matching: .any)[id]
|
||||||
|
if inApp.exists { return inApp }
|
||||||
|
return app.sheets.firstMatch.descendants(matching: .any)[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll until `id` no longer resolves — the badge is conditionally
|
||||||
|
/// rendered, so absence is only meaningful after a settle window.
|
||||||
|
private func waitForAbsence(_ id: String, timeout: TimeInterval = 5) {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if !element(id).exists { return }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(element(id).exists, "Element \(id) should be absent")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the app through targen + printtarg so the print panel is
|
||||||
|
/// live with a manifest.
|
||||||
|
private func reachPrintPanel() {
|
||||||
|
app.buttons["btnBrowse"].click()
|
||||||
|
app.buttons["btnGenerate"].click()
|
||||||
|
_ = waitFor("btnCreateLayout", timeout: 25)
|
||||||
|
app.buttons["btnCreateLayout"].click()
|
||||||
|
_ = waitFor("galleryPage-0", timeout: 25)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the printer-picker menu item whose title contains
|
||||||
|
/// `needle` (display names come from the `lpoptions` fixture).
|
||||||
|
private func selectPrinter(containing needle: String) {
|
||||||
|
let picker = app.popUpButtons["printerSelect"]
|
||||||
|
XCTAssertTrue(picker.waitForExistence(timeout: 10))
|
||||||
|
picker.click()
|
||||||
|
let item = app.menuItems
|
||||||
|
.matching(NSPredicate(format: "title CONTAINS %@", needle))
|
||||||
|
.firstMatch
|
||||||
|
XCTAssertTrue(item.waitForExistence(timeout: 5),
|
||||||
|
"No printer menu item containing \(needle)")
|
||||||
|
item.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
/// The badge appears while the AirPrint fixture queue is selected
|
||||||
|
/// and is absent for the USB Epson — in both directions.
|
||||||
|
func testAirPrintBadgeTracksSelectedQueue() throws {
|
||||||
|
app.launch()
|
||||||
|
app.activate()
|
||||||
|
reachPrintPanel()
|
||||||
|
_ = waitFor("printerStatusBadge")
|
||||||
|
|
||||||
|
// Default queue is the USB Epson — no badge.
|
||||||
|
waitForAbsence("airPrintWarningBadge")
|
||||||
|
|
||||||
|
selectPrinter(containing: "Canon")
|
||||||
|
let badge = element("airPrintWarningBadge")
|
||||||
|
XCTAssertTrue(badge.waitForExistence(timeout: 10))
|
||||||
|
// StaticText exposes its content via AXValue, not the label.
|
||||||
|
let badgeText = [badge.value as? String, badge.label, badge.title]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.first { !$0.isEmpty } ?? ""
|
||||||
|
XCTAssertTrue(
|
||||||
|
badgeText.contains(
|
||||||
|
"AirPrint queue — unmanaged colour cannot be guaranteed."),
|
||||||
|
"Unexpected badge text: \(badgeText)")
|
||||||
|
|
||||||
|
selectPrinter(containing: "Epson")
|
||||||
|
waitForAbsence("airPrintWarningBadge")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 |
|
||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
|
|||||||
@@ -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>"
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user