Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2f994821f | ||
|
|
1d6032456d | ||
|
|
fc1530ab2e | ||
|
|
ffbe56f43a | ||
|
|
ee5da4a637 | ||
|
|
0513b272cb | ||
|
|
555bf8fda4 | ||
|
|
f3c7b19694 | ||
|
|
5e696ff3e9 | ||
|
|
19f619ad98 | ||
|
|
09f466ae46 | ||
|
|
d228594bd3 | ||
|
|
6adbf99bd9 | ||
|
|
d8c41444b9 | ||
|
|
1767792f75 | ||
|
|
e8678586bd | ||
|
|
c9b423b229 | ||
|
|
62a45d9c7d |
@@ -50,6 +50,27 @@ bump `project.yml` on `develop` before tagging. CI needs `fetch-depth: 0`.
|
|||||||
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
|
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
|
||||||
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
|
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
|
||||||
|
|
||||||
|
## Issue ticket style
|
||||||
|
- Title: `[Kind/Priority] short description` — e.g. `[Bug/Critical] …`, `[Feature/Medium] …`.
|
||||||
|
- Labels: `Kind/Bug` or `Kind/Feature` (also `Kind/Testing` for test work),
|
||||||
|
one `Bug/<area>` or `Feature/<area>` (Architecture/Backend/UI/DevOps),
|
||||||
|
one `Priority/*`, plus `Project/ICCery-v2`. Set the milestone when the work
|
||||||
|
belongs to an active `mN` milestone.
|
||||||
|
- Bug bodies: `## Summary` → `## Root Cause Analysis` (file:line evidence;
|
||||||
|
note checked-and-dismissed hypotheses) → `## Proposed Fix` (options or
|
||||||
|
deterministic plan) → `## Acceptance Criteria` (checkbox list) →
|
||||||
|
`## Dependencies` → `## References`.
|
||||||
|
- Feature bodies: same skeleton minus Root Cause; lead with Summary and a
|
||||||
|
concrete implementation plan.
|
||||||
|
- Dependencies/blockers must **always** be recorded via the gitea MCP
|
||||||
|
`issue_write` methods (`add_dependency`, `block_issue`; reads via
|
||||||
|
`issue_read` `list_dependencies` / `list_blocks` — see "Gitea issue
|
||||||
|
dependencies"), not just mentioned in the body. This is
|
||||||
|
mandatory when issues share a milestone with an implementation order:
|
||||||
|
wire up `add_dependency` (blocked-by) and `block_issue` (blocks) links so
|
||||||
|
the order is machine-readable. The `## Dependencies` body section may
|
||||||
|
still summarise them for readability, but the MCP links are authoritative.
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
```
|
```
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
|
|||||||
| M11 | Printer settings completeness & dialog binding | 183, 180, 181, 186 | Quality/media/paper caps parse + `LpArgs` emit + picker UI smoke + panel capture-return | Real Epson: 7 `EPIJ_Qual` options; real Canon Pro9500: English media labels; dialog bound to non-default queue with paper/quality/media/orientation preselected; captured settings reach `lp` argv |
|
| M11 | Printer settings completeness & dialog binding | 183, 180, 181, 186 | Quality/media/paper caps parse + `LpArgs` emit + picker UI smoke + panel capture-return | Real Epson: 7 `EPIJ_Qual` options; real Canon Pro9500: English media labels; dialog bound to non-default queue with paper/quality/media/orientation preselected; captured settings reach `lp` argv |
|
||||||
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
||||||
|
|
||||||
M8–M10 are merged to `develop`; M11 assembles on `milestone/m11-print-settings` from `736fd08`.
|
M8–M11 are merged to `develop`; M11 shipped via PR #199 (hardware gate passed on real Epson XP-55 / Canon Pro9500 II).
|
||||||
|
|
||||||
Issue **16 is not an M3, M6, M10, or M11 exit gate.**
|
Issue **16 is not an M3, M6, M10, or M11 exit gate.**
|
||||||
|
|
||||||
|
|||||||
@@ -174,8 +174,13 @@ public enum CupsParsers {
|
|||||||
|
|
||||||
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
||||||
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
|
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
|
||||||
|
/// Precedence is deterministic, not positional (#181): unqualified
|
||||||
|
/// `*Key` > `en_US.` > `en.` > first-qualified-seen, so a trailing
|
||||||
|
/// locale block (Canon `th.CNIJMediaType`) can never overwrite the
|
||||||
|
/// base English labels — and a qualified-only id still gets its
|
||||||
|
/// first-seen qualified label (R9).
|
||||||
public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] {
|
public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] {
|
||||||
var map: [String: String] = [:]
|
var hits: [String: [(qualifier: String?, label: String)]] = [:]
|
||||||
for rawLine in ppd.split(separator: "\n") {
|
for rawLine in ppd.split(separator: "\n") {
|
||||||
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
||||||
@@ -183,7 +188,9 @@ public enum CupsParsers {
|
|||||||
// Optional locale qualifier: `en_US.InputSlot` → `InputSlot`.
|
// Optional locale qualifier: `en_US.InputSlot` → `InputSlot`.
|
||||||
// Only strip when the part before the first `.` looks like
|
// Only strip when the part before the first `.` looks like
|
||||||
// a locale (short `xx`/`xx_YY`); real keys containing dots
|
// a locale (short `xx`/`xx_YY`); real keys containing dots
|
||||||
// are left alone.
|
// are left alone. The qualifier is recorded for precedence
|
||||||
|
// rather than dropped (#181).
|
||||||
|
var qualifier: String?
|
||||||
if let dot = line.firstIndex(of: ".") {
|
if let dot = line.firstIndex(of: ".") {
|
||||||
let prefix = line[..<dot]
|
let prefix = line[..<dot]
|
||||||
let looksLikeLocale = (2...5).contains(prefix.count)
|
let looksLikeLocale = (2...5).contains(prefix.count)
|
||||||
@@ -191,6 +198,7 @@ public enum CupsParsers {
|
|||||||
&& (prefix.count == 2 || prefix.contains("_"))
|
&& (prefix.count == 2 || prefix.contains("_"))
|
||||||
let candidate = line[line.index(after: dot)...]
|
let candidate = line[line.index(after: dot)...]
|
||||||
if looksLikeLocale && candidate.hasPrefix(key) {
|
if looksLikeLocale && candidate.hasPrefix(key) {
|
||||||
|
qualifier = prefix.lowercased()
|
||||||
line = String(candidate)
|
line = String(candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,15 +209,50 @@ public enum CupsParsers {
|
|||||||
rest = String(rest[..<colon])
|
rest = String(rest[..<colon])
|
||||||
// `<id>/<Human label>` — human label after the last `/`.
|
// `<id>/<Human label>` — human label after the last `/`.
|
||||||
guard let slash = rest.firstIndex(of: "/") else { continue }
|
guard let slash = rest.firstIndex(of: "/") else { continue }
|
||||||
let id = String(rest[..<slash])
|
let id = ppdUnescape(String(rest[..<slash])
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces))
|
||||||
let human = String(rest[rest.index(after: slash)...])
|
let human = ppdUnescape(String(rest[rest.index(after: slash)...])
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces))
|
||||||
if !id.isEmpty { map[id] = human.isEmpty ? id : human }
|
guard !id.isEmpty else { continue }
|
||||||
|
hits[id, default: []].append(
|
||||||
|
(qualifier, human.isEmpty ? id : human))
|
||||||
|
}
|
||||||
|
var map: [String: String] = [:]
|
||||||
|
for (id, candidates) in hits {
|
||||||
|
// First occurrence wins inside each qualifier class, in
|
||||||
|
// file order — same as the old sequential behaviour.
|
||||||
|
map[id] = candidates.first { $0.qualifier == nil }?.label
|
||||||
|
?? candidates.first { $0.qualifier == "en_us" }?.label
|
||||||
|
?? candidates.first { $0.qualifier == "en" }?.label
|
||||||
|
?? candidates[0].label
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PPD `<XX>` hex escapes → the literal byte (`<2F>` → `/`,
|
||||||
|
/// `<20>` → space). Anything that is not `<` + two hex digits +
|
||||||
|
/// `>` passes through untouched (#181).
|
||||||
|
private static func ppdUnescape(_ text: String) -> String {
|
||||||
|
var result = ""
|
||||||
|
var index = text.startIndex
|
||||||
|
while index < text.endIndex {
|
||||||
|
guard text[index] == "<",
|
||||||
|
let hexEnd = text.index(
|
||||||
|
index, offsetBy: 3, limitedBy: text.endIndex),
|
||||||
|
hexEnd < text.endIndex, text[hexEnd] == ">",
|
||||||
|
let byte = UInt8(
|
||||||
|
text[text.index(after: index)..<hexEnd], radix: 16)
|
||||||
|
else {
|
||||||
|
result.append(text[index])
|
||||||
|
index = text.index(after: index)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.append(Character(UnicodeScalar(byte)))
|
||||||
|
index = text.index(after: hexEnd)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Detection (docs/11)
|
// MARK: - Detection (docs/11)
|
||||||
|
|
||||||
/// Media-type option key in preference order — used both to read a
|
/// Media-type option key in preference order — used both to read a
|
||||||
@@ -223,13 +266,20 @@ public enum CupsParsers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Media type from a captured `key=value key=value` options string.
|
/// Media type from a captured `key=value key=value` options string.
|
||||||
/// Prefers `MediaType`, then `EPIJ_Medi` (docs/11 §tests).
|
/// Prefers `MediaType` (docs/11 §tests), then the remaining roster
|
||||||
|
/// keys in detection order — `CNIJMediaType`, `EPIJ_Medi`,
|
||||||
|
/// `StpMediaType` (#186 capture-return).
|
||||||
public static func extractMediaType(fromOptionsString options: String) -> String? {
|
public static func extractMediaType(fromOptionsString options: String) -> String? {
|
||||||
let pairs = lpoptions(options)
|
let pairs = lpoptions(options)
|
||||||
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
|
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value
|
for key in mediaTypeKeys where key != "MediaType" {
|
||||||
|
if let v = pairs.first(where: { $0.key == key })?.value {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print-quality option key in preference order — vendor-first,
|
/// Print-quality option key in preference order — vendor-first,
|
||||||
|
|||||||
@@ -187,7 +187,11 @@ public struct CupsService: Sendable {
|
|||||||
|
|
||||||
private func loadPPD(for queue: String) -> String? {
|
private func loadPPD(for queue: String) -> String? {
|
||||||
let url = ppdDir.appendingPathComponent("\(queue).ppd")
|
let url = ppdDir.appendingPathComponent("\(queue).ppd")
|
||||||
return try? String(contentsOf: url, encoding: .utf8)
|
// UTF-8 first — the Canon Thai labels are UTF-8 and a blanket
|
||||||
|
// Latin-1 read would mojibake them (#181, R10). Latin-1 only
|
||||||
|
// when UTF-8 decoding fails outright.
|
||||||
|
return (try? String(contentsOf: url, encoding: .utf8))
|
||||||
|
?? (try? String(contentsOf: url, encoding: .isoLatin1))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Spawn
|
// MARK: - Spawn
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|
|||||||
| M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped |
|
| M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped |
|
||||||
| M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped |
|
| M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped |
|
||||||
| M10 | Studio workflow: media library (#146), gamut compare (#147), Spot Read (#148), project files (#149) — shipped on `develop` |
|
| M10 | Studio workflow: media library (#146), gamut compare (#147), Spot Read (#148), project files (#149) — shipped on `develop` |
|
||||||
| M11 | Printer settings completeness & dialog binding (#183, #180, #181, #186) — in flight on `milestone/m11-print-settings` |
|
| M11 | Printer settings completeness & dialog binding (#183, #180, #181, #186) — shipped on `develop` (PR #199) |
|
||||||
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
@@ -299,9 +299,9 @@ fix/<issue>-<slug>
|
|||||||
```
|
```
|
||||||
|
|
||||||
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
|
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
|
||||||
branch is used only while that milestone is assembling — currently
|
branch is used only while that milestone is assembling; `milestone/m10-studio`
|
||||||
`milestone/m11-print-settings` (`milestone/m10-studio` was merged and
|
and `milestone/m11-print-settings` have been merged (branches retained). Do
|
||||||
deleted). Do not open umbrella “bugfix” branches that mix tickets.
|
not open umbrella “bugfix” branches that mix tickets.
|
||||||
|
|
||||||
`main` is push-protected and requires status check
|
`main` is push-protected and requires status check
|
||||||
`macOS CI / build-and-test (push)`. Protected **file** patterns on `main`
|
`macOS CI / build-and-test (push)`. Protected **file** patterns on `main`
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import AppKit
|
||||||
|
import ApplicationServices
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Errors raised by Core Printing bridge operations.
|
||||||
|
enum PMTicketError: LocalizedError, Equatable {
|
||||||
|
case printerUnknown(queue: String)
|
||||||
|
case sessionBindingFailed(OSStatus)
|
||||||
|
case serialiseFailed(stage: String, status: OSStatus)
|
||||||
|
case restoreFailed(stage: String, status: OSStatus)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .printerUnknown(let queue):
|
||||||
|
return "No printer found for queue '\(queue)'."
|
||||||
|
case .sessionBindingFailed(let status):
|
||||||
|
return "Could not bind the print session to the queue (OSStatus \(status))."
|
||||||
|
case .serialiseFailed(let stage, let status):
|
||||||
|
return "Print ticket capture failed at \(stage) (OSStatus \(status))."
|
||||||
|
case .restoreFailed(let stage, let status):
|
||||||
|
return "Print ticket restore failed at \(stage) (OSStatus \(status))."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every Core Printing (`PM*`) call in ICCery lives here (#201 D12).
|
||||||
|
///
|
||||||
|
/// Ownership — the ONLY rules, do not improvise:
|
||||||
|
/// * `PMPrinterCreateFromPrinterID`, `PMCreatePageFormatWithPMPaper`,
|
||||||
|
/// `PMPrintSettingsCreateWithDataRepresentation`,
|
||||||
|
/// `PMPageFormatCreateWithDataRepresentation` return **+1** →
|
||||||
|
/// `PMRelease(object(_:))` on every path (`defer`).
|
||||||
|
/// * `PM*CreateDataRepresentation` writes a **+1 CFData** →
|
||||||
|
/// `takeRetainedValue()` (the headers say "the caller is responsible
|
||||||
|
/// for releasing"; the out-param is not `CF_RETURNS_RETAINED`-annotated,
|
||||||
|
/// so Swift hands back an unbalanced `Unmanaged`).
|
||||||
|
/// * `PMPrinterGetPaperList`, `PMPaperGetID`, `PMPrinterGetID`,
|
||||||
|
/// `PMPrintSettingsGetValue` are **borrowed** → `takeUnretainedValue()`,
|
||||||
|
/// never released. `PMSessionGetCurrentPrinter` hands back the
|
||||||
|
/// session's own printer — also borrowed, never `PMRelease`d (an
|
||||||
|
/// over-release here dangles the session and crashes AppKit's
|
||||||
|
/// `_printerInPrintSession` / session teardown).
|
||||||
|
/// * `printInfo.pmPrintSession()/pmPrintSettings()/pmPageFormat()` are
|
||||||
|
/// borrowed from the `NSPrintInfo` → never released.
|
||||||
|
@MainActor
|
||||||
|
enum PMTicketBridge {
|
||||||
|
|
||||||
|
// MARK: Handles
|
||||||
|
|
||||||
|
/// Borrowed `PMPrintSession` from the `NSPrintInfo` — never released.
|
||||||
|
static func session(_ printInfo: NSPrintInfo) -> PMPrintSession {
|
||||||
|
unsafeBitCast(printInfo.pmPrintSession(), to: PMPrintSession.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrowed `PMPrintSettings` from the `NSPrintInfo` — never released.
|
||||||
|
static func settings(_ printInfo: NSPrintInfo) -> PMPrintSettings {
|
||||||
|
unsafeBitCast(printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrowed `PMPageFormat` from the `NSPrintInfo` — never released.
|
||||||
|
static func pageFormat(_ printInfo: NSPrintInfo) -> PMPageFormat {
|
||||||
|
unsafeBitCast(printInfo.pmPageFormat(), to: PMPageFormat.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Any `PM*` handle → `PMObject` for `PMRelease` — the Carbon API
|
||||||
|
/// wants `UnsafeRawPointer`, Swift imports handles as `OpaquePointer`.
|
||||||
|
static func object<Handle>(_ handle: Handle) -> PMObject {
|
||||||
|
unsafeBitCast(handle, to: PMObject.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Binding (layer ①)
|
||||||
|
|
||||||
|
/// `PMPrinterCreateFromPrinterID` — **+1**, caller must
|
||||||
|
/// `PMTicketBridge.release` on every path.
|
||||||
|
static func makePrinter(queue: String) -> PMPrinter? {
|
||||||
|
PMPrinterCreateFromPrinterID(queue as CFString)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Balances `makePrinter(queue:)` / any +1 PM handle.
|
||||||
|
static func release(_ printer: PMPrinter) {
|
||||||
|
PMRelease(object(printer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PMSessionSetCurrentPMPrinter` + session defaults (warn-only).
|
||||||
|
/// Throws `PMTicketError.sessionBindingFailed` when the bind itself
|
||||||
|
/// fails — the caller still owns `printer` on every path.
|
||||||
|
static func bind(printer: PMPrinter, to printInfo: NSPrintInfo) throws {
|
||||||
|
let session = session(printInfo)
|
||||||
|
let status = PMSessionSetCurrentPMPrinter(session, printer)
|
||||||
|
guard status == 0 else {
|
||||||
|
throw PMTicketError.sessionBindingFailed(status)
|
||||||
|
}
|
||||||
|
// Warn-only: defaults keep the panel consistent with the
|
||||||
|
// queue but are not fatal when they fail.
|
||||||
|
_ = PMSessionDefaultPrintSettings(session, settings(printInfo))
|
||||||
|
_ = PMSessionDefaultPageFormat(session, pageFormat(printInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String.
|
||||||
|
static func currentPrinterID(
|
||||||
|
session: PMPrintSession,
|
||||||
|
fallback: String
|
||||||
|
) -> String {
|
||||||
|
var current: PMPrinter?
|
||||||
|
guard PMSessionGetCurrentPrinter(session, ¤t) == 0,
|
||||||
|
let printer = current
|
||||||
|
else { return fallback }
|
||||||
|
// Borrowed from the session — never released (see header doc).
|
||||||
|
guard let id = PMPrinterGetID(printer)
|
||||||
|
else { return fallback }
|
||||||
|
return id.takeUnretainedValue() as String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Ticket (#201 D3)
|
||||||
|
|
||||||
|
/// Capture the live `PMPrintSettings` + `PMPageFormat` as XML
|
||||||
|
/// `Data` plus a plist-safe `NSPrintInfo.dictionary()` fallback.
|
||||||
|
/// The CFData out-params are **+1** by header contract
|
||||||
|
/// ("the caller is responsible for releasing") →
|
||||||
|
/// `takeRetainedValue()`.
|
||||||
|
static func serialise(
|
||||||
|
_ printInfo: NSPrintInfo,
|
||||||
|
queue: String
|
||||||
|
) throws -> PrintTicket {
|
||||||
|
var settingsRef: Unmanaged<CFData>?
|
||||||
|
let settingsStatus = PMPrintSettingsCreateDataRepresentation(
|
||||||
|
settings(printInfo), &settingsRef, kPMDataFormatXMLDefault)
|
||||||
|
guard settingsStatus == noErr, let settingsRef else {
|
||||||
|
throw PMTicketError.serialiseFailed(
|
||||||
|
stage: "printSettings", status: settingsStatus)
|
||||||
|
}
|
||||||
|
let settingsData = settingsRef.takeRetainedValue() as Data
|
||||||
|
|
||||||
|
var pageFormatRef: Unmanaged<CFData>?
|
||||||
|
let pageFormatStatus = PMPageFormatCreateDataRepresentation(
|
||||||
|
pageFormat(printInfo), &pageFormatRef, kPMDataFormatXMLDefault)
|
||||||
|
guard pageFormatStatus == noErr, let pageFormatRef else {
|
||||||
|
throw PMTicketError.serialiseFailed(
|
||||||
|
stage: "pageFormat", status: pageFormatStatus)
|
||||||
|
}
|
||||||
|
let pageFormatData = pageFormatRef.takeRetainedValue() as Data
|
||||||
|
|
||||||
|
// Cocoa-level fallback — warn-only, never fatal: filter the
|
||||||
|
// dictionary to plist-safe values so one exotic attribute
|
||||||
|
// cannot fail the whole snapshot.
|
||||||
|
let plist = try? PropertyListSerialization.data(
|
||||||
|
fromPropertyList: plistSafe(printInfo.dictionary()) ?? [:],
|
||||||
|
format: .binary, options: 0)
|
||||||
|
|
||||||
|
return PrintTicket(
|
||||||
|
queue: queue,
|
||||||
|
printSettings: settingsData,
|
||||||
|
pageFormat: pageFormatData,
|
||||||
|
printInfoPlist: plist,
|
||||||
|
capturedAt: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rehydrate a ticket into `printInfo`'s live PM objects. Order is
|
||||||
|
/// load-bearing: create → copy → session-validate → Cocoa update.
|
||||||
|
/// Cross-queue replay is refused (R3) — the destination's bound
|
||||||
|
/// queue is the session's current printer; a destination with no
|
||||||
|
/// bound printer accepts the ticket (the spooler always binds
|
||||||
|
/// first, so production replay is always guarded).
|
||||||
|
/// A page-format failure is warn-only — paper is re-derived
|
||||||
|
/// upstream by the spooler's S6/S7.
|
||||||
|
static func restore(
|
||||||
|
_ ticket: PrintTicket,
|
||||||
|
into printInfo: NSPrintInfo
|
||||||
|
) throws {
|
||||||
|
let bound = currentPrinterID(
|
||||||
|
session: session(printInfo),
|
||||||
|
fallback: ticket.queue)
|
||||||
|
guard ticket.queue == bound else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"PrintTicket: refusing to replay a ticket captured "
|
||||||
|
+ "for '\(ticket.queue)' onto '\(bound)'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var srcSettings: PMPrintSettings?
|
||||||
|
let createStatus = PMPrintSettingsCreateWithDataRepresentation(
|
||||||
|
ticket.printSettings as CFData, &srcSettings)
|
||||||
|
defer {
|
||||||
|
if let srcSettings { PMRelease(object(srcSettings)) }
|
||||||
|
}
|
||||||
|
guard createStatus == noErr, let srcSettings else {
|
||||||
|
throw PMTicketError.restoreFailed(
|
||||||
|
stage: "printSettings", status: createStatus)
|
||||||
|
}
|
||||||
|
let copyStatus = PMCopyPrintSettings(
|
||||||
|
srcSettings, settings(printInfo))
|
||||||
|
guard copyStatus == noErr else {
|
||||||
|
throw PMTicketError.restoreFailed(
|
||||||
|
stage: "printSettings", status: copyStatus)
|
||||||
|
}
|
||||||
|
var changed = DarwinBoolean(false)
|
||||||
|
_ = PMSessionValidatePrintSettings(
|
||||||
|
session(printInfo), settings(printInfo), &changed)
|
||||||
|
if changed.boolValue {
|
||||||
|
AppLogger.shared.info(
|
||||||
|
"PrintTicket: driver adjusted the restored ticket")
|
||||||
|
}
|
||||||
|
printInfo.updateFromPMPrintSettings()
|
||||||
|
|
||||||
|
// Page format — warn-only.
|
||||||
|
var srcFormat: PMPageFormat?
|
||||||
|
let formatStatus = PMPageFormatCreateWithDataRepresentation(
|
||||||
|
ticket.pageFormat as CFData, &srcFormat)
|
||||||
|
defer {
|
||||||
|
if let srcFormat { PMRelease(object(srcFormat)) }
|
||||||
|
}
|
||||||
|
guard formatStatus == noErr, let srcFormat else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"PrintTicket: page format restore failed "
|
||||||
|
+ "(\(formatStatus)) — paper re-derived upstream")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard PMCopyPageFormat(srcFormat, pageFormat(printInfo)) == noErr
|
||||||
|
else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"PrintTicket: page format copy failed — "
|
||||||
|
+ "paper re-derived upstream")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var formatChanged = DarwinBoolean(false)
|
||||||
|
_ = PMSessionValidatePageFormat(
|
||||||
|
session(printInfo), pageFormat(printInfo), &formatChanged)
|
||||||
|
printInfo.updateFromPMPageFormat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursive plist-safety filter for `NSPrintInfo.dictionary()`:
|
||||||
|
/// keeps String / NSNumber / Bool / Date / Data / URL (→
|
||||||
|
/// absoluteString) / Array / Dictionary, drops everything else, so
|
||||||
|
/// one non-plist attribute cannot fail the whole snapshot.
|
||||||
|
private static func plistSafe(_ value: Any) -> Any? {
|
||||||
|
switch value {
|
||||||
|
case let string as String:
|
||||||
|
return string
|
||||||
|
case let number as NSNumber:
|
||||||
|
return number
|
||||||
|
case let date as Date:
|
||||||
|
return date
|
||||||
|
case let data as Data:
|
||||||
|
return data
|
||||||
|
case let url as URL:
|
||||||
|
return url.absoluteString
|
||||||
|
case let array as [Any]:
|
||||||
|
return array.compactMap(plistSafe)
|
||||||
|
case let dictionary as [String: Any]:
|
||||||
|
var safe: [String: Any] = [:]
|
||||||
|
for (key, element) in dictionary {
|
||||||
|
if let filtered = plistSafe(element) {
|
||||||
|
safe[key] = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return safe
|
||||||
|
case let dictionary as [NSPrintInfo.AttributeKey: Any]:
|
||||||
|
var safe: [String: Any] = [:]
|
||||||
|
for (key, element) in dictionary {
|
||||||
|
if let filtered = plistSafe(element) {
|
||||||
|
safe[key.rawValue] = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return safe
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Values
|
||||||
|
|
||||||
|
/// Warn-only `PMPrintSettingsSetValue`: a driver that rejects a key
|
||||||
|
/// must not abort the caller's flow. Returns `true` on success.
|
||||||
|
@discardableResult
|
||||||
|
static func setValue(
|
||||||
|
_ value: String,
|
||||||
|
forKey key: String,
|
||||||
|
locked: Bool,
|
||||||
|
in settings: PMPrintSettings,
|
||||||
|
context: String
|
||||||
|
) -> Bool {
|
||||||
|
let status = PMPrintSettingsSetValue(
|
||||||
|
settings, key as CFString, value as CFString, locked)
|
||||||
|
if status != 0 {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"\(context): PMPrintSettingsSetValue(\(key)) "
|
||||||
|
+ "rejected (\(status))")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PMPrintSettingsGetValue` — borrowed value, never released.
|
||||||
|
/// `nil` for absent keys, non-string values, or lookup errors.
|
||||||
|
static func stringValue(
|
||||||
|
forKey key: String,
|
||||||
|
in settings: PMPrintSettings
|
||||||
|
) -> String? {
|
||||||
|
var value: Unmanaged<CFTypeRef>?
|
||||||
|
guard PMPrintSettingsGetValue(
|
||||||
|
settings, key as CFString, &value) == 0,
|
||||||
|
let ref = value?.takeUnretainedValue()
|
||||||
|
else { return nil }
|
||||||
|
return ref as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Paper
|
||||||
|
|
||||||
|
/// The `PMPageFormat` half of paper preselect (#186 E1): the
|
||||||
|
/// panel's paper dropdown reflects the page format's `PMPaper`,
|
||||||
|
/// not `PMPrintSettings`. Match the Stage 2 `PageSize` token to a
|
||||||
|
/// paper from `PMPrinterGetPaperList`, rebuild the page format
|
||||||
|
/// around it, and copy it into the printInfo's format (TN2248:
|
||||||
|
/// `PMCreatePageFormatWithPMPaper` → `PMSessionValidatePageFormat`
|
||||||
|
/// → `PMCopyPageFormat` → `updateFromPMPageFormat`).
|
||||||
|
/// `Custom.<w>x<h>` tokens (already points) have no `PMPaper` —
|
||||||
|
/// set the Cocoa `paperSize` directly. Warn-only throughout: a
|
||||||
|
/// missed match must not keep the panel from opening.
|
||||||
|
static func applyPaper(
|
||||||
|
token: String,
|
||||||
|
printer: PMPrinter,
|
||||||
|
session: PMPrintSession,
|
||||||
|
printInfo: NSPrintInfo
|
||||||
|
) {
|
||||||
|
if let custom = customPaperDimensions(from: token) {
|
||||||
|
printInfo.paperSize = NSSize(
|
||||||
|
width: custom.width, height: custom.height)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var paperList: Unmanaged<CFArray>?
|
||||||
|
guard PMPrinterGetPaperList(printer, &paperList) == 0,
|
||||||
|
let papers = paperList?.takeUnretainedValue()
|
||||||
|
else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: PMPrinterGetPaperList failed — "
|
||||||
|
+ "paper preselect skipped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The list (and its elements) is owned by the printer —
|
||||||
|
// borrowed, never released.
|
||||||
|
var match: PMPaper?
|
||||||
|
for index in 0..<CFArrayGetCount(papers) {
|
||||||
|
let paper = unsafeBitCast(
|
||||||
|
CFArrayGetValueAtIndex(papers, index), to: PMPaper.self)
|
||||||
|
var idRef: Unmanaged<CFString>?
|
||||||
|
guard PMPaperGetID(paper, &idRef) == 0,
|
||||||
|
let paperID = idRef?.takeUnretainedValue() as String?
|
||||||
|
else { continue }
|
||||||
|
if paperID == token {
|
||||||
|
match = paper
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let paper = match else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: no PMPaper id matches '\(token)'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var created: PMPageFormat?
|
||||||
|
guard PMCreatePageFormatWithPMPaper(&created, paper) == 0,
|
||||||
|
let newFormat = created
|
||||||
|
else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: PMCreatePageFormatWithPMPaper failed "
|
||||||
|
+ "for '\(token)'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer { PMRelease(object(newFormat)) }
|
||||||
|
_ = PMSessionValidatePageFormat(session, newFormat, nil)
|
||||||
|
_ = PMCopyPageFormat(newFormat, pageFormat(printInfo))
|
||||||
|
printInfo.updateFromPMPageFormat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Custom.<w>x<h>` → dimensions in points (the token builder
|
||||||
|
/// emits integer points, mm × 72/25.4). `nil` for non-custom or
|
||||||
|
/// malformed tokens — a malformed `Custom.*` then misses the
|
||||||
|
/// `PMPaper` match and logs instead of guessing a size.
|
||||||
|
static func customPaperDimensions(
|
||||||
|
from token: String
|
||||||
|
) -> (width: Double, height: Double)? {
|
||||||
|
guard token.hasPrefix("Custom.") else { return nil }
|
||||||
|
let dims = token.dropFirst("Custom.".count).split(separator: "x")
|
||||||
|
guard dims.count == 2,
|
||||||
|
let width = Double(dims[0]), let height = Double(dims[1]),
|
||||||
|
width > 0, height > 0
|
||||||
|
else { return nil }
|
||||||
|
return (width, height)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,10 @@ import ICCeryCore
|
|||||||
|
|
||||||
/// Errors raised while preparing the bound print panel.
|
/// Errors raised while preparing the bound print panel.
|
||||||
enum PrintPanelError: LocalizedError {
|
enum PrintPanelError: LocalizedError {
|
||||||
case sessionBindingFailed(OSStatus)
|
|
||||||
case noPrinterFound(String)
|
case noPrinterFound(String)
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .sessionBindingFailed(let status):
|
|
||||||
return "Could not bind the print session to the queue (OSStatus \(status))."
|
|
||||||
case .noPrinterFound(let name):
|
case .noPrinterFound(let name):
|
||||||
return "No printer found for '\(name)'."
|
return "No printer found for '\(name)'."
|
||||||
}
|
}
|
||||||
@@ -18,18 +15,21 @@ enum PrintPanelError: LocalizedError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stage 2 selections pre-applied to the bound print panel before it
|
/// Stage 2 selections pre-applied to the bound print panel before it
|
||||||
/// opens (#183). This phase consumes `paperSize` + `qualityKey`/
|
/// opens (#183/#186). Every write is warn-only — the panel still
|
||||||
/// `quality` only; `mediaType` and `orientation` preselect — and the
|
/// opens when a driver ignores a key.
|
||||||
/// `PMPageFormat`/`PMPaper` half of paper — are #186's scope.
|
|
||||||
struct PrintPanelInitialSelections {
|
struct PrintPanelInitialSelections {
|
||||||
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`.
|
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written
|
||||||
|
/// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1).
|
||||||
var paperSize: String?
|
var paperSize: String?
|
||||||
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
|
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
|
||||||
var qualityKey: String?
|
var qualityKey: String?
|
||||||
/// The selected quality token.
|
/// The selected quality token.
|
||||||
var quality: String?
|
var quality: String?
|
||||||
var mediaType: String? // #186 consumes
|
/// The selected media token — written to the queue's detected
|
||||||
var orientation: String? // #186 consumes
|
/// vendor key (`CNIJMediaType`/`EPIJ_Medi`/…) (#186).
|
||||||
|
var mediaType: String?
|
||||||
|
/// `"portrait"`/`"landscape"` → `orientation-requested` 3|4 (#186).
|
||||||
|
var orientation: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
||||||
@@ -59,10 +59,12 @@ struct PrintPanelService {
|
|||||||
cupsService: CupsService,
|
cupsService: CupsService,
|
||||||
initialSelections: PrintPanelInitialSelections =
|
initialSelections: PrintPanelInitialSelections =
|
||||||
PrintPanelInitialSelections()
|
PrintPanelInitialSelections()
|
||||||
) async throws -> PrintPropertiesResult? {
|
) async throws -> PanelCaptureResult? {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if UITestHooks.printPanelStubbed {
|
if UITestHooks.printPanelStubbed {
|
||||||
return UITestHooks.printPanelResult(forQueue: queue)
|
return UITestHooks.printPanelResult(forQueue: queue).map {
|
||||||
|
PanelCaptureResult(properties: $0, ticket: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
// `??` rhs is a non-async @autoclosure — fetch first.
|
// `??` rhs is a non-async @autoclosure — fetch first.
|
||||||
@@ -84,36 +86,43 @@ struct PrintPanelService {
|
|||||||
displayName: String?,
|
displayName: String?,
|
||||||
optionKeys: Set<String>,
|
optionKeys: Set<String>,
|
||||||
initialSelections: PrintPanelInitialSelections
|
initialSelections: PrintPanelInitialSelections
|
||||||
) throws -> PrintPropertiesResult? {
|
) throws -> PanelCaptureResult? {
|
||||||
let printInfo = NSPrintInfo()
|
let printInfo = NSPrintInfo()
|
||||||
var pmPrinter: PMPrinter?
|
var pmPrinter: PMPrinter?
|
||||||
var boundViaPM = false
|
var boundViaPM = false
|
||||||
|
|
||||||
// ① Bind the session to the selected CUPS queue (docs/11).
|
// ① Bind the session to the selected CUPS queue (docs/11).
|
||||||
if let printer = PMPrinterCreateFromPrinterID(queue as CFString) {
|
if let printer = PMTicketBridge.makePrinter(queue: queue) {
|
||||||
pmPrinter = printer
|
pmPrinter = printer
|
||||||
let session = unsafeBitCast(
|
let session = PMTicketBridge.session(printInfo)
|
||||||
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
let settings = PMTicketBridge.settings(printInfo)
|
||||||
let settings = unsafeBitCast(
|
|
||||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
|
||||||
let pageFormat = unsafeBitCast(
|
|
||||||
printInfo.pmPageFormat(), to: PMPageFormat.self)
|
|
||||||
|
|
||||||
let status = PMSessionSetCurrentPMPrinter(session, printer)
|
do {
|
||||||
if status != 0 {
|
try PMTicketBridge.bind(printer: printer, to: printInfo)
|
||||||
PMRelease(Self.pmObject(printer))
|
} catch {
|
||||||
throw PrintPanelError.sessionBindingFailed(status)
|
PMTicketBridge.release(printer)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
// Warn-only: defaults keep the panel consistent with the
|
|
||||||
// queue but are not fatal when they fail.
|
|
||||||
_ = PMSessionDefaultPrintSettings(session, settings)
|
|
||||||
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
|
||||||
// Initial selections — after `PMSessionDefault*`, before
|
// Initial selections — after `PMSessionDefault*`, before
|
||||||
// ColorSync suppression ②–⑤ (locked write order, #183).
|
// ColorSync suppression ②–⑤ (locked write order,
|
||||||
applyInitialSelections(initialSelections, to: settings)
|
// #183/#186). Paper is TWO writes (E1): the `PageSize`
|
||||||
|
// print-settings value drivers/capture read AND the
|
||||||
|
// `PMPageFormat` paper the panel's dropdown reflects.
|
||||||
|
applyInitialSelections(
|
||||||
|
initialSelections, to: settings, optionKeys: optionKeys)
|
||||||
|
if let paperToken = initialSelections.paperSize {
|
||||||
|
PMTicketBridge.applyPaper(
|
||||||
|
token: paperToken, printer: printer,
|
||||||
|
session: session, printInfo: printInfo)
|
||||||
|
}
|
||||||
boundViaPM = true
|
boundViaPM = true
|
||||||
} else {
|
} else {
|
||||||
// Fallback: NSPrinter by display name (docs/11 §binding).
|
// Fallback: NSPrinter by display name (docs/11 §binding).
|
||||||
|
// Warn — the display name can resolve a *different* queue
|
||||||
|
// (#186 E2: diagnosable, not a proven defect).
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: PM binding unavailable for '\(queue)' — "
|
||||||
|
+ "falling back to NSPrinter(displayName)")
|
||||||
guard let displayName,
|
guard let displayName,
|
||||||
let nsPrinter = NSPrinter(name: displayName)
|
let nsPrinter = NSPrinter(name: displayName)
|
||||||
else {
|
else {
|
||||||
@@ -125,19 +134,17 @@ struct PrintPanelService {
|
|||||||
}
|
}
|
||||||
defer {
|
defer {
|
||||||
if let printer = pmPrinter {
|
if let printer = pmPrinter {
|
||||||
PMRelease(Self.pmObject(printer))
|
PMTicketBridge.release(printer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ②–⑤ ColourSync suppression — only on the PM path: the SPI
|
// ②–⑤ ColourSync suppression — only on the PM path: the SPI
|
||||||
// and PMPrintSettingsSetValue need a session with a current
|
// and PMPrintSettingsSetValue need a session with a current
|
||||||
// printer to attach to.
|
// printer to attach to.
|
||||||
var settings = unsafeBitCast(
|
var settings = PMTicketBridge.settings(printInfo)
|
||||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
|
||||||
var driverBypass: (key: String, value: String)?
|
var driverBypass: (key: String, value: String)?
|
||||||
if boundViaPM {
|
if boundViaPM {
|
||||||
let session = unsafeBitCast(
|
let session = PMTicketBridge.session(printInfo)
|
||||||
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
|
||||||
suppressor.applySPIMode(to: session) // ②
|
suppressor.applySPIMode(to: session) // ②
|
||||||
suppressor.applyLockedKeys(to: settings) // ③
|
suppressor.applyLockedKeys(to: settings) // ③
|
||||||
driverBypass = suppressor.applyDriverBypass( // ④
|
driverBypass = suppressor.applyDriverBypass( // ④
|
||||||
@@ -166,70 +173,73 @@ struct PrintPanelService {
|
|||||||
var cupsOptions: String?
|
var cupsOptions: String?
|
||||||
var mediaType: String?
|
var mediaType: String?
|
||||||
if boundViaPM {
|
if boundViaPM {
|
||||||
settings = unsafeBitCast(
|
settings = PMTicketBridge.settings(printInfo)
|
||||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
|
||||||
let captured = suppressor.captureOptions(from: settings)
|
let captured = suppressor.captureOptions(from: settings)
|
||||||
cupsOptions = captured.cupsOptions
|
cupsOptions = captured.cupsOptions
|
||||||
mediaType = captured.mediaType
|
mediaType = captured.mediaType
|
||||||
}
|
}
|
||||||
let capturedOptions = cupsOptions ?? ""
|
let capturedOptions = cupsOptions ?? ""
|
||||||
return PrintPropertiesResult(
|
let resolvedQueue = boundViaPM
|
||||||
selectedPrinter: boundViaPM
|
? PMTicketBridge.currentPrinterID(
|
||||||
? Self.currentPrinterID(
|
session: PMTicketBridge.session(printInfo),
|
||||||
session: unsafeBitCast(
|
fallback: queue)
|
||||||
printInfo.pmPrintSession(), to: PMPrintSession.self),
|
: queue
|
||||||
fallback: queue)
|
// ⑦ Serialise the native ticket — the payload `lp -o` could
|
||||||
: nil,
|
// never carry (#201). Warn-only via `try?`: a serialise
|
||||||
options: PrintOptions(
|
// failure must not lose the Stage 2 mirror above.
|
||||||
orientation: CupsParsers.extractOrientation(
|
let ticket = try? PMTicketBridge.serialise(
|
||||||
fromOptionsString: capturedOptions),
|
printInfo, queue: resolvedQueue)
|
||||||
paperSize: CupsParsers.extractOption(
|
return PanelCaptureResult(
|
||||||
named: "PageSize", fromOptionsString: capturedOptions),
|
properties: PrintPropertiesResult(
|
||||||
mediaType: mediaType,
|
selectedPrinter: boundViaPM ? resolvedQueue : nil,
|
||||||
quality: CupsParsers.extractQuality(
|
options: PrintOptions(
|
||||||
fromOptionsString: capturedOptions),
|
orientation: CupsParsers.extractOrientation(
|
||||||
ppdUncorrectedPassthrough: true,
|
fromOptionsString: capturedOptions),
|
||||||
cupsOptions: cupsOptions))
|
paperSize: CupsParsers.extractOption(
|
||||||
|
named: "PageSize",
|
||||||
|
fromOptionsString: capturedOptions),
|
||||||
|
mediaType: mediaType,
|
||||||
|
quality: CupsParsers.extractQuality(
|
||||||
|
fromOptionsString: capturedOptions),
|
||||||
|
ppdUncorrectedPassthrough: true,
|
||||||
|
cupsOptions: cupsOptions)),
|
||||||
|
ticket: ticket)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initial-selection `PMPrintSettings` writes — paper size and
|
/// Initial-selection `PMPrintSettings` writes — paper, quality,
|
||||||
/// quality only this phase; media type / orientation and the
|
/// media type, orientation. All warn-only: a driver that ignores
|
||||||
/// `PMPageFormat`/`PMPaper` paper half are #186's contract.
|
/// a key must not keep the panel from opening (R12 surfaces via
|
||||||
|
/// the capture echo instead).
|
||||||
private func applyInitialSelections(
|
private func applyInitialSelections(
|
||||||
_ selections: PrintPanelInitialSelections,
|
_ selections: PrintPanelInitialSelections,
|
||||||
to settings: PMPrintSettings
|
to settings: PMPrintSettings,
|
||||||
|
optionKeys: Set<String>
|
||||||
) {
|
) {
|
||||||
if let paperSize = selections.paperSize {
|
if let paperSize = selections.paperSize {
|
||||||
_ = PMPrintSettingsSetValue(
|
PMTicketBridge.setValue(
|
||||||
settings, "PageSize" as CFString,
|
paperSize, forKey: "PageSize", locked: false,
|
||||||
paperSize as CFString, false)
|
in: settings, context: "Print panel")
|
||||||
}
|
}
|
||||||
if let key = selections.qualityKey, let value = selections.quality {
|
if let key = selections.qualityKey, let value = selections.quality {
|
||||||
_ = PMPrintSettingsSetValue(
|
PMTicketBridge.setValue(
|
||||||
settings, key as CFString, value as CFString, false)
|
value, forKey: key, locked: false,
|
||||||
|
in: settings, context: "Print panel")
|
||||||
|
}
|
||||||
|
// Media type via the queue's detected vendor key (#186).
|
||||||
|
if let mediaType = selections.mediaType,
|
||||||
|
let mediaKey = CupsParsers.detectMediaTypeKey(
|
||||||
|
optionKeys: optionKeys) {
|
||||||
|
PMTicketBridge.setValue(
|
||||||
|
mediaType, forKey: mediaKey, locked: false,
|
||||||
|
in: settings, context: "Print panel")
|
||||||
|
}
|
||||||
|
// Orientation — portrait=3, landscape=4 (CUPS IPP codes).
|
||||||
|
if let orientation = selections.orientation {
|
||||||
|
let code = orientation == "landscape" ? "4" : "3"
|
||||||
|
PMTicketBridge.setValue(
|
||||||
|
code, forKey: "orientation-requested", locked: false,
|
||||||
|
in: settings, context: "Print panel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PM helpers
|
|
||||||
|
|
||||||
/// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants
|
|
||||||
/// `UnsafeRawPointer`, Swift imports `PMPrinter` as `OpaquePointer`.
|
|
||||||
static func pmObject(_ printer: PMPrinter) -> PMObject {
|
|
||||||
unsafeBitCast(printer, to: PMObject.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String.
|
|
||||||
private static func currentPrinterID(
|
|
||||||
session: PMPrintSession,
|
|
||||||
fallback: String
|
|
||||||
) -> String {
|
|
||||||
var current: PMPrinter?
|
|
||||||
guard PMSessionGetCurrentPrinter(session, ¤t) == 0,
|
|
||||||
let printer = current
|
|
||||||
else { return fallback }
|
|
||||||
defer { PMRelease(pmObject(printer)) }
|
|
||||||
guard let id = PMPrinterGetID(printer)
|
|
||||||
else { return fallback }
|
|
||||||
return id.takeUnretainedValue() as String
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
@Published var selectedQuality: String?
|
@Published var selectedQuality: String?
|
||||||
@Published var printOrientation = "portrait"
|
@Published var printOrientation = "portrait"
|
||||||
@Published var capturedCupsOptions: [String: String] = [:]
|
@Published var capturedCupsOptions: [String: String] = [:]
|
||||||
|
/// Native print tickets per queue — the spool payload (#201).
|
||||||
|
/// Session-only; cleared whenever the queue's mirror is.
|
||||||
|
@Published var capturedTickets: [String: PrintTicket] = [:]
|
||||||
@Published var printNotice: Notice?
|
@Published var printNotice: Notice?
|
||||||
@Published var isPrinting = false
|
@Published var isPrinting = false
|
||||||
private var printTask: Task<Void, Never>?
|
private var printTask: Task<Void, Never>?
|
||||||
@@ -147,8 +150,8 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
paperSize: selectedPaperSizeToken,
|
paperSize: selectedPaperSizeToken,
|
||||||
qualityKey: printerCaps.qualityKey,
|
qualityKey: printerCaps.qualityKey,
|
||||||
quality: selectedQuality,
|
quality: selectedQuality,
|
||||||
mediaType: nil,
|
mediaType: selectedMediaType,
|
||||||
orientation: nil)
|
orientation: printOrientation)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
guard let result = try await PrintPanelService()
|
guard let result = try await PrintPanelService()
|
||||||
@@ -164,29 +167,37 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let selected = result.selectedPrinter,
|
if let selected = result.properties.selectedPrinter,
|
||||||
printers.contains(where: { $0.name == selected }),
|
printers.contains(where: { $0.name == selected }),
|
||||||
selected != queue {
|
selected != queue {
|
||||||
selectedPrinter = selected
|
selectedPrinter = selected
|
||||||
await reloadSelectedCapabilities()
|
await reloadSelectedCapabilities()
|
||||||
}
|
}
|
||||||
if let captured = result.options.cupsOptions {
|
if let captured = result.properties.options.cupsOptions {
|
||||||
capturedCupsOptions[selectedPrinter] = captured
|
capturedCupsOptions[selectedPrinter] = captured
|
||||||
}
|
}
|
||||||
if let media = result.options.mediaType {
|
// The native ticket rides alongside the mirror (#201).
|
||||||
|
if let ticket = result.ticket {
|
||||||
|
capturedTickets[ticket.queue] = ticket
|
||||||
|
}
|
||||||
|
if let media = result.properties.options.mediaType {
|
||||||
selectedMediaType = media
|
selectedMediaType = media
|
||||||
}
|
}
|
||||||
// Capture-return (#183): a dialog paper/quality change
|
// Capture-return (#183/#186): a dialog paper/quality/
|
||||||
// updates the Stage 2 selections — never
|
// orientation change updates the Stage 2 selections —
|
||||||
// `workflow.pageSize` (printtarg layout is sacred).
|
// never `workflow.pageSize` (printtarg layout is
|
||||||
if let paper = result.options.paperSize,
|
// sacred).
|
||||||
|
if let paper = result.properties.options.paperSize,
|
||||||
let match = printerCaps.paperSizes
|
let match = printerCaps.paperSizes
|
||||||
.first(where: { $0.name == paper }) {
|
.first(where: { $0.name == paper }) {
|
||||||
selectedPaperSize = match.id
|
selectedPaperSize = match.id
|
||||||
}
|
}
|
||||||
if let quality = result.options.quality {
|
if let quality = result.properties.options.quality {
|
||||||
selectedQuality = quality
|
selectedQuality = quality
|
||||||
}
|
}
|
||||||
|
if let orientation = result.properties.options.orientation {
|
||||||
|
printOrientation = orientation
|
||||||
|
}
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
kind: .info,
|
kind: .info,
|
||||||
text: "Settings captured for \(selectedPrinter).",
|
text: "Settings captured for \(selectedPrinter).",
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// A captured native print ticket (#201) — the payload `lp -o` could
|
||||||
|
/// never carry. Vendor PDE state, including opaque binary blobs, is
|
||||||
|
/// preserved verbatim inside `printSettings`.
|
||||||
|
///
|
||||||
|
/// In-memory, session-only. Never written to `settings.json` or an
|
||||||
|
/// `.icceryproj` (a ticket is queue- and driver-version-specific).
|
||||||
|
struct PrintTicket: Equatable, Sendable {
|
||||||
|
/// CUPS queue id this ticket was captured for. Replay onto any other
|
||||||
|
/// queue is refused (R3).
|
||||||
|
let queue: String
|
||||||
|
/// `PMPrintSettingsCreateDataRepresentation(…, kPMDataFormatXMLDefault)`.
|
||||||
|
let printSettings: Data
|
||||||
|
/// `PMPageFormatCreateDataRepresentation(…, kPMDataFormatXMLDefault)`.
|
||||||
|
let pageFormat: Data
|
||||||
|
/// Binary-plist snapshot of `NSPrintInfo.dictionary()`, plist-filtered.
|
||||||
|
/// Cocoa-level fallback only — never the primary restore path.
|
||||||
|
let printInfoPlist: Data?
|
||||||
|
let capturedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App-level panel outcome: the ICCeryCore `PrintPropertiesResult`
|
||||||
|
/// (Stage 2 mirror values, #183/#186) **plus** the native ticket the
|
||||||
|
/// spooler replays (#201).
|
||||||
|
struct PanelCaptureResult {
|
||||||
|
var properties: PrintPropertiesResult
|
||||||
|
var ticket: PrintTicket?
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Headless print canvas (#201, docs/14 §6). Never installed in a
|
||||||
|
/// window; `NSPrintOperation` is its only client.
|
||||||
|
@MainActor
|
||||||
|
final class TargetPageCanvasView: NSView {
|
||||||
|
|
||||||
|
private let pages: [TargetPageRaster]
|
||||||
|
private let paperSize: NSSize
|
||||||
|
|
||||||
|
/// Test probe — invoked once per drawn page with the live context
|
||||||
|
/// so the interpolation/antialias contract is assertable without a
|
||||||
|
/// printer.
|
||||||
|
var drawProbe: ((NSGraphicsContext, Int, NSRect) -> Void)?
|
||||||
|
|
||||||
|
init(pages: [TargetPageRaster], paperSize: NSSize) {
|
||||||
|
self.pages = pages
|
||||||
|
self.paperSize = paperSize
|
||||||
|
super.init(frame: NSRect(
|
||||||
|
x: 0, y: 0,
|
||||||
|
width: paperSize.width,
|
||||||
|
height: paperSize.height * CGFloat(pages.count)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("TargetPageCanvasView is code-only")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flipped: the CoreGraphics coordinate space is correctly oriented
|
||||||
|
/// top-down, so page 1 is the TOP rect.
|
||||||
|
override var isFlipped: Bool { true }
|
||||||
|
override var isOpaque: Bool { true }
|
||||||
|
|
||||||
|
override func knowsPageRange(_ range: NSRangePointer) -> Bool {
|
||||||
|
range.pointee = NSRange(location: 1, length: pages.count)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1-based page → its paper-sized rect, stacked top-down.
|
||||||
|
override func rectForPage(_ page: Int) -> NSRect {
|
||||||
|
NSRect(x: 0,
|
||||||
|
y: CGFloat(page - 1) * paperSize.height,
|
||||||
|
width: paperSize.width,
|
||||||
|
height: paperSize.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where page `page`'s image lands: top-left anchored inside
|
||||||
|
/// `rectForPage`, sized `raster.pointSize`, origin snapped to
|
||||||
|
/// 0.001 pt (docs/14 §6) so a fractional origin cannot trigger
|
||||||
|
/// device resampling. `internal` for unit tests.
|
||||||
|
func destinationRect(forPage page: Int) -> NSRect {
|
||||||
|
let pageRect = rectForPage(page)
|
||||||
|
let size = pages[page - 1].pointSize
|
||||||
|
return NSRect(x: Self.snap(pageRect.minX),
|
||||||
|
y: Self.snap(pageRect.minY),
|
||||||
|
width: size.width,
|
||||||
|
height: size.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
guard let ctx = NSGraphicsContext.current else { return }
|
||||||
|
ctx.imageInterpolation = .none
|
||||||
|
ctx.shouldAntialias = false
|
||||||
|
let cg = ctx.cgContext
|
||||||
|
cg.interpolationQuality = .none
|
||||||
|
cg.setShouldAntialias(false)
|
||||||
|
cg.setAllowsAntialiasing(false)
|
||||||
|
cg.setShouldSmoothFonts(false)
|
||||||
|
for index in pages.indices {
|
||||||
|
let pageRect = rectForPage(index + 1)
|
||||||
|
guard pageRect.intersects(dirtyRect) else { continue }
|
||||||
|
cg.saveGState() // page N never inherits N-1's CTM
|
||||||
|
NSColor.white.setFill()
|
||||||
|
pageRect.fill()
|
||||||
|
cg.draw(pages[index].cgImage,
|
||||||
|
in: destinationRect(forPage: index + 1))
|
||||||
|
cg.restoreGState()
|
||||||
|
drawProbe?(ctx, index + 1, destinationRect(forPage: index + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func snap(_ value: CGFloat) -> CGFloat {
|
||||||
|
(value * 1000).rounded() / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
import ImageIO
|
||||||
|
|
||||||
|
/// One decoded target page ready to draw 1:1 (#201, docs/14 §6).
|
||||||
|
struct TargetPageRaster: Equatable {
|
||||||
|
let sourceURL: URL
|
||||||
|
/// Re-tagged into a **device** colour space — the image Quartz draws.
|
||||||
|
let cgImage: CGImage
|
||||||
|
let pixelWidth: Int
|
||||||
|
let pixelHeight: Int
|
||||||
|
let dpiX: Double
|
||||||
|
let dpiY: Double
|
||||||
|
let componentCount: Int
|
||||||
|
let bitsPerComponent: Int
|
||||||
|
/// Expected physical size from the printtarg manifest, when known.
|
||||||
|
let expectedWidthMm: Double?
|
||||||
|
let expectedHeightMm: Double?
|
||||||
|
|
||||||
|
/// `pixels / dpi * 72`. The ONLY size the canvas ever draws at.
|
||||||
|
/// 72 pt = 1 in. **Never** `backingScaleFactor` (docs/14 §6).
|
||||||
|
var pointSize: CGSize {
|
||||||
|
CGSize(width: Double(pixelWidth) / dpiX * 72,
|
||||||
|
height: Double(pixelHeight) / dpiY * 72)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pixels / dpi * 25.4` — the physical size the DPI implies.
|
||||||
|
var physicalMm: CGSize {
|
||||||
|
CGSize(width: Double(pixelWidth) / dpiX * 25.4,
|
||||||
|
height: Double(pixelHeight) / dpiY * 25.4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `nil` when within 0.5 mm of the manifest, else the drift
|
||||||
|
/// message (D9 — warn + spool, never rescale).
|
||||||
|
var manifestDrift: String? {
|
||||||
|
var parts: [String] = []
|
||||||
|
if let expectedWidthMm,
|
||||||
|
abs(physicalMm.width - expectedWidthMm) > 0.5 {
|
||||||
|
parts.append(String(
|
||||||
|
format: "width %.2f mm vs manifest %.2f mm",
|
||||||
|
physicalMm.width, expectedWidthMm))
|
||||||
|
}
|
||||||
|
if let expectedHeightMm,
|
||||||
|
abs(physicalMm.height - expectedHeightMm) > 0.5 {
|
||||||
|
parts.append(String(
|
||||||
|
format: "height %.2f mm vs manifest %.2f mm",
|
||||||
|
physicalMm.height, expectedHeightMm))
|
||||||
|
}
|
||||||
|
guard !parts.isEmpty else { return nil }
|
||||||
|
return "TIFF DPI disagrees with the printtarg manifest — "
|
||||||
|
+ parts.joined(separator: "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: TargetPageRaster, rhs: TargetPageRaster) -> Bool {
|
||||||
|
lhs.sourceURL == rhs.sourceURL
|
||||||
|
&& lhs.cgImage === rhs.cgImage
|
||||||
|
&& lhs.pixelWidth == rhs.pixelWidth
|
||||||
|
&& lhs.pixelHeight == rhs.pixelHeight
|
||||||
|
&& lhs.dpiX == rhs.dpiX
|
||||||
|
&& lhs.dpiY == rhs.dpiY
|
||||||
|
&& lhs.componentCount == rhs.componentCount
|
||||||
|
&& lhs.bitsPerComponent == rhs.bitsPerComponent
|
||||||
|
&& lhs.expectedWidthMm == rhs.expectedWidthMm
|
||||||
|
&& lhs.expectedHeightMm == rhs.expectedHeightMm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TargetRasterError: LocalizedError, Equatable {
|
||||||
|
case tiffMissing(String)
|
||||||
|
case undecodable(String)
|
||||||
|
case unsupportedLayout(path: String, components: Int,
|
||||||
|
bitsPerComponent: Int, hasAlpha: Bool)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .tiffMissing(let path):
|
||||||
|
return "Target TIFF is missing: \(path)"
|
||||||
|
case .undecodable(let path):
|
||||||
|
return "Target TIFF could not be decoded: \(path)"
|
||||||
|
case .unsupportedLayout(let path, let components,
|
||||||
|
let bitsPerComponent, let hasAlpha):
|
||||||
|
return "Target TIFF has an unsupported layout "
|
||||||
|
+ "(\(components) components, \(bitsPerComponent) bpc"
|
||||||
|
+ "\(hasAlpha ? ", alpha" : "")): \(path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TargetRasterLoader {
|
||||||
|
/// Absent DPI metadata defaults to 72 (docs/14 §6) — and logs `warn`.
|
||||||
|
static let fallbackDPI: Double = 72
|
||||||
|
|
||||||
|
static func load(
|
||||||
|
tiff url: URL,
|
||||||
|
expectedWidthMm: Double? = nil,
|
||||||
|
expectedHeightMm: Double? = nil
|
||||||
|
) throws -> TargetPageRaster {
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
throw TargetRasterError.tiffMissing(url.path)
|
||||||
|
}
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil)
|
||||||
|
else {
|
||||||
|
throw TargetRasterError.undecodable(url.path)
|
||||||
|
}
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageSourceShouldCache: true,
|
||||||
|
kCGImageSourceShouldAllowFloat: false,
|
||||||
|
]
|
||||||
|
guard let image = CGImageSourceCreateImageAtIndex(
|
||||||
|
source, 0, options as CFDictionary)
|
||||||
|
else {
|
||||||
|
throw TargetRasterError.undecodable(url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DPI lives in the container metadata, not the CGImage.
|
||||||
|
var dpiX = fallbackDPI
|
||||||
|
var dpiY = fallbackDPI
|
||||||
|
var foundX = false
|
||||||
|
var foundY = false
|
||||||
|
if let properties = CGImageSourceCopyPropertiesAtIndex(
|
||||||
|
source, 0, nil) as? [CFString: Any] {
|
||||||
|
if let x = (properties[kCGImagePropertyDPIWidth]
|
||||||
|
as? NSNumber)?.doubleValue, x > 0 {
|
||||||
|
dpiX = x
|
||||||
|
foundX = true
|
||||||
|
}
|
||||||
|
if let y = (properties[kCGImagePropertyDPIHeight]
|
||||||
|
as? NSNumber)?.doubleValue, y > 0 {
|
||||||
|
dpiY = y
|
||||||
|
foundY = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundX || !foundY {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"TargetRaster: '\(url.lastPathComponent)' carries no "
|
||||||
|
+ "usable DPI metadata — assuming \(fallbackDPI) dpi")
|
||||||
|
}
|
||||||
|
|
||||||
|
let components = image.colorSpace?.numberOfComponents ?? 0
|
||||||
|
guard !Self.hasRealAlpha(image), let tagged = deviceTagged(image)
|
||||||
|
else {
|
||||||
|
throw TargetRasterError.unsupportedLayout(
|
||||||
|
path: url.path,
|
||||||
|
components: components,
|
||||||
|
bitsPerComponent: image.bitsPerComponent,
|
||||||
|
hasAlpha: Self.hasRealAlpha(image))
|
||||||
|
}
|
||||||
|
|
||||||
|
return TargetPageRaster(
|
||||||
|
sourceURL: url,
|
||||||
|
cgImage: tagged,
|
||||||
|
pixelWidth: image.width,
|
||||||
|
pixelHeight: image.height,
|
||||||
|
dpiX: dpiX,
|
||||||
|
dpiY: dpiY,
|
||||||
|
componentCount: components,
|
||||||
|
bitsPerComponent: image.bitsPerComponent,
|
||||||
|
expectedWidthMm: expectedWidthMm,
|
||||||
|
expectedHeightMm: expectedHeightMm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-tag into `DeviceGray` (1 comp) / `DeviceRGB` (3) /
|
||||||
|
/// `DeviceCMYK` (4) by reusing the decoded image's own
|
||||||
|
/// `dataProvider`, `bitsPerComponent`, `bitsPerPixel`, `bytesPerRow`
|
||||||
|
/// and `bitmapInfo` — **no resample, no bit-depth change, no
|
||||||
|
/// `CGImageCreateCopyWithColorSpace` into a calibrated space**
|
||||||
|
/// (docs/14 §6.3). `shouldInterpolate: false`,
|
||||||
|
/// `intent: .defaultIntent`.
|
||||||
|
///
|
||||||
|
/// Returns `nil` (caller throws `.unsupportedLayout`) for alpha,
|
||||||
|
/// indexed, float, or >4-component images — a profiling target
|
||||||
|
/// never has those, and guessing would corrupt patches.
|
||||||
|
static func deviceTagged(_ image: CGImage) -> CGImage? {
|
||||||
|
guard !hasRealAlpha(image),
|
||||||
|
!image.bitmapInfo.contains(.floatComponents),
|
||||||
|
let colorSpace = image.colorSpace,
|
||||||
|
colorSpace.model != .indexed,
|
||||||
|
colorSpace.model != .pattern,
|
||||||
|
let space = deviceSpace(for: colorSpace.numberOfComponents),
|
||||||
|
let provider = image.dataProvider
|
||||||
|
else { return nil }
|
||||||
|
return CGImage(
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
bitsPerComponent: image.bitsPerComponent,
|
||||||
|
bitsPerPixel: image.bitsPerPixel,
|
||||||
|
bytesPerRow: image.bytesPerRow,
|
||||||
|
space: space,
|
||||||
|
bitmapInfo: image.bitmapInfo,
|
||||||
|
provider: provider,
|
||||||
|
decode: image.decode,
|
||||||
|
shouldInterpolate: false,
|
||||||
|
intent: .defaultIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip-only padding (`noneSkipLast`/`noneSkipFirst`) is **not**
|
||||||
|
/// alpha — ImageIO pads opaque RGB TIFFs to 32 bpp that way, so
|
||||||
|
/// accepting it is required for real printtarg files. True coverage
|
||||||
|
/// alpha (`last`/`first`/`premultiplied*`/`only`) is rejected.
|
||||||
|
static func hasRealAlpha(_ image: CGImage) -> Bool {
|
||||||
|
switch image.alphaInfo {
|
||||||
|
case .none, .noneSkipLast, .noneSkipFirst:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The device colour space for a component count — `nil` for
|
||||||
|
/// layouts a profiling target can never legally be (2 channels,
|
||||||
|
/// DeviceN, >4 components).
|
||||||
|
static func deviceSpace(for componentCount: Int) -> CGColorSpace? {
|
||||||
|
switch componentCount {
|
||||||
|
case 1: return CGColorSpaceCreateDeviceGray()
|
||||||
|
case 3: return CGColorSpaceCreateDeviceRGB()
|
||||||
|
case 4: return CGColorSpaceCreateDeviceCMYK()
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -232,6 +232,42 @@ final class CupsParserTests: XCTestCase {
|
|||||||
fromOptionsString: "PageSize=A4"))
|
fromOptionsString: "PageSize=A4"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - #186 capture-return
|
||||||
|
|
||||||
|
/// A captured `k=v` string maps to all four `PrintOptions`
|
||||||
|
/// fields — `PageSize`, the detected quality key,
|
||||||
|
/// `orientation-requested`, and a vendor media key (#186).
|
||||||
|
func testCapturedStringMapsAllFields() {
|
||||||
|
let captured =
|
||||||
|
"PageSize=A4 EPIJ_Qual=305 orientation-requested=4 CNIJMediaType=Photo"
|
||||||
|
XCTAssertEqual(CupsParsers.extractOption(
|
||||||
|
named: "PageSize", fromOptionsString: captured), "A4")
|
||||||
|
XCTAssertEqual(CupsParsers.extractQuality(
|
||||||
|
fromOptionsString: captured), "305")
|
||||||
|
XCTAssertEqual(CupsParsers.extractOrientation(
|
||||||
|
fromOptionsString: captured), "landscape")
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: captured), "Photo")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vendor media keys beyond `MediaType`/`EPIJ_Medi` extract via
|
||||||
|
/// the detection roster — `CNIJMediaType`/`StpMediaType` included
|
||||||
|
/// (#186). `MediaType` still wins when present alongside them.
|
||||||
|
func testExtractMediaTypeRosterFallback() {
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "CNIJMediaType=PhotoPlus"), "PhotoPlus")
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "StpMediaType=Glossy"), "Glossy")
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "EPIJ_Medi=Photo"), "Photo")
|
||||||
|
// `MediaType` keeps first precedence (docs/11 §tests).
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "CNIJMediaType=PhotoPlus MediaType=Plain"),
|
||||||
|
"Plain")
|
||||||
|
XCTAssertNil(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "PageSize=A4"))
|
||||||
|
}
|
||||||
|
|
||||||
func testDriverBypass() {
|
func testDriverBypass() {
|
||||||
func pair(_ keys: Set<String>) -> String? {
|
func pair(_ keys: Set<String>) -> String? {
|
||||||
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
||||||
@@ -246,4 +282,177 @@ final class CupsParserTests: XCTestCase {
|
|||||||
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
|
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
|
||||||
XCTAssertNil(pair(["PageSize"]))
|
XCTAssertNil(pair(["PageSize"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - #181 Canon media locale precedence + PPD encoding
|
||||||
|
|
||||||
|
/// The 18 Canon Pro9500 media types named in the issue — the ids
|
||||||
|
/// are the numeric codes the driver enumerates via `lpoptions -l`.
|
||||||
|
private let canonMedia: [(id: String, label: String)] = [
|
||||||
|
("0", "Plain Paper"),
|
||||||
|
("1", "Photo Paper Plus Glossy II"),
|
||||||
|
("2", "Photo Paper Pro Platinum N"),
|
||||||
|
("3", "Photo Paper Pro Platinum"),
|
||||||
|
("4", "Photo Paper Pro Luster"),
|
||||||
|
("5", "Photo Paper Plus Semi-gloss"),
|
||||||
|
("6", "Matte Photo Paper"),
|
||||||
|
("7", "Fine Art \"Photo Rag\""),
|
||||||
|
("8", "Fine Art \"Museum Etching\""),
|
||||||
|
("9", "Photo Paper Pro Premium Matte"),
|
||||||
|
("10", "Fine Art Premium Matte"),
|
||||||
|
("11", "Other Fine Art Paper"),
|
||||||
|
("12", "Canvas"),
|
||||||
|
("13", "Board Paper"),
|
||||||
|
("14", "Ink Jet Hagaki"),
|
||||||
|
("15", "Hagaki"),
|
||||||
|
("16", "Printable disc"),
|
||||||
|
("17", "Printable disc (bleed-proof)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Canon Pro9500-shaped fragment: the unqualified base block comes
|
||||||
|
/// early and the `th.` block trails at the end — the ordering that
|
||||||
|
/// let Thai overwrite English under last-write-wins (#181).
|
||||||
|
private var canonPPD: String {
|
||||||
|
var lines = [
|
||||||
|
"*OpenUI *CNIJMediaType/Media Type: PickOne",
|
||||||
|
"*DefaultCNIJMediaType: 0",
|
||||||
|
]
|
||||||
|
for media in canonMedia {
|
||||||
|
lines.append(
|
||||||
|
"*CNIJMediaType \(media.id)/\(media.label): \"\"")
|
||||||
|
}
|
||||||
|
lines.append("*CloseUI: *CNIJMediaType")
|
||||||
|
for media in canonMedia {
|
||||||
|
lines.append(
|
||||||
|
"*th.CNIJMediaType \(media.id)/กระดาษ\(media.id): \"\"")
|
||||||
|
}
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPpdLabelsUnqualifiedSurvivesTrailingThai() {
|
||||||
|
let labels = CupsParsers.ppdChoiceLabels(
|
||||||
|
canonPPD, key: "CNIJMediaType")
|
||||||
|
XCTAssertEqual(labels["0"], "Plain Paper")
|
||||||
|
XCTAssertEqual(labels["17"], "Printable disc (bleed-proof)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPpdLabelsUnqualifiedWinsRegardlessOfOrder() {
|
||||||
|
// `th.` block first — precedence is deterministic, not
|
||||||
|
// positional (#181, E3).
|
||||||
|
let ppd = """
|
||||||
|
*th.CNIJMediaType 0/กระดาษธรรมดา: ""
|
||||||
|
*CNIJMediaType 0/Plain Paper: ""
|
||||||
|
"""
|
||||||
|
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||||
|
XCTAssertEqual(labels["0"], "Plain Paper")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPpdLabelsQualifiedFallbackOrder() {
|
||||||
|
// en_US > en > first-qualified-seen (#181, E3).
|
||||||
|
let ppd = """
|
||||||
|
*en.CNIJMediaType 1/English Label: ""
|
||||||
|
*en_US.CNIJMediaType 1/US English Label: ""
|
||||||
|
*th.CNIJMediaType 1/กระดาษ: ""
|
||||||
|
*fr.CNIJMediaType 2/Français: ""
|
||||||
|
*de.CNIJMediaType 2/Deutsch: ""
|
||||||
|
"""
|
||||||
|
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||||
|
XCTAssertEqual(labels["1"], "US English Label")
|
||||||
|
// A qualified-only id still gets its first-seen qualified
|
||||||
|
// label — never left unlabeled (R9).
|
||||||
|
XCTAssertEqual(labels["2"], "Français")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPpdLabelsHexEscapeDecoding() {
|
||||||
|
let ppd = """
|
||||||
|
*CNIJMediaType 3/Photo Paper Plus Glossy<2F>Matte: ""
|
||||||
|
*CNIJMediaType 4/Plain<20>Paper: ""
|
||||||
|
*CNIJMediaType 5/Bad<ZZ>Escape: ""
|
||||||
|
"""
|
||||||
|
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||||
|
XCTAssertEqual(labels["3"], "Photo Paper Plus Glossy/Matte")
|
||||||
|
XCTAssertEqual(labels["4"], "Plain Paper")
|
||||||
|
XCTAssertEqual(labels["5"], "Bad<ZZ>Escape")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `CNIJMediaType` choice enumerated by `lpoptions -l` gets a
|
||||||
|
/// non-Thai label (E1 — the true count is the hardware gate's, so
|
||||||
|
/// no count is hardcoded here); the 18 named AC labels are
|
||||||
|
/// spot-checked.
|
||||||
|
func testCapabilitiesCanonMediaAllNonThai() {
|
||||||
|
var choices = canonMedia.map(\.id)
|
||||||
|
choices[0] = "*\(choices[0])"
|
||||||
|
let listings = CupsParsers.lpoptionsList(
|
||||||
|
"CNIJMediaType/Media Type: \(choices.joined(separator: " "))\n")
|
||||||
|
let caps = CupsService().capabilities(from: listings, ppd: canonPPD)
|
||||||
|
|
||||||
|
XCTAssertEqual(caps.mediaTypes.count, canonMedia.count)
|
||||||
|
for type in caps.mediaTypes {
|
||||||
|
XCTAssertFalse(type.name.unicodeScalars.contains {
|
||||||
|
(0x0E00...0x0E7F).contains($0.value)
|
||||||
|
}, "Thai label leaked into \(type.id): \(type.name)")
|
||||||
|
}
|
||||||
|
for media in canonMedia {
|
||||||
|
XCTAssertEqual(
|
||||||
|
caps.mediaTypes.first { $0.id == media.id }?.name,
|
||||||
|
media.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTF-8 PPD carrying Thai labels decodes intact — the English
|
||||||
|
/// base block wins precedence and no mojibake leaks through (#181,
|
||||||
|
/// R10). Exercises `loadPPD` through `capabilities(for:)`.
|
||||||
|
func testLoadPPDUtf8ThaiSurvivesDecode() async throws {
|
||||||
|
let (service, root) = try makeCupsService(
|
||||||
|
ppdData: Data(canonPPD.utf8),
|
||||||
|
listing: "CNIJMediaType/Media Type: *0 1")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
|
||||||
|
let caps = try await service.capabilities(for: "Canon_Test")
|
||||||
|
XCTAssertEqual(caps.mediaTypes.map(\.name),
|
||||||
|
["Plain Paper", "Photo Paper Plus Glossy II"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A PPD that is not valid UTF-8 (lone `0xE9` for `é`) falls back
|
||||||
|
/// to ISO-Latin-1 instead of yielding nil → raw ids (#181, R10).
|
||||||
|
func testLoadPPDLatin1Fallback() async throws {
|
||||||
|
let ppd = "*CNIJMediaType 0/Papier Couché: \"\"\n"
|
||||||
|
let (service, root) = try makeCupsService(
|
||||||
|
ppdData: ppd.data(using: .isoLatin1)!,
|
||||||
|
listing: "CNIJMediaType/Media Type: *0")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
|
||||||
|
let caps = try await service.capabilities(for: "Canon_Test")
|
||||||
|
XCTAssertEqual(caps.mediaTypes,
|
||||||
|
[PrinterMediaType(id: "0", name: "Papier Couché")])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fixture `lpoptions` + `ppdDir` so `capabilities(for:)` reaches
|
||||||
|
/// the private `loadPPD` — same mock style as
|
||||||
|
/// `MediaLibraryViewModelTests.installMockCups`.
|
||||||
|
private func makeCupsService(
|
||||||
|
ppdData: Data,
|
||||||
|
listing: String,
|
||||||
|
queue: String = "Canon_Test"
|
||||||
|
) throws -> (CupsService, URL) {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ppd-\(UUID().uuidString)")
|
||||||
|
let bin = root.appendingPathComponent("bin")
|
||||||
|
let ppdDir = root.appendingPathComponent("ppd")
|
||||||
|
for dir in [bin, ppdDir] {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: dir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
let lpoptions = """
|
||||||
|
#!/bin/sh
|
||||||
|
printf '%s\\n' '\(listing)'
|
||||||
|
"""
|
||||||
|
let scriptURL = bin.appendingPathComponent("lpoptions")
|
||||||
|
try lpoptions.write(to: scriptURL, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: scriptURL.path)
|
||||||
|
try ppdData.write(to: ppdDir.appendingPathComponent("\(queue).ppd"))
|
||||||
|
return (CupsService(
|
||||||
|
processManager: ProcessManager(),
|
||||||
|
binaryDir: bin, ppdDir: ppdDir), root)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import AppKit
|
||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #201 Phase 3 — real `NSPrintOperation` with
|
||||||
|
/// `jobDisposition = .save`: proves the canvas paginates at paper size,
|
||||||
|
/// draws the raster 1:1 at the top-left, and survives end-to-end
|
||||||
|
/// without a printer. Skippable via `ICCERY_SKIP_PRINT_PDF=1` for
|
||||||
|
/// hosts where the print system cannot run (R8).
|
||||||
|
@MainActor
|
||||||
|
final class NativeSpoolPDFTests: XCTestCase {
|
||||||
|
|
||||||
|
private var tempRoot: URL!
|
||||||
|
|
||||||
|
/// A4 in points.
|
||||||
|
private let paperSize = NSSize(width: 595.28, height: 841.89)
|
||||||
|
/// Known top-left patch colour — solid fill across the fixture.
|
||||||
|
private let patchBytes: [UInt8] = [230, 40, 50]
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
tempRoot = try TargetTestFixtures.makeTempDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
try? FileManager.default.removeItem(at: tempRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSavePDFDrawsPagesOneToOne() throws {
|
||||||
|
try XCTSkipIf(
|
||||||
|
ProcessInfo.processInfo.environment["ICCERY_SKIP_PRINT_PDF"]
|
||||||
|
== "1",
|
||||||
|
"ICCERY_SKIP_PRINT_PDF=1 — print system unavailable")
|
||||||
|
|
||||||
|
// 72×72 px @ 72 dpi → a 72×72 pt solid block at the top-left.
|
||||||
|
let tiff = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 72, height: 72), dpi: 72,
|
||||||
|
components: 3, bitsPerComponent: 8,
|
||||||
|
pixelBytes: patchBytes, in: tempRoot)
|
||||||
|
let raster = try TargetRasterLoader.load(tiff: tiff)
|
||||||
|
|
||||||
|
let info = NSPrintInfo()
|
||||||
|
info.paperSize = paperSize
|
||||||
|
info.orientation = .portrait
|
||||||
|
info.topMargin = 0
|
||||||
|
info.bottomMargin = 0
|
||||||
|
info.leftMargin = 0
|
||||||
|
info.rightMargin = 0
|
||||||
|
info.horizontalPagination = .clip
|
||||||
|
info.verticalPagination = .clip
|
||||||
|
info.scalingFactor = 1.0
|
||||||
|
info.isHorizontallyCentered = false
|
||||||
|
info.isVerticallyCentered = false
|
||||||
|
info.jobDisposition = .save
|
||||||
|
let pdfURL = tempRoot.appendingPathComponent("spool.pdf")
|
||||||
|
info.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = pdfURL
|
||||||
|
|
||||||
|
let canvas = TargetPageCanvasView(
|
||||||
|
pages: [raster, 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")
|
||||||
|
XCTAssertTrue(
|
||||||
|
FileManager.default.fileExists(atPath: pdfURL.path))
|
||||||
|
|
||||||
|
let document = try XCTUnwrap(
|
||||||
|
CGPDFDocument(pdfURL as CFURL))
|
||||||
|
XCTAssertEqual(document.numberOfPages, 2)
|
||||||
|
|
||||||
|
let page = try XCTUnwrap(document.page(at: 1))
|
||||||
|
let mediaBox = page.getBoxRect(.mediaBox)
|
||||||
|
XCTAssertEqual(mediaBox.width, paperSize.width, accuracy: 0.5)
|
||||||
|
XCTAssertEqual(mediaBox.height, paperSize.height, accuracy: 0.5)
|
||||||
|
|
||||||
|
try assertTopLeftPatch(on: page, mediaBox: mediaBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the page at 1 px/pt and check the fixture's solid block
|
||||||
|
/// landed at the top-left at its point size — the coarse 1:1 +
|
||||||
|
/// no-scaling check.
|
||||||
|
private func assertTopLeftPatch(
|
||||||
|
on page: CGPDFPage, mediaBox: CGRect
|
||||||
|
) throws {
|
||||||
|
let width = Int(ceil(mediaBox.width))
|
||||||
|
let height = Int(ceil(mediaBox.height))
|
||||||
|
let rep = try XCTUnwrap(NSBitmapImageRep(
|
||||||
|
bitmapDataPlanes: nil,
|
||||||
|
pixelsWide: width,
|
||||||
|
pixelsHigh: height,
|
||||||
|
bitsPerSample: 8,
|
||||||
|
samplesPerPixel: 4,
|
||||||
|
hasAlpha: true,
|
||||||
|
isPlanar: false,
|
||||||
|
colorSpaceName: .deviceRGB,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
bitsPerPixel: 0))
|
||||||
|
let context = try XCTUnwrap(
|
||||||
|
NSGraphicsContext(bitmapImageRep: rep))
|
||||||
|
let cg = context.cgContext
|
||||||
|
let transform = page.getDrawingTransform(
|
||||||
|
.mediaBox,
|
||||||
|
rect: CGRect(x: 0, y: 0, width: width, height: height),
|
||||||
|
rotate: 0, preserveAspectRatio: true)
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
NSGraphicsContext.current = context
|
||||||
|
cg.concatenate(transform)
|
||||||
|
cg.drawPDFPage(page)
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
|
||||||
|
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 isPatch(_ p: (Double, Double, Double)) -> Bool {
|
||||||
|
abs(p.0 - Double(patchBytes[0]) / 255) < 0.04
|
||||||
|
&& abs(p.1 - Double(patchBytes[1]) / 255) < 0.04
|
||||||
|
&& abs(p.2 - Double(patchBytes[2]) / 255) < 0.04
|
||||||
|
}
|
||||||
|
|
||||||
|
// The print system insets the view by the queue's unprintable
|
||||||
|
// margin (R10) — host-dependent, ~18 pt on this machine — so
|
||||||
|
// the block's anchor is the painted region's top-left, not
|
||||||
|
// (0,0). Find the painted origin by the first opaque pixel.
|
||||||
|
var originX = width, originY = height
|
||||||
|
for y in 0..<height {
|
||||||
|
for x in 0..<width
|
||||||
|
where (rep.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.5 {
|
||||||
|
originX = min(originX, x)
|
||||||
|
originY = min(originY, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertLessThan(originX, width, "PDF page rendered empty")
|
||||||
|
XCTAssertLessThan(originY, height, "PDF page rendered empty")
|
||||||
|
|
||||||
|
// 10 px inside the 72 pt block on each axis → patch colour.
|
||||||
|
XCTAssertTrue(try isPatch(pixel(originX + 10, originY + 10)),
|
||||||
|
"top-left block is not the fixture colour")
|
||||||
|
|
||||||
|
// Measure the block's painted width on a row through it:
|
||||||
|
// 72 pt at 1 px/pt — the 1:1 / no-scaling assertion.
|
||||||
|
var blockWidth = 0
|
||||||
|
var x = originX
|
||||||
|
while x < width,
|
||||||
|
isPatch(try pixel(x, originY + 10)) {
|
||||||
|
blockWidth += 1
|
||||||
|
x += 1
|
||||||
|
}
|
||||||
|
XCTAssertEqual(blockWidth, 72, accuracy: 2)
|
||||||
|
|
||||||
|
// Past the block edge the canvas's white page fill shows.
|
||||||
|
let outside = try pixel(originX + 80, originY + 80)
|
||||||
|
XCTAssertEqual(outside.0, 1, accuracy: 0.04)
|
||||||
|
XCTAssertEqual(outside.1, 1, accuracy: 0.04)
|
||||||
|
XCTAssertEqual(outside.2, 1, accuracy: 0.04)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,24 @@ final class PrintPanelStubTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #186 — a vendor media key in the captured string reaches
|
||||||
|
/// `options.mediaType` through the detection roster
|
||||||
|
/// (`CNIJMediaType`/`StpMediaType`, not only `MediaType`).
|
||||||
|
func testOkResultExtractsVendorMediaKey() throws {
|
||||||
|
try withEnv([
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||||
|
"ICCERY_TEST_PANEL_OPTIONS":
|
||||||
|
"PageSize=A4 orientation-requested=4 CNIJMediaType=Photo",
|
||||||
|
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||||
|
]) {
|
||||||
|
let result = UITestHooks.printPanelResult(forQueue: "q")
|
||||||
|
XCTAssertEqual(result?.options.paperSize, "A4")
|
||||||
|
XCTAssertEqual(result?.options.orientation, "landscape")
|
||||||
|
XCTAssertEqual(result?.options.mediaType, "Photo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testOkDefaultsPrinter() throws {
|
func testOkDefaultsPrinter() throws {
|
||||||
try withEnv([
|
try withEnv([
|
||||||
"ICCERY_UI_TESTING": "1",
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
|||||||
@@ -244,4 +244,149 @@ final class PrintSessionViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
|
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
|
||||||
XCTAssertEqual(workflow.print.selectedQuality, "305")
|
XCTAssertEqual(workflow.print.selectedQuality, "305")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #186 — the captured `orientation-requested`/media token apply
|
||||||
|
/// back to `printOrientation`/`selectedMediaType`, and a dialog
|
||||||
|
/// result never mutates `workflow.pageSize` (printtarg layout).
|
||||||
|
func testPanelResultAppliesBackOrientationAndMedia() async throws {
|
||||||
|
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||||
|
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||||
|
setenv("ICCERY_TEST_PANEL_OPTIONS",
|
||||||
|
"orientation-requested=4 MediaType=Glossy", 1)
|
||||||
|
defer {
|
||||||
|
unsetenv("ICCERY_UI_TESTING")
|
||||||
|
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||||
|
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
workflow.pageSize = .a4
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
XCTAssertEqual(workflow.print.printOrientation, "portrait")
|
||||||
|
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
|
||||||
|
|
||||||
|
workflow.print.openPrinterPreferences()
|
||||||
|
await waitForNotice(workflow.print, containing: "Settings captured")
|
||||||
|
XCTAssertEqual(workflow.print.printOrientation, "landscape")
|
||||||
|
XCTAssertEqual(workflow.print.selectedMediaType, "Glossy")
|
||||||
|
XCTAssertEqual(workflow.pageSize, .a4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #186 — a captured `PageSize` token with no capability match
|
||||||
|
/// leaves `selectedPaperSize` unchanged (never a guessed id).
|
||||||
|
func testPanelResultUnknownPaperLeavesSelection() async throws {
|
||||||
|
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||||
|
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||||
|
setenv("ICCERY_TEST_PANEL_OPTIONS", "PageSize=Bogus", 1)
|
||||||
|
defer {
|
||||||
|
unsetenv("ICCERY_UI_TESTING")
|
||||||
|
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||||
|
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
workflow.pageSize = .a4
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||||
|
|
||||||
|
workflow.print.openPrinterPreferences()
|
||||||
|
await waitForNotice(workflow.print, containing: "Settings captured")
|
||||||
|
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #186 — a stub result pointing at another queue in `printers`
|
||||||
|
/// switches `selectedPrinter` and reloads its capabilities.
|
||||||
|
func testPanelResultSwitchesToKnownQueue() async throws {
|
||||||
|
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||||
|
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||||
|
setenv("ICCERY_TEST_PANEL_OPTIONS", "PageSize=Letter", 1)
|
||||||
|
setenv("ICCERY_TEST_PANEL_PRINTER", "Other_Q", 1)
|
||||||
|
defer {
|
||||||
|
unsetenv("ICCERY_UI_TESTING")
|
||||||
|
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||||
|
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||||
|
unsetenv("ICCERY_TEST_PANEL_PRINTER")
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
workflow.pageSize = .a4
|
||||||
|
workflow.print.printers = [
|
||||||
|
Printer(name: "Mock_Q", isDefault: true),
|
||||||
|
Printer(name: "Other_Q"),
|
||||||
|
]
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
|
||||||
|
workflow.print.openPrinterPreferences()
|
||||||
|
await waitForNotice(
|
||||||
|
workflow.print, containing: "Settings captured for Other_Q")
|
||||||
|
XCTAssertEqual(workflow.print.selectedPrinter, "Other_Q")
|
||||||
|
// Caps reloaded for the new queue: paper re-seeded, then the
|
||||||
|
// captured PageSize applied back onto the new caps.
|
||||||
|
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
|
||||||
|
XCTAssertEqual(workflow.print.capturedCupsOptions["Other_Q"],
|
||||||
|
"PageSize=Letter")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #186 — a stub result naming a queue absent from `printers`
|
||||||
|
/// leaves the selection on the opened queue.
|
||||||
|
func testPanelResultGhostQueueIgnored() async throws {
|
||||||
|
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||||
|
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||||
|
setenv("ICCERY_TEST_PANEL_PRINTER", "Ghost_Q", 1)
|
||||||
|
defer {
|
||||||
|
unsetenv("ICCERY_UI_TESTING")
|
||||||
|
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||||
|
unsetenv("ICCERY_TEST_PANEL_PRINTER")
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
workflow.pageSize = .a4
|
||||||
|
workflow.print.printers = [Printer(name: "Mock_Q", isDefault: true)]
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
|
||||||
|
workflow.print.openPrinterPreferences()
|
||||||
|
await waitForNotice(
|
||||||
|
workflow.print, containing: "Settings captured for Mock_Q")
|
||||||
|
XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Q")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #186 — cancel returns `nil`: info notice, no field changes.
|
||||||
|
func testPanelCancelLeavesSelections() async throws {
|
||||||
|
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||||
|
setenv("ICCERY_TEST_PRINT_PANEL", "cancel", 1)
|
||||||
|
defer {
|
||||||
|
unsetenv("ICCERY_UI_TESTING")
|
||||||
|
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
workflow.pageSize = .a4
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
workflow.print.printOrientation = "landscape"
|
||||||
|
|
||||||
|
workflow.print.openPrinterPreferences()
|
||||||
|
await waitForNotice(workflow.print, containing: "cancelled")
|
||||||
|
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||||
|
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||||
|
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
|
||||||
|
XCTAssertEqual(workflow.print.printOrientation, "landscape")
|
||||||
|
XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll until the panel task posts a notice whose text contains
|
||||||
|
/// `fragment` (the Task-completion signal for `nil` results too).
|
||||||
|
private func waitForNotice(
|
||||||
|
_ vm: PrintSessionViewModel,
|
||||||
|
containing fragment: String,
|
||||||
|
timeout: TimeInterval = 10
|
||||||
|
) async {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if let text = vm.printNotice?.text, text.contains(fragment) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
}
|
||||||
|
XCTFail("Timed out waiting for notice containing '\(fragment)'")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #201 Phase 2 — `PMTicketBridge.serialise`/`restore` byte
|
||||||
|
/// round-trip, cross-queue refusal (R3), plist fallback, and the
|
||||||
|
/// `takeRetainedValue` ownership canary.
|
||||||
|
@MainActor
|
||||||
|
final class PrintTicketTests: XCTestCase {
|
||||||
|
|
||||||
|
/// The queue a destination `NSPrintInfo` will report as bound —
|
||||||
|
/// the session's current printer, else the Cocoa printer name,
|
||||||
|
/// else a synthetic token for a queue-less environment.
|
||||||
|
private func boundQueue(of printInfo: NSPrintInfo) -> String {
|
||||||
|
PMTicketBridge.currentPrinterID(
|
||||||
|
session: PMTicketBridge.session(printInfo),
|
||||||
|
fallback: "UnboundQueue")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoundTripRetainsVendorValue() throws {
|
||||||
|
let source = NSPrintInfo()
|
||||||
|
XCTAssertTrue(
|
||||||
|
PMTicketBridge.setValue(
|
||||||
|
"305", forKey: "EPIJ_Qual", locked: false,
|
||||||
|
in: PMTicketBridge.settings(source),
|
||||||
|
context: "PrintTicketTests"))
|
||||||
|
|
||||||
|
let target = NSPrintInfo()
|
||||||
|
let ticket = try PMTicketBridge.serialise(
|
||||||
|
source, queue: boundQueue(of: target))
|
||||||
|
try PMTicketBridge.restore(ticket, into: target)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(
|
||||||
|
forKey: "EPIJ_Qual",
|
||||||
|
in: PMTicketBridge.settings(target)),
|
||||||
|
"305")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrintSettingsDataIsXmlPlist() throws {
|
||||||
|
let ticket = try PMTicketBridge.serialise(
|
||||||
|
NSPrintInfo(), queue: "AnyQueue")
|
||||||
|
XCTAssertFalse(ticket.printSettings.isEmpty)
|
||||||
|
XCTAssertFalse(ticket.pageFormat.isEmpty)
|
||||||
|
XCTAssertEqual(
|
||||||
|
String(decoding: ticket.printSettings.prefix(5),
|
||||||
|
as: UTF8.self),
|
||||||
|
"<?xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCrossQueueReplayIsRefused() throws {
|
||||||
|
let target = NSPrintInfo()
|
||||||
|
let bound = boundQueue(of: target)
|
||||||
|
let foreign = "DefinitelyNot_\(bound)"
|
||||||
|
try XCTSkipIf(
|
||||||
|
bound == foreign,
|
||||||
|
"destination resolved to the foreign queue")
|
||||||
|
|
||||||
|
// Sentinel on the destination — a refused restore must leave
|
||||||
|
// the target's settings untouched.
|
||||||
|
XCTAssertTrue(
|
||||||
|
PMTicketBridge.setValue(
|
||||||
|
"999", forKey: "EPIJ_Qual", locked: false,
|
||||||
|
in: PMTicketBridge.settings(target),
|
||||||
|
context: "PrintTicketTests"))
|
||||||
|
|
||||||
|
let source = NSPrintInfo()
|
||||||
|
XCTAssertTrue(
|
||||||
|
PMTicketBridge.setValue(
|
||||||
|
"305", forKey: "EPIJ_Qual", locked: false,
|
||||||
|
in: PMTicketBridge.settings(source),
|
||||||
|
context: "PrintTicketTests"))
|
||||||
|
let ticket = try PMTicketBridge.serialise(
|
||||||
|
source, queue: foreign)
|
||||||
|
|
||||||
|
try PMTicketBridge.restore(ticket, into: target)
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(
|
||||||
|
forKey: "EPIJ_Qual",
|
||||||
|
in: PMTicketBridge.settings(target)),
|
||||||
|
"999")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrintInfoPlistFallbackRoundTrips() throws {
|
||||||
|
let source = NSPrintInfo()
|
||||||
|
// The `com.apple.print.PrintSettings` entry only exists once
|
||||||
|
// something writes into the attribute dictionary — the
|
||||||
|
// ColorSync mirror does this on the real panel path.
|
||||||
|
source.dictionary()[
|
||||||
|
NSPrintInfo.AttributeKey("com.apple.print.PrintSettings")] =
|
||||||
|
["PMColorMatchingMode": "APCustomColorMatching"]
|
||||||
|
as NSDictionary
|
||||||
|
let ticket = try PMTicketBridge.serialise(
|
||||||
|
source, queue: "AnyQueue")
|
||||||
|
let data = try XCTUnwrap(ticket.printInfoPlist)
|
||||||
|
let object = try PropertyListSerialization.propertyList(
|
||||||
|
from: data, options: [], format: nil)
|
||||||
|
let dictionary = try XCTUnwrap(object as? [String: Any])
|
||||||
|
XCTAssertNotNil(
|
||||||
|
dictionary["com.apple.print.PrintSettings"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 200× serialise loop — a `takeRetainedValue` over/under-release
|
||||||
|
/// canary. Byte counts must stay stable; ASan + Malloc Scribble
|
||||||
|
/// catches an actual imbalance.
|
||||||
|
func testSerialiseIsStableAcrossIterations() throws {
|
||||||
|
let source = NSPrintInfo()
|
||||||
|
var firstCount: Int?
|
||||||
|
for _ in 0..<200 {
|
||||||
|
let ticket = try PMTicketBridge.serialise(
|
||||||
|
source, queue: "AnyQueue")
|
||||||
|
if let first = firstCount {
|
||||||
|
XCTAssertEqual(ticket.printSettings.count, first)
|
||||||
|
} else {
|
||||||
|
firstCount = ticket.printSettings.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertNotNil(firstCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import AppKit
|
||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #201 Phase 3 — `TargetPageCanvasView` top-down page stacking,
|
||||||
|
/// snapped top-left anchoring, and the draw-time interpolation /
|
||||||
|
/// antialias contract asserted via `drawProbe`.
|
||||||
|
@MainActor
|
||||||
|
final class TargetCanvasGeometryTests: XCTestCase {
|
||||||
|
|
||||||
|
/// A4-ish paper, points.
|
||||||
|
private let paperSize = NSSize(width: 595.28, height: 841.89)
|
||||||
|
|
||||||
|
private func raster(
|
||||||
|
pixelWidth: Int = 2480,
|
||||||
|
pixelHeight: Int = 3508,
|
||||||
|
dpi: Double = 300
|
||||||
|
) -> TargetPageRaster {
|
||||||
|
let image = TargetTestFixtures.makeImage(
|
||||||
|
px: CGSize(width: 8, height: 8),
|
||||||
|
components: 3, bitsPerComponent: 8)!
|
||||||
|
return TargetPageRaster(
|
||||||
|
sourceURL: URL(fileURLWithPath: "/fixture.tiff"),
|
||||||
|
cgImage: image,
|
||||||
|
pixelWidth: pixelWidth,
|
||||||
|
pixelHeight: pixelHeight,
|
||||||
|
dpiX: dpi, dpiY: dpi,
|
||||||
|
componentCount: 3,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
expectedWidthMm: nil,
|
||||||
|
expectedHeightMm: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page stacking
|
||||||
|
|
||||||
|
func testRectForPageStacksTopDown() {
|
||||||
|
let view = TargetPageCanvasView(
|
||||||
|
pages: [raster(), raster(), raster()],
|
||||||
|
paperSize: paperSize)
|
||||||
|
XCTAssertTrue(view.isFlipped)
|
||||||
|
|
||||||
|
let page1 = view.rectForPage(1)
|
||||||
|
let page3 = view.rectForPage(3)
|
||||||
|
XCTAssertEqual(page1.minY, 0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(page3.minY, 2 * paperSize.height, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(page1.size, paperSize)
|
||||||
|
XCTAssertEqual(page3.size, paperSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKnowsPageRangeAndFrame() {
|
||||||
|
let view = TargetPageCanvasView(
|
||||||
|
pages: [raster(), raster()],
|
||||||
|
paperSize: paperSize)
|
||||||
|
var range = NSRange()
|
||||||
|
XCTAssertTrue(view.knowsPageRange(&range))
|
||||||
|
XCTAssertEqual(range, NSRange(location: 1, length: 2))
|
||||||
|
XCTAssertEqual(
|
||||||
|
view.frame.height, 2 * paperSize.height, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(view.frame.width, paperSize.width, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Destination rect
|
||||||
|
|
||||||
|
func testDestinationRectAnchorsTopLeftAndSnaps() {
|
||||||
|
let view = TargetPageCanvasView(
|
||||||
|
pages: [raster(), raster()],
|
||||||
|
paperSize: paperSize)
|
||||||
|
|
||||||
|
let first = view.destinationRect(forPage: 1)
|
||||||
|
XCTAssertEqual(first.origin.x, 0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(first.origin.y, 0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(first.width, 595.2, accuracy: 0.01)
|
||||||
|
XCTAssertEqual(first.height, 841.92, accuracy: 0.01)
|
||||||
|
|
||||||
|
let second = view.destinationRect(forPage: 2)
|
||||||
|
XCTAssertEqual(
|
||||||
|
second.origin.y, paperSize.height, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(second.size, first.size)
|
||||||
|
|
||||||
|
for rect in [first, second] {
|
||||||
|
XCTAssertEqual(
|
||||||
|
rect.origin.x * 1000,
|
||||||
|
(rect.origin.x * 1000).rounded(),
|
||||||
|
accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(
|
||||||
|
rect.origin.y * 1000,
|
||||||
|
(rect.origin.y * 1000).rounded(),
|
||||||
|
accuracy: 1e-9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Draw flags
|
||||||
|
|
||||||
|
/// Render the canvas into an offscreen bitmap context and assert —
|
||||||
|
/// from inside `drawProbe` — that interpolation and antialiasing
|
||||||
|
/// are off and the drawn rect is `destinationRect(forPage:)`.
|
||||||
|
func testDrawDisablesInterpolationAndAntialias() throws {
|
||||||
|
let pages = [raster(), raster()]
|
||||||
|
let view = TargetPageCanvasView(
|
||||||
|
pages: pages, paperSize: paperSize)
|
||||||
|
|
||||||
|
let rep = try XCTUnwrap(NSBitmapImageRep(
|
||||||
|
bitmapDataPlanes: nil,
|
||||||
|
pixelsWide: Int(ceil(view.frame.width)),
|
||||||
|
pixelsHigh: Int(ceil(view.frame.height)),
|
||||||
|
bitsPerSample: 8,
|
||||||
|
samplesPerPixel: 4,
|
||||||
|
hasAlpha: true,
|
||||||
|
isPlanar: false,
|
||||||
|
colorSpaceName: .deviceRGB,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
bitsPerPixel: 0))
|
||||||
|
let context = try XCTUnwrap(
|
||||||
|
NSGraphicsContext(bitmapImageRep: rep))
|
||||||
|
|
||||||
|
var probed: [(page: Int, rect: NSRect)] = []
|
||||||
|
view.drawProbe = { ctx, page, rect in
|
||||||
|
XCTAssertEqual(ctx.imageInterpolation, .none,
|
||||||
|
"page \(page): interpolation not .none")
|
||||||
|
XCTAssertFalse(ctx.shouldAntialias,
|
||||||
|
"page \(page): antialiasing still on")
|
||||||
|
probed.append((page, rect))
|
||||||
|
}
|
||||||
|
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
NSGraphicsContext.current = context
|
||||||
|
view.draw(view.bounds)
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
|
||||||
|
XCTAssertEqual(probed.count, pages.count)
|
||||||
|
for (page, rect) in probed {
|
||||||
|
XCTAssertEqual(
|
||||||
|
rect, view.destinationRect(forPage: page),
|
||||||
|
"page \(page): probe rect mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dirty rect covering only page 2's band must draw only page 2.
|
||||||
|
func testDrawSkipsPagesOutsideDirtyRect() throws {
|
||||||
|
let view = TargetPageCanvasView(
|
||||||
|
pages: [raster(), raster(), raster()],
|
||||||
|
paperSize: paperSize)
|
||||||
|
|
||||||
|
let rep = try XCTUnwrap(NSBitmapImageRep(
|
||||||
|
bitmapDataPlanes: nil,
|
||||||
|
pixelsWide: Int(ceil(view.frame.width)),
|
||||||
|
pixelsHigh: Int(ceil(view.frame.height)),
|
||||||
|
bitsPerSample: 8,
|
||||||
|
samplesPerPixel: 4,
|
||||||
|
hasAlpha: true,
|
||||||
|
isPlanar: false,
|
||||||
|
colorSpaceName: .deviceRGB,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
bitsPerPixel: 0))
|
||||||
|
let context = try XCTUnwrap(
|
||||||
|
NSGraphicsContext(bitmapImageRep: rep))
|
||||||
|
|
||||||
|
var probedPages: [Int] = []
|
||||||
|
view.drawProbe = { _, page, _ in probedPages.append(page) }
|
||||||
|
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
NSGraphicsContext.current = context
|
||||||
|
view.draw(view.rectForPage(2))
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
|
||||||
|
XCTAssertEqual(probedPages, [2])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// Shared TIFF fixture builder for the #201 raster / canvas / PDF
|
||||||
|
/// harness tests. Writes real TIFFs via `CGImageDestination` so the
|
||||||
|
/// loader exercises the same decode path as production.
|
||||||
|
enum TargetTestFixtures {
|
||||||
|
|
||||||
|
enum FixtureError: Error {
|
||||||
|
case imageNotCreated
|
||||||
|
case destinationNotCreated
|
||||||
|
case finalizeFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fresh per-test directory under the temp root.
|
||||||
|
static func makeTempDirectory() throws -> URL {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-raster-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url, withIntermediateDirectories: true)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A solid-fill (or repeating-pattern) `CGImage` with a device
|
||||||
|
/// colour space for 1/3/4 components at 8 or 16 bpc. `alpha: true`
|
||||||
|
/// appends one alpha sample per pixel (`last`). `pixelBytes` is the
|
||||||
|
/// repeating per-pixel pattern (big-endian for 16 bpc).
|
||||||
|
static func makeImage(
|
||||||
|
px: CGSize,
|
||||||
|
components: Int,
|
||||||
|
bitsPerComponent: Int,
|
||||||
|
alpha: Bool = false,
|
||||||
|
pixelBytes: [UInt8]? = nil
|
||||||
|
) -> CGImage? {
|
||||||
|
let width = Int(px.width)
|
||||||
|
let height = Int(px.height)
|
||||||
|
let bytesPerComponent = bitsPerComponent / 8
|
||||||
|
let samples = components + (alpha ? 1 : 0)
|
||||||
|
let bytesPerPixel = samples * bytesPerComponent
|
||||||
|
let bytesPerRow = width * bytesPerPixel
|
||||||
|
let pattern = pixelBytes
|
||||||
|
?? Array(0..<bytesPerPixel).map { UInt8(($0 * 37 + 11) & 0xff) }
|
||||||
|
var row = [UInt8](repeating: 0, count: bytesPerRow)
|
||||||
|
for offset in stride(from: 0, to: bytesPerRow, by: pattern.count) {
|
||||||
|
for (index, byte) in pattern.enumerated()
|
||||||
|
where offset + index < bytesPerRow {
|
||||||
|
row[offset + index] = byte
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var bytes = [UInt8]()
|
||||||
|
bytes.reserveCapacity(bytesPerRow * height)
|
||||||
|
for _ in 0..<height {
|
||||||
|
bytes.append(contentsOf: row)
|
||||||
|
}
|
||||||
|
let data = Data(bytes)
|
||||||
|
|
||||||
|
let space: CGColorSpace
|
||||||
|
switch components {
|
||||||
|
case 1: space = CGColorSpaceCreateDeviceGray()
|
||||||
|
case 3: space = CGColorSpaceCreateDeviceRGB()
|
||||||
|
case 4: space = CGColorSpaceCreateDeviceCMYK()
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
var info = CGBitmapInfo()
|
||||||
|
if bitsPerComponent == 16 {
|
||||||
|
info.insert(.byteOrder16Big)
|
||||||
|
}
|
||||||
|
if alpha {
|
||||||
|
info = CGBitmapInfo(
|
||||||
|
rawValue: info.rawValue | CGImageAlphaInfo.last.rawValue)
|
||||||
|
}
|
||||||
|
guard let provider = CGDataProvider(data: data as CFData)
|
||||||
|
else { return nil }
|
||||||
|
return CGImage(
|
||||||
|
width: width, height: height,
|
||||||
|
bitsPerComponent: bitsPerComponent,
|
||||||
|
bitsPerPixel: bitsPerComponent * samples,
|
||||||
|
bytesPerRow: bytesPerRow,
|
||||||
|
space: space,
|
||||||
|
bitmapInfo: info,
|
||||||
|
provider: provider,
|
||||||
|
decode: nil,
|
||||||
|
shouldInterpolate: false,
|
||||||
|
intent: .defaultIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `makeImage` output to a single-page TIFF. `dpi` nil
|
||||||
|
/// produces a TIFF with no resolution tags (the 72-fallback path).
|
||||||
|
static func makeTIFF(
|
||||||
|
px: CGSize,
|
||||||
|
dpi: Double?,
|
||||||
|
components: Int = 3,
|
||||||
|
bitsPerComponent: Int = 8,
|
||||||
|
alpha: Bool = false,
|
||||||
|
pixelBytes: [UInt8]? = nil,
|
||||||
|
in directory: URL
|
||||||
|
) throws -> URL {
|
||||||
|
guard let image = makeImage(
|
||||||
|
px: px, components: components,
|
||||||
|
bitsPerComponent: bitsPerComponent,
|
||||||
|
alpha: alpha, pixelBytes: pixelBytes)
|
||||||
|
else { throw FixtureError.imageNotCreated }
|
||||||
|
let url = directory.appendingPathComponent(
|
||||||
|
"fixture-\(UUID().uuidString).tiff")
|
||||||
|
guard let destination = CGImageDestinationCreateWithURL(
|
||||||
|
url as CFURL, UTType.tiff.identifier as CFString, 1, nil)
|
||||||
|
else { throw FixtureError.destinationNotCreated }
|
||||||
|
var properties: [CFString: Any] = [:]
|
||||||
|
if let dpi {
|
||||||
|
properties[kCGImagePropertyDPIWidth] = dpi
|
||||||
|
properties[kCGImagePropertyDPIHeight] = dpi
|
||||||
|
properties[kCGImagePropertyTIFFDictionary] = [
|
||||||
|
kCGImagePropertyTIFFXResolution: dpi,
|
||||||
|
kCGImagePropertyTIFFYResolution: dpi,
|
||||||
|
kCGImagePropertyTIFFResolutionUnit: 2, // inches
|
||||||
|
]
|
||||||
|
}
|
||||||
|
CGImageDestinationAddImage(
|
||||||
|
destination, image, properties as CFDictionary)
|
||||||
|
guard CGImageDestinationFinalize(destination)
|
||||||
|
else { throw FixtureError.finalizeFailed }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #201 Phase 3 — `TargetRasterLoader` DPI handling, device
|
||||||
|
/// re-tag (D10), unsupported-layout rejection, and manifest drift (D9).
|
||||||
|
final class TargetRasterTests: XCTestCase {
|
||||||
|
|
||||||
|
private var tempRoot: URL!
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
tempRoot = try TargetTestFixtures.makeTempDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
try? FileManager.default.removeItem(at: tempRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load(_ url: URL) throws -> TargetPageRaster {
|
||||||
|
try TargetRasterLoader.load(tiff: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode index 0 the same way the loader does, for provider-byte
|
||||||
|
/// comparisons that prove the re-tag did not resample.
|
||||||
|
private func decoded(_ url: URL) throws -> CGImage {
|
||||||
|
let source = try XCTUnwrap(
|
||||||
|
CGImageSourceCreateWithURL(url as CFURL, nil))
|
||||||
|
return try XCTUnwrap(
|
||||||
|
CGImageSourceCreateImageAtIndex(source, 0, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - pointSize / physicalMm
|
||||||
|
|
||||||
|
func testPointSizeFromDPI() throws {
|
||||||
|
let url = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 2480, height: 3508), dpi: 300,
|
||||||
|
in: tempRoot)
|
||||||
|
let raster = try load(url)
|
||||||
|
XCTAssertEqual(raster.pointSize.width, 595.2, accuracy: 0.01)
|
||||||
|
XCTAssertEqual(raster.pointSize.height, 841.92, accuracy: 0.01)
|
||||||
|
XCTAssertEqual(raster.physicalMm.width, 209.97, accuracy: 0.01)
|
||||||
|
XCTAssertEqual(raster.physicalMm.height, 297.01, accuracy: 0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPointSizeAt600dpi() throws {
|
||||||
|
let url = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 1200, height: 1800), dpi: 600,
|
||||||
|
in: tempRoot)
|
||||||
|
let raster = try load(url)
|
||||||
|
XCTAssertEqual(raster.pointSize.width, 144, accuracy: 0.01)
|
||||||
|
XCTAssertEqual(raster.pointSize.height, 216, accuracy: 0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingDPIFallsBackTo72() throws {
|
||||||
|
let url = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 100, height: 80), dpi: nil,
|
||||||
|
in: tempRoot)
|
||||||
|
let raster = try load(url)
|
||||||
|
XCTAssertEqual(raster.dpiX, TargetRasterLoader.fallbackDPI)
|
||||||
|
XCTAssertEqual(raster.dpiY, TargetRasterLoader.fallbackDPI)
|
||||||
|
XCTAssertEqual(raster.pointSize.width, 100, accuracy: 0.01)
|
||||||
|
XCTAssertEqual(raster.pointSize.height, 80, accuracy: 0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingFileThrows() {
|
||||||
|
let url = tempRoot.appendingPathComponent("nope.tiff")
|
||||||
|
XCTAssertThrowsError(try load(url)) { error in
|
||||||
|
XCTAssertEqual(
|
||||||
|
error as? TargetRasterError,
|
||||||
|
.tiffMissing(url.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGarbageFileThrowsUndecodable() throws {
|
||||||
|
let url = tempRoot.appendingPathComponent("junk.tiff")
|
||||||
|
try Data([0x00, 0x01, 0x02, 0x03]).write(to: url)
|
||||||
|
XCTAssertThrowsError(try load(url)) { error in
|
||||||
|
XCTAssertEqual(
|
||||||
|
error as? TargetRasterError,
|
||||||
|
.undecodable(url.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - deviceTagged (D10)
|
||||||
|
|
||||||
|
func testDeviceTaggedRGB8() throws {
|
||||||
|
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
|
||||||
|
px: CGSize(width: 16, height: 16),
|
||||||
|
components: 3, bitsPerComponent: 8))
|
||||||
|
let tagged = try XCTUnwrap(
|
||||||
|
TargetRasterLoader.deviceTagged(source))
|
||||||
|
XCTAssertEqual(tagged.colorSpace?.model, .rgb)
|
||||||
|
XCTAssertEqual(tagged.bitsPerComponent, 8)
|
||||||
|
XCTAssertFalse(tagged.shouldInterpolate)
|
||||||
|
XCTAssertEqual(
|
||||||
|
tagged.dataProvider?.data as? Data,
|
||||||
|
source.dataProvider?.data as? Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeviceTaggedCMYK8() throws {
|
||||||
|
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
|
||||||
|
px: CGSize(width: 16, height: 16),
|
||||||
|
components: 4, bitsPerComponent: 8))
|
||||||
|
let tagged = try XCTUnwrap(
|
||||||
|
TargetRasterLoader.deviceTagged(source))
|
||||||
|
XCTAssertEqual(tagged.colorSpace?.model, .cmyk)
|
||||||
|
XCTAssertEqual(tagged.bitsPerComponent, 8)
|
||||||
|
XCTAssertEqual(
|
||||||
|
tagged.dataProvider?.data as? Data,
|
||||||
|
source.dataProvider?.data as? Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeviceTaggedGray8() throws {
|
||||||
|
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
|
||||||
|
px: CGSize(width: 16, height: 16),
|
||||||
|
components: 1, bitsPerComponent: 8))
|
||||||
|
let tagged = try XCTUnwrap(
|
||||||
|
TargetRasterLoader.deviceTagged(source))
|
||||||
|
XCTAssertEqual(tagged.colorSpace?.model, .monochrome)
|
||||||
|
XCTAssertEqual(tagged.bitsPerComponent, 8)
|
||||||
|
XCTAssertEqual(
|
||||||
|
tagged.dataProvider?.data as? Data,
|
||||||
|
source.dataProvider?.data as? Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeviceTaggedRGB16KeepsBitDepth() throws {
|
||||||
|
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
|
||||||
|
px: CGSize(width: 16, height: 16),
|
||||||
|
components: 3, bitsPerComponent: 16))
|
||||||
|
let tagged = try XCTUnwrap(
|
||||||
|
TargetRasterLoader.deviceTagged(source))
|
||||||
|
XCTAssertEqual(tagged.colorSpace?.model, .rgb)
|
||||||
|
XCTAssertEqual(tagged.bitsPerComponent, 16)
|
||||||
|
XCTAssertEqual(
|
||||||
|
tagged.dataProvider?.data as? Data,
|
||||||
|
source.dataProvider?.data as? Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The loaded raster reuses the decoder's provider verbatim — byte
|
||||||
|
/// equality is the no-resample guarantee (R5).
|
||||||
|
func testLoadedRasterSharesDecodedBytes() throws {
|
||||||
|
let url = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 32, height: 24), dpi: 300,
|
||||||
|
in: tempRoot)
|
||||||
|
let raster = try load(url)
|
||||||
|
let decoded = try decoded(url)
|
||||||
|
XCTAssertEqual(
|
||||||
|
raster.cgImage.dataProvider?.data as? Data,
|
||||||
|
decoded.dataProvider?.data as? Data)
|
||||||
|
XCTAssertEqual(raster.cgImage.colorSpace?.model, .rgb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rejections
|
||||||
|
|
||||||
|
func testAlphaTIFFThrowsUnsupportedLayout() throws {
|
||||||
|
let url = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 16, height: 16), dpi: 300,
|
||||||
|
components: 3, alpha: true, in: tempRoot)
|
||||||
|
XCTAssertThrowsError(try load(url)) { error in
|
||||||
|
guard case .unsupportedLayout(_, _, _, let hasAlpha) =
|
||||||
|
error as? TargetRasterError
|
||||||
|
else {
|
||||||
|
return XCTFail("expected unsupportedLayout, got \(error)")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(hasAlpha)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeviceTaggedRejectsFloat() throws {
|
||||||
|
var info = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
|
||||||
|
info.insert(.floatComponents)
|
||||||
|
info.insert(.byteOrder32Little)
|
||||||
|
var bytes = [Float](repeating: 0.5, count: 4 * 4 * 3)
|
||||||
|
let floatImage = bytes.withUnsafeMutableBytes { buffer -> CGImage? in
|
||||||
|
CGImage(
|
||||||
|
width: 4, height: 4,
|
||||||
|
bitsPerComponent: 32, bitsPerPixel: 96,
|
||||||
|
bytesPerRow: 4 * 3 * 4,
|
||||||
|
space: CGColorSpaceCreateDeviceRGB(),
|
||||||
|
bitmapInfo: info,
|
||||||
|
provider: CGDataProvider(data: Data(buffer) as CFData)!,
|
||||||
|
decode: nil, shouldInterpolate: false,
|
||||||
|
intent: .defaultIntent)
|
||||||
|
}
|
||||||
|
bytes = []
|
||||||
|
let image = try XCTUnwrap(floatImage)
|
||||||
|
XCTAssertNil(TargetRasterLoader.deviceTagged(image))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeviceTaggedRejectsIndexed() throws {
|
||||||
|
var table = [UInt8](repeating: 0, count: 256 * 3)
|
||||||
|
let indexed = try XCTUnwrap(CGColorSpace(
|
||||||
|
indexedBaseSpace: CGColorSpaceCreateDeviceRGB(),
|
||||||
|
last: 255, colorTable: &table))
|
||||||
|
let image = try XCTUnwrap(CGImage(
|
||||||
|
width: 8, height: 8,
|
||||||
|
bitsPerComponent: 8, bitsPerPixel: 8,
|
||||||
|
bytesPerRow: 8,
|
||||||
|
space: indexed,
|
||||||
|
bitmapInfo: CGBitmapInfo(),
|
||||||
|
provider: CGDataProvider(
|
||||||
|
data: Data(repeating: 7, count: 64) as CFData)!,
|
||||||
|
decode: nil, shouldInterpolate: false,
|
||||||
|
intent: .defaultIntent))
|
||||||
|
XCTAssertNil(TargetRasterLoader.deviceTagged(image))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No 2-component `CGImage` is fabricatable through public API —
|
||||||
|
/// the rejection lives in `deviceSpace(for:)`, asserted here.
|
||||||
|
func testDeviceSpaceRejectsTwoAndFiveComponents() {
|
||||||
|
XCTAssertNil(TargetRasterLoader.deviceSpace(for: 2))
|
||||||
|
XCTAssertNil(TargetRasterLoader.deviceSpace(for: 0))
|
||||||
|
XCTAssertNil(TargetRasterLoader.deviceSpace(for: 5))
|
||||||
|
XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 1))
|
||||||
|
XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 3))
|
||||||
|
XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manifest drift (D9)
|
||||||
|
|
||||||
|
func testManifestDriftWhenMismatch() throws {
|
||||||
|
let url = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 2480, height: 3508), dpi: 300,
|
||||||
|
in: tempRoot)
|
||||||
|
let raster = try TargetRasterLoader.load(
|
||||||
|
tiff: url, expectedWidthMm: 200, expectedHeightMm: 297)
|
||||||
|
XCTAssertNotNil(raster.manifestDrift)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testManifestDriftNilWhenMatching() throws {
|
||||||
|
let url = try TargetTestFixtures.makeTIFF(
|
||||||
|
px: CGSize(width: 2480, height: 3508), dpi: 300,
|
||||||
|
in: tempRoot)
|
||||||
|
// 2480 px @300 dpi = 209.97 mm — inside the 0.5 mm band.
|
||||||
|
let raster = try TargetRasterLoader.load(
|
||||||
|
tiff: url, expectedWidthMm: 210, expectedHeightMm: 297)
|
||||||
|
XCTAssertNil(raster.manifestDrift)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,6 +188,50 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
|
|||||||
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv)
|
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #186 — the stubbed panel result's `orientation-requested=` /
|
||||||
|
/// `MediaType=` apply back to the Stage 2 selections and reach the
|
||||||
|
/// `lp` argv through the captured `cupsOptions` replay.
|
||||||
|
func testPanelResultAppliesBackOrientationAndMedia() throws {
|
||||||
|
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
|
||||||
|
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
|
||||||
|
"PageSize=Letter EPIJ_Qual=305 orientation-requested=4 MediaType=PhotographicGlossy"
|
||||||
|
launchAppWithDefaults()
|
||||||
|
reachPrintPanel()
|
||||||
|
_ = waitFor("printerStatusBadge")
|
||||||
|
|
||||||
|
element("btnPrinterProperties").click()
|
||||||
|
let notice = element("printNotificationText")
|
||||||
|
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue((notice.value as? String ?? "")
|
||||||
|
.contains("Settings captured"))
|
||||||
|
|
||||||
|
// `printerMediaTypeSelect` is the group's id — the popup is a
|
||||||
|
// descendant (stacked identifiers collapse to the container).
|
||||||
|
// The popup's AX title lags the binding — poll for the
|
||||||
|
// apply-back value.
|
||||||
|
let mediaPopup = element("printerMediaTypeSelect")
|
||||||
|
.descendants(matching: .popUpButton).firstMatch
|
||||||
|
XCTAssertTrue(mediaPopup.waitForExistence(timeout: 5))
|
||||||
|
var mediaSelection = ""
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
while Date() < deadline, mediaSelection != "PhotographicGlossy" {
|
||||||
|
mediaSelection = [
|
||||||
|
mediaPopup.title, mediaPopup.label,
|
||||||
|
mediaPopup.value as? String ?? "",
|
||||||
|
].first { !$0.isEmpty } ?? ""
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(mediaSelection, "PhotographicGlossy")
|
||||||
|
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter")
|
||||||
|
|
||||||
|
app.buttons["btnPrintAll"].click()
|
||||||
|
let argv = waitForLpLine()
|
||||||
|
XCTAssertTrue(argv.contains("orientation-requested=4"), argv)
|
||||||
|
XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv)
|
||||||
|
XCTAssertTrue(argv.contains("PageSize=Letter"), argv)
|
||||||
|
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv)
|
||||||
|
}
|
||||||
|
|
||||||
private func launchAppWithDefaults() {
|
private func launchAppWithDefaults() {
|
||||||
app.launch()
|
app.launch()
|
||||||
app.activate()
|
app.activate()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+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.0"
|
MARKETING_VERSION: "2.0.1"
|
||||||
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