Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2f994821f | ||
|
|
1d6032456d | ||
|
|
fc1530ab2e | ||
|
|
ffbe56f43a |
@@ -36,7 +36,10 @@ enum PMTicketError: LocalizedError, Equatable {
|
||||
/// so Swift hands back an unbalanced `Unmanaged`).
|
||||
/// * `PMPrinterGetPaperList`, `PMPaperGetID`, `PMPrinterGetID`,
|
||||
/// `PMPrintSettingsGetValue` are **borrowed** → `takeUnretainedValue()`,
|
||||
/// never released.
|
||||
/// 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
|
||||
@@ -102,12 +105,168 @@ enum PMTicketBridge {
|
||||
guard PMSessionGetCurrentPrinter(session, ¤t) == 0,
|
||||
let printer = current
|
||||
else { return fallback }
|
||||
defer { PMRelease(object(printer)) }
|
||||
// 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
|
||||
|
||||
@@ -59,10 +59,12 @@ struct PrintPanelService {
|
||||
cupsService: CupsService,
|
||||
initialSelections: PrintPanelInitialSelections =
|
||||
PrintPanelInitialSelections()
|
||||
) async throws -> PrintPropertiesResult? {
|
||||
) async throws -> PanelCaptureResult? {
|
||||
#if DEBUG
|
||||
if UITestHooks.printPanelStubbed {
|
||||
return UITestHooks.printPanelResult(forQueue: queue)
|
||||
return UITestHooks.printPanelResult(forQueue: queue).map {
|
||||
PanelCaptureResult(properties: $0, ticket: nil)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// `??` rhs is a non-async @autoclosure — fetch first.
|
||||
@@ -84,7 +86,7 @@ struct PrintPanelService {
|
||||
displayName: String?,
|
||||
optionKeys: Set<String>,
|
||||
initialSelections: PrintPanelInitialSelections
|
||||
) throws -> PrintPropertiesResult? {
|
||||
) throws -> PanelCaptureResult? {
|
||||
let printInfo = NSPrintInfo()
|
||||
var pmPrinter: PMPrinter?
|
||||
var boundViaPM = false
|
||||
@@ -177,22 +179,31 @@ struct PrintPanelService {
|
||||
mediaType = captured.mediaType
|
||||
}
|
||||
let capturedOptions = cupsOptions ?? ""
|
||||
return PrintPropertiesResult(
|
||||
selectedPrinter: boundViaPM
|
||||
let resolvedQueue = boundViaPM
|
||||
? PMTicketBridge.currentPrinterID(
|
||||
session: PMTicketBridge.session(printInfo),
|
||||
fallback: queue)
|
||||
: nil,
|
||||
: queue
|
||||
// ⑦ Serialise the native ticket — the payload `lp -o` could
|
||||
// never carry (#201). Warn-only via `try?`: a serialise
|
||||
// failure must not lose the Stage 2 mirror above.
|
||||
let ticket = try? PMTicketBridge.serialise(
|
||||
printInfo, queue: resolvedQueue)
|
||||
return PanelCaptureResult(
|
||||
properties: PrintPropertiesResult(
|
||||
selectedPrinter: boundViaPM ? resolvedQueue : nil,
|
||||
options: PrintOptions(
|
||||
orientation: CupsParsers.extractOrientation(
|
||||
fromOptionsString: capturedOptions),
|
||||
paperSize: CupsParsers.extractOption(
|
||||
named: "PageSize", fromOptionsString: capturedOptions),
|
||||
named: "PageSize",
|
||||
fromOptionsString: capturedOptions),
|
||||
mediaType: mediaType,
|
||||
quality: CupsParsers.extractQuality(
|
||||
fromOptionsString: capturedOptions),
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: cupsOptions))
|
||||
cupsOptions: cupsOptions)),
|
||||
ticket: ticket)
|
||||
}
|
||||
|
||||
/// Initial-selection `PMPrintSettings` writes — paper, quality,
|
||||
|
||||
@@ -23,6 +23,9 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
@Published var selectedQuality: String?
|
||||
@Published var printOrientation = "portrait"
|
||||
@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 isPrinting = false
|
||||
private var printTask: Task<Void, Never>?
|
||||
@@ -164,31 +167,35 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
)
|
||||
return
|
||||
}
|
||||
if let selected = result.selectedPrinter,
|
||||
if let selected = result.properties.selectedPrinter,
|
||||
printers.contains(where: { $0.name == selected }),
|
||||
selected != queue {
|
||||
selectedPrinter = selected
|
||||
await reloadSelectedCapabilities()
|
||||
}
|
||||
if let captured = result.options.cupsOptions {
|
||||
if let captured = result.properties.options.cupsOptions {
|
||||
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
|
||||
}
|
||||
// Capture-return (#183/#186): a dialog paper/quality/
|
||||
// orientation change updates the Stage 2 selections —
|
||||
// never `workflow.pageSize` (printtarg layout is
|
||||
// sacred).
|
||||
if let paper = result.options.paperSize,
|
||||
if let paper = result.properties.options.paperSize,
|
||||
let match = printerCaps.paperSizes
|
||||
.first(where: { $0.name == paper }) {
|
||||
selectedPaperSize = match.id
|
||||
}
|
||||
if let quality = result.options.quality {
|
||||
if let quality = result.properties.options.quality {
|
||||
selectedQuality = quality
|
||||
}
|
||||
if let orientation = result.options.orientation {
|
||||
if let orientation = result.properties.options.orientation {
|
||||
printOrientation = orientation
|
||||
}
|
||||
printNotice = Notice(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user