Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee5da4a637 | ||
|
|
0513b272cb | ||
|
|
555bf8fda4 | ||
|
|
f3c7b19694 | ||
|
|
5e696ff3e9 | ||
|
|
19f619ad98 | ||
|
|
09f466ae46 | ||
|
|
d228594bd3 | ||
|
|
6adbf99bd9 | ||
|
|
62a45d9c7d |
@@ -50,6 +50,27 @@ bump `project.yml` on `develop` before tagging. CI needs `fetch-depth: 0`.
|
||||
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
|
||||
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
|
||||
```
|
||||
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 |
|
||||
| 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.**
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| 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` |
|
||||
| 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 |
|
||||
|
||||
## What it does
|
||||
@@ -299,9 +299,9 @@ fix/<issue>-<slug>
|
||||
```
|
||||
|
||||
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
|
||||
branch is used only while that milestone is assembling — currently
|
||||
`milestone/m11-print-settings` (`milestone/m10-studio` was merged and
|
||||
deleted). Do not open umbrella “bugfix” branches that mix tickets.
|
||||
branch is used only while that milestone is assembling; `milestone/m10-studio`
|
||||
and `milestone/m11-print-settings` have been merged (branches retained). Do
|
||||
not open umbrella “bugfix” branches that mix tickets.
|
||||
|
||||
`main` is push-protected and requires status check
|
||||
`macOS CI / build-and-test (push)`. Protected **file** patterns on `main`
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
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.
|
||||
/// * `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 }
|
||||
defer { PMRelease(object(printer)) }
|
||||
guard let id = PMPrinterGetID(printer)
|
||||
else { return fallback }
|
||||
return id.takeUnretainedValue() as String
|
||||
}
|
||||
|
||||
// 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.
|
||||
enum PrintPanelError: LocalizedError {
|
||||
case sessionBindingFailed(OSStatus)
|
||||
case noPrinterFound(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .sessionBindingFailed(let status):
|
||||
return "Could not bind the print session to the queue (OSStatus \(status))."
|
||||
case .noPrinterFound(let name):
|
||||
return "No printer found for '\(name)'."
|
||||
}
|
||||
@@ -93,24 +90,17 @@ struct PrintPanelService {
|
||||
var boundViaPM = false
|
||||
|
||||
// ① 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
|
||||
let session = unsafeBitCast(
|
||||
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
||||
let settings = unsafeBitCast(
|
||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||
let pageFormat = unsafeBitCast(
|
||||
printInfo.pmPageFormat(), to: PMPageFormat.self)
|
||||
let session = PMTicketBridge.session(printInfo)
|
||||
let settings = PMTicketBridge.settings(printInfo)
|
||||
|
||||
let status = PMSessionSetCurrentPMPrinter(session, printer)
|
||||
if status != 0 {
|
||||
PMRelease(Self.pmObject(printer))
|
||||
throw PrintPanelError.sessionBindingFailed(status)
|
||||
do {
|
||||
try PMTicketBridge.bind(printer: printer, to: printInfo)
|
||||
} catch {
|
||||
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
|
||||
// ColorSync suppression ②–⑤ (locked write order,
|
||||
// #183/#186). Paper is TWO writes (E1): the `PageSize`
|
||||
@@ -119,9 +109,9 @@ struct PrintPanelService {
|
||||
applyInitialSelections(
|
||||
initialSelections, to: settings, optionKeys: optionKeys)
|
||||
if let paperToken = initialSelections.paperSize {
|
||||
applyPaperPageFormat(
|
||||
paperToken, printer: printer, session: session,
|
||||
printInfo: printInfo)
|
||||
PMTicketBridge.applyPaper(
|
||||
token: paperToken, printer: printer,
|
||||
session: session, printInfo: printInfo)
|
||||
}
|
||||
boundViaPM = true
|
||||
} else {
|
||||
@@ -142,19 +132,17 @@ struct PrintPanelService {
|
||||
}
|
||||
defer {
|
||||
if let printer = pmPrinter {
|
||||
PMRelease(Self.pmObject(printer))
|
||||
PMTicketBridge.release(printer)
|
||||
}
|
||||
}
|
||||
|
||||
// ②–⑤ ColourSync suppression — only on the PM path: the SPI
|
||||
// and PMPrintSettingsSetValue need a session with a current
|
||||
// printer to attach to.
|
||||
var settings = unsafeBitCast(
|
||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||
var settings = PMTicketBridge.settings(printInfo)
|
||||
var driverBypass: (key: String, value: String)?
|
||||
if boundViaPM {
|
||||
let session = unsafeBitCast(
|
||||
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
||||
let session = PMTicketBridge.session(printInfo)
|
||||
suppressor.applySPIMode(to: session) // ②
|
||||
suppressor.applyLockedKeys(to: settings) // ③
|
||||
driverBypass = suppressor.applyDriverBypass( // ④
|
||||
@@ -183,8 +171,7 @@ struct PrintPanelService {
|
||||
var cupsOptions: String?
|
||||
var mediaType: String?
|
||||
if boundViaPM {
|
||||
settings = unsafeBitCast(
|
||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||
settings = PMTicketBridge.settings(printInfo)
|
||||
let captured = suppressor.captureOptions(from: settings)
|
||||
cupsOptions = captured.cupsOptions
|
||||
mediaType = captured.mediaType
|
||||
@@ -192,9 +179,8 @@ struct PrintPanelService {
|
||||
let capturedOptions = cupsOptions ?? ""
|
||||
return PrintPropertiesResult(
|
||||
selectedPrinter: boundViaPM
|
||||
? Self.currentPrinterID(
|
||||
session: unsafeBitCast(
|
||||
printInfo.pmPrintSession(), to: PMPrintSession.self),
|
||||
? PMTicketBridge.currentPrinterID(
|
||||
session: PMTicketBridge.session(printInfo),
|
||||
fallback: queue)
|
||||
: nil,
|
||||
options: PrintOptions(
|
||||
@@ -219,143 +205,30 @@ struct PrintPanelService {
|
||||
optionKeys: Set<String>
|
||||
) {
|
||||
if let paperSize = selections.paperSize {
|
||||
warnOnFailure(PMPrintSettingsSetValue(
|
||||
settings, "PageSize" as CFString,
|
||||
paperSize as CFString, false), key: "PageSize")
|
||||
PMTicketBridge.setValue(
|
||||
paperSize, forKey: "PageSize", locked: false,
|
||||
in: settings, context: "Print panel")
|
||||
}
|
||||
if let key = selections.qualityKey, let value = selections.quality {
|
||||
warnOnFailure(PMPrintSettingsSetValue(
|
||||
settings, key as CFString,
|
||||
value as CFString, false), key: key)
|
||||
PMTicketBridge.setValue(
|
||||
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) {
|
||||
warnOnFailure(PMPrintSettingsSetValue(
|
||||
settings, mediaKey as CFString,
|
||||
mediaType as CFString, false), key: mediaKey)
|
||||
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"
|
||||
warnOnFailure(PMPrintSettingsSetValue(
|
||||
settings, "orientation-requested" as CFString,
|
||||
code as CFString, false), key: "orientation-requested")
|
||||
PMTicketBridge.setValue(
|
||||
code, forKey: "orientation-requested", locked: false,
|
||||
in: settings, context: "Print panel")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
private func applyPaperPageFormat(
|
||||
_ token: String,
|
||||
printer: PMPrinter,
|
||||
session: PMPrintSession,
|
||||
printInfo: NSPrintInfo
|
||||
) {
|
||||
if let custom = Self.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(unsafeBitCast(newFormat, to: PMObject.self)) }
|
||||
_ = PMSessionValidatePageFormat(session, newFormat, nil)
|
||||
let destination = unsafeBitCast(
|
||||
printInfo.pmPageFormat(), to: PMPageFormat.self)
|
||||
_ = PMCopyPageFormat(newFormat, destination)
|
||||
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)
|
||||
}
|
||||
|
||||
private func warnOnFailure(_ status: OSStatus, key: String) {
|
||||
if status != 0 {
|
||||
AppLogger.shared.warn(
|
||||
"Print panel: PMPrintSettingsSetValue(\(key)) "
|
||||
+ "rejected (\(status))")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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_NSPrincipalClass: NSApplication
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
||||
MARKETING_VERSION: "2.0.0"
|
||||
MARKETING_VERSION: "2.0.1"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
# Stamped by scripts/version.sh at packaging/CI time (release tag or
|
||||
# `git describe` string); About shows it next to the marketing version.
|
||||
|
||||
Reference in New Issue
Block a user