feat(#29): Stage 0 printer calibration #64
@@ -9,6 +9,7 @@ public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
|
|||||||
case chartreadFailed(String)
|
case chartreadFailed(String)
|
||||||
case averageFailed(String)
|
case averageFailed(String)
|
||||||
case colprofFailed(String)
|
case colprofFailed(String)
|
||||||
|
case printcalFailed(String)
|
||||||
case applycalFailed(String)
|
case applycalFailed(String)
|
||||||
case iccgamutFailed(String)
|
case iccgamutFailed(String)
|
||||||
case profcheckFailed(String)
|
case profcheckFailed(String)
|
||||||
@@ -30,6 +31,8 @@ public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
|
|||||||
return "Averaging failed: \(reason)"
|
return "Averaging failed: \(reason)"
|
||||||
case .colprofFailed(let reason):
|
case .colprofFailed(let reason):
|
||||||
return "Profile creation failed: \(reason)"
|
return "Profile creation failed: \(reason)"
|
||||||
|
case .printcalFailed(let reason):
|
||||||
|
return "Calibration curve computation failed: \(reason)"
|
||||||
case .applycalFailed(let reason):
|
case .applycalFailed(let reason):
|
||||||
return "Apply calibration failed: \(reason)"
|
return "Apply calibration failed: \(reason)"
|
||||||
case .iccgamutFailed(let reason):
|
case .iccgamutFailed(let reason):
|
||||||
@@ -755,6 +758,82 @@ public struct ArgyllRunner: Sendable {
|
|||||||
await processManager.kill(id: processId)
|
await processManager.kill(id: processId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Stage 0 calibration
|
||||||
|
|
||||||
|
/// Generates a calibration wedge `.ti1`.
|
||||||
|
public func runCalibrationTargen(
|
||||||
|
config: CalibrationTargenConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let args = try CalibrationTargenArgs.build(config: config)
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let calBasename = config.basename.hasPrefix("CAL_") ? config.basename : "CAL_\(config.basename)"
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(calBasename)
|
||||||
|
let binaryURL = binaryResolver.resolve("targen")
|
||||||
|
let processId = ProcessID.targen(cleanBasename)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||||
|
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
|
||||||
|
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
|
||||||
|
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
|
||||||
|
}
|
||||||
|
return ti1URL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes a `.cal` curve from a measured `CAL_*.ti3`.
|
||||||
|
///
|
||||||
|
/// `printcal` is captured (not streamed) and is exempt from the `-u`
|
||||||
|
/// JSON policy.
|
||||||
|
public func runPrintcal(
|
||||||
|
config: PrintcalConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let args = try PrintcalArgs.build(config: config)
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let binaryURL = binaryResolver.resolve("printcal")
|
||||||
|
let calBasename = config.ti3Basename.hasPrefix("CAL_") ? config.ti3Basename : "CAL_\(config.ti3Basename)"
|
||||||
|
let processId = ProcessID.printcal(calBasename)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let result = try await processManager.runCaptured(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
|
||||||
|
if let onLogBatch = onLogBatch, !result.stdout.isEmpty {
|
||||||
|
onLogBatch(result.stdout.components(separatedBy: .newlines))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard result.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.printcalFailed(
|
||||||
|
result.stderr.isEmpty
|
||||||
|
? "printcal exited with code \(result.exitCode)"
|
||||||
|
: result.stderr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let calURL = config.outputURL
|
||||||
|
guard FileManager.default.fileExists(atPath: calURL.path) else {
|
||||||
|
throw ArgyllRunnerError.missingArtefact(calURL.path)
|
||||||
|
}
|
||||||
|
return calURL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Events emitted by a running `chartread` session.
|
/// Events emitted by a running `chartread` session.
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ public enum AppPaths {
|
|||||||
|
|
||||||
/// `~/Library/Application Support/com.gronod.iccery2`
|
/// `~/Library/Application Support/com.gronod.iccery2`
|
||||||
///
|
///
|
||||||
/// DEBUG only: `ICCERY_TEST_ROOT` redirects app data so UI tests run
|
/// DEBUG only: `ICCERY_TEST_ROOT` or `ICCERY_TEST_WORKDIR` redirect app
|
||||||
/// against an isolated root and never touch the developer's state.
|
/// data so UI tests run against an isolated root and never touch the
|
||||||
|
/// developer's state.
|
||||||
public static var appDataDir: URL {
|
public static var appDataDir: URL {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if let root = testRoot {
|
if let root = testRoot {
|
||||||
@@ -42,11 +43,32 @@ public enum AppPaths {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
/// DEBUG-only root override. Order:
|
||||||
|
/// 1. `ICCERY_TEST_ROOT` for an explicit test root.
|
||||||
|
/// 2. `ICCERY_TEST_WORKDIR` so the app data and log files live next to
|
||||||
|
/// the current UI test's working directory.
|
||||||
|
/// 3. `ICCERY_UI_TESTING=1` creates a per-process temp root so a UI test
|
||||||
|
/// that sets neither of the above still runs in isolation.
|
||||||
|
///
|
||||||
|
/// Computed from `ProcessInfo` each call — no mutable static state.
|
||||||
private static var testRoot: URL? {
|
private static var testRoot: URL? {
|
||||||
guard let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"],
|
if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"],
|
||||||
!raw.isEmpty else { return nil }
|
!raw.isEmpty {
|
||||||
return URL(fileURLWithPath: raw, isDirectory: true)
|
return URL(fileURLWithPath: raw, isDirectory: true)
|
||||||
}
|
}
|
||||||
|
if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_WORKDIR"],
|
||||||
|
!raw.isEmpty {
|
||||||
|
return URL(fileURLWithPath: raw, isDirectory: true)
|
||||||
|
}
|
||||||
|
if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" {
|
||||||
|
return FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"iccery-ui-\(ProcessInfo.processInfo.processIdentifier)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// `~/Library/Logs/com.gronod.iccery2/iccery.log`
|
/// `~/Library/Logs/com.gronod.iccery2/iccery.log`
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A single channel's calibration curve.
|
||||||
|
public struct CalibrationCurve: Sendable, Equatable {
|
||||||
|
public let channel: Character
|
||||||
|
public let input: [Double]
|
||||||
|
public let output: [Double]
|
||||||
|
|
||||||
|
public init(channel: Character, input: [Double], output: [Double]) {
|
||||||
|
self.channel = channel
|
||||||
|
self.input = input
|
||||||
|
self.output = output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed Argyll `.cal` curve data.
|
||||||
|
public struct CalibrationData: Sendable, Equatable {
|
||||||
|
public var colorRep: String
|
||||||
|
public var descriptor: String?
|
||||||
|
public var created: Date?
|
||||||
|
public var maxTac: Double?
|
||||||
|
public var inkLimits: [Character: Double]
|
||||||
|
public var curves: [CalibrationCurve]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
colorRep: String = "",
|
||||||
|
descriptor: String? = nil,
|
||||||
|
created: Date? = nil,
|
||||||
|
maxTac: Double? = nil,
|
||||||
|
inkLimits: [Character: Double] = [:],
|
||||||
|
curves: [CalibrationCurve] = []
|
||||||
|
) {
|
||||||
|
self.colorRep = colorRep
|
||||||
|
self.descriptor = descriptor
|
||||||
|
self.created = created
|
||||||
|
self.maxTac = maxTac
|
||||||
|
self.inkLimits = inkLimits
|
||||||
|
self.curves = curves
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors from loading and parsing a `.cal` file.
|
||||||
|
public enum CalibrationStoreError: Error, Equatable {
|
||||||
|
case unreadableFile
|
||||||
|
case missingColorRep
|
||||||
|
case missingCurveData
|
||||||
|
case unsupportedFormat
|
||||||
|
case parseFailed(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unreadableFile:
|
||||||
|
return "Could not read the calibration file."
|
||||||
|
case .missingColorRep:
|
||||||
|
return "The .cal file is missing its COLOR_REP header."
|
||||||
|
case .missingCurveData:
|
||||||
|
return "The .cal file contains no calibration curve data."
|
||||||
|
case .unsupportedFormat:
|
||||||
|
return "The .cal file format is not supported."
|
||||||
|
case .parseFailed(let reason):
|
||||||
|
return "Calibration parse failed: \(reason)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store for a calibration curve, its metadata, and staleness checks.
|
||||||
|
public actor CalibrationStore {
|
||||||
|
|
||||||
|
public private(set) var data: CalibrationData?
|
||||||
|
public private(set) var sourceURL: URL?
|
||||||
|
public private(set) var storedPrinterName: String?
|
||||||
|
|
||||||
|
/// Number of days after which a calibration is considered stale.
|
||||||
|
public var staleDays: Int
|
||||||
|
|
||||||
|
public init(staleDays: Int = 30) {
|
||||||
|
self.staleDays = staleDays
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load and parse a `.cal` file.
|
||||||
|
public func load(url: URL) async throws {
|
||||||
|
let dataset = try CGATSParser.parse(url: url)
|
||||||
|
|
||||||
|
guard let colorRep = dataset.colorRep, !colorRep.isEmpty else {
|
||||||
|
throw CalibrationStoreError.missingColorRep
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = CalibrationData()
|
||||||
|
data.colorRep = colorRep
|
||||||
|
data.descriptor = dataset.keywords["DESCRIPTOR"]
|
||||||
|
|
||||||
|
if let createdString = dataset.keywords["CREATED"] {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
data.created = formatter.date(from: createdString)
|
||||||
|
?? Date(timeIntervalSince1970: 0)
|
||||||
|
} else {
|
||||||
|
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
|
data.created = attrs?[.modificationDate] as? Date
|
||||||
|
}
|
||||||
|
|
||||||
|
let limitKeys = ["MAX_TAC", "TOTAL_INK_LIMIT", "INK_LIMIT"]
|
||||||
|
for key in limitKeys {
|
||||||
|
if let raw = dataset.keywords[key], let value = Double(raw) {
|
||||||
|
data.maxTac = value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, raw) in dataset.keywords where key.hasPrefix("INK_LIMIT_") {
|
||||||
|
let suffix = key.dropFirst("INK_LIMIT_".count)
|
||||||
|
guard let channel = suffix.first, let value = Double(raw) else { continue }
|
||||||
|
data.inkLimits[channel] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
data.curves = try Self.extractCurves(from: dataset)
|
||||||
|
guard !data.curves.isEmpty else {
|
||||||
|
throw CalibrationStoreError.missingCurveData
|
||||||
|
}
|
||||||
|
|
||||||
|
self.data = data
|
||||||
|
self.sourceURL = url
|
||||||
|
|
||||||
|
// Printer name may live in a sidecar JSON. For now, fall back to the
|
||||||
|
// descriptor so callers have something to compare.
|
||||||
|
self.storedPrinterName = data.descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store an explicit printer name (e.g. from a sidecar).
|
||||||
|
public func setPrinterName(_ name: String?) {
|
||||||
|
self.storedPrinterName = name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the loaded calibration is older than `staleDays` or the
|
||||||
|
/// printer name does not match.
|
||||||
|
public func isStale(comparedTo currentPrinter: String? = nil) -> Bool {
|
||||||
|
guard let data else { return true }
|
||||||
|
|
||||||
|
if let created = data.created,
|
||||||
|
let threshold = Calendar.current.date(byAdding: .day, value: staleDays, to: created),
|
||||||
|
Date() > threshold {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let stored = storedPrinterName, !stored.isEmpty,
|
||||||
|
let current = currentPrinter, !current.isEmpty,
|
||||||
|
stored != current {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func extractCurves(from dataset: CGATSDataset) throws -> [CalibrationCurve] {
|
||||||
|
// Argyll .cal files contain an INPUT_VALUE column and one or more
|
||||||
|
// per-channel output columns. Field names vary by COLOR_REP.
|
||||||
|
let outputFields = dataset.fieldNames.filter { $0 != "SAMPLE_ID" && $0 != "SAMPLE_LOC" && $0 != "INPUT_VALUE" }
|
||||||
|
guard !outputFields.isEmpty else {
|
||||||
|
// Older .cal files may only have one output column named OUTPUT_VALUE.
|
||||||
|
if dataset.fieldNames.contains("OUTPUT_VALUE") {
|
||||||
|
return [try buildCurve(channel: "K", field: "OUTPUT_VALUE", dataset: dataset)]
|
||||||
|
}
|
||||||
|
throw CalibrationStoreError.missingCurveData
|
||||||
|
}
|
||||||
|
|
||||||
|
var curves = [CalibrationCurve]()
|
||||||
|
for field in outputFields {
|
||||||
|
let channel = field.first ?? "?"
|
||||||
|
let curve = try buildCurve(channel: channel, field: field, dataset: dataset)
|
||||||
|
curves.append(curve)
|
||||||
|
}
|
||||||
|
return curves
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildCurve(
|
||||||
|
channel: Character,
|
||||||
|
field: String,
|
||||||
|
dataset: CGATSDataset
|
||||||
|
) throws -> CalibrationCurve {
|
||||||
|
var input = [Double]()
|
||||||
|
var output = [Double]()
|
||||||
|
|
||||||
|
for sample in dataset.samples {
|
||||||
|
guard let inRaw = sample.values["INPUT_VALUE"] ?? sample.values[field],
|
||||||
|
let inVal = parseNumber(inRaw),
|
||||||
|
let outRaw = sample.values[field],
|
||||||
|
let outVal = parseNumber(outRaw) else {
|
||||||
|
throw CalibrationStoreError.parseFailed("Non-numeric curve value in \(field)")
|
||||||
|
}
|
||||||
|
input.append(inVal)
|
||||||
|
output.append(outVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
return CalibrationCurve(channel: channel, input: input, output: output)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseNumber(_ raw: String) -> Double? {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .decimal
|
||||||
|
return formatter.number(from: raw)?.doubleValue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during calibration `targen` argv construction.
|
||||||
|
public enum CalibrationTargenArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
case invalidSteps(Int)
|
||||||
|
case invalidInkLimit(Int)
|
||||||
|
case invalidWhitePatches(Int)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid calibration basename: \(name)"
|
||||||
|
case .invalidSteps(let steps):
|
||||||
|
return "Calibration steps must be 11–51, got: \(steps)"
|
||||||
|
case .invalidInkLimit(let limit):
|
||||||
|
return "Calibration ink limit must be 200–400, got: \(limit)"
|
||||||
|
case .invalidWhitePatches(let count):
|
||||||
|
return "Calibration white patches cannot be negative, got: \(count)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for a calibration wedge `targen` run.
|
||||||
|
public struct CalibrationTargenConfig: Sendable, Equatable {
|
||||||
|
public var colourSpace: ColourSpace
|
||||||
|
public var steps: Int
|
||||||
|
public var whitePatches: Int
|
||||||
|
public var includeNeutralEmphasis: Bool
|
||||||
|
public var inkLimit: Int?
|
||||||
|
public var basename: String
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
colourSpace: ColourSpace = .rgb,
|
||||||
|
steps: Int = 21,
|
||||||
|
whitePatches: Int = 4,
|
||||||
|
includeNeutralEmphasis: Bool = false,
|
||||||
|
inkLimit: Int? = nil,
|
||||||
|
basename: String = "",
|
||||||
|
workingDirectory: URL? = nil
|
||||||
|
) {
|
||||||
|
self.colourSpace = colourSpace
|
||||||
|
self.steps = steps
|
||||||
|
self.whitePatches = whitePatches
|
||||||
|
self.includeNeutralEmphasis = includeNeutralEmphasis
|
||||||
|
self.inkLimit = inkLimit
|
||||||
|
self.basename = basename
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for the Stage 0 calibration `targen` chart.
|
||||||
|
///
|
||||||
|
/// Produces a per-channel wedge with `-f 0` (no full-spread patches).
|
||||||
|
public enum CalibrationTargenArgs {
|
||||||
|
|
||||||
|
/// Builds `targen -v -d {2|4} -s N -g N [-n N] -e W [-l TAC] -f 0 CAL_basename`.
|
||||||
|
public static func build(config: CalibrationTargenConfig) throws -> [String] {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
|
||||||
|
guard (11...51).contains(config.steps) else {
|
||||||
|
throw CalibrationTargenArgError.invalidSteps(config.steps)
|
||||||
|
}
|
||||||
|
guard config.whitePatches >= 0 else {
|
||||||
|
throw CalibrationTargenArgError.invalidWhitePatches(config.whitePatches)
|
||||||
|
}
|
||||||
|
|
||||||
|
var args: [String] = [
|
||||||
|
"-v",
|
||||||
|
"-d", config.colourSpace.dFlagValue,
|
||||||
|
"-s", "\(config.steps)",
|
||||||
|
"-g", "\(config.steps)",
|
||||||
|
"-e", "\(config.whitePatches)",
|
||||||
|
"-f", "0"
|
||||||
|
]
|
||||||
|
|
||||||
|
if config.includeNeutralEmphasis {
|
||||||
|
args.append(contentsOf: ["-n", "\(config.steps)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.colourSpace == .cmyk, let inkLimit = config.inkLimit {
|
||||||
|
guard (200...400).contains(inkLimit) else {
|
||||||
|
throw CalibrationTargenArgError.invalidInkLimit(inkLimit)
|
||||||
|
}
|
||||||
|
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
|
||||||
|
args.append(calBasename)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `printcal` argv construction.
|
||||||
|
public enum PrintcalArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
case invalidTotalInkLimit(Double)
|
||||||
|
case invalidPerChannelLimit(Character, Double)
|
||||||
|
case invalidOutputPath
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid calibration basename: \(name)"
|
||||||
|
case .invalidTotalInkLimit(let limit):
|
||||||
|
return "Total ink limit must be positive, got: \(limit)"
|
||||||
|
case .invalidPerChannelLimit(let channel, let limit):
|
||||||
|
return "\(channel) channel limit must be 0–100, got: \(limit)"
|
||||||
|
case .invalidOutputPath:
|
||||||
|
return "Invalid .cal output path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-channel ink limit for `printcal -x{C|M|Y|K} pct`.
|
||||||
|
public struct PrintcalChannelLimit: Sendable, Equatable {
|
||||||
|
public let channel: Character
|
||||||
|
public let percent: Double
|
||||||
|
|
||||||
|
public init(channel: Character, percent: Double) {
|
||||||
|
self.channel = channel
|
||||||
|
self.percent = percent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for an Argyll `printcal` run.
|
||||||
|
public struct PrintcalConfig: Sendable, Equatable {
|
||||||
|
public var ti3Basename: String
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
public var outputURL: URL
|
||||||
|
public var noInkLimit: Bool
|
||||||
|
public var verify: Bool
|
||||||
|
public var previousCalPath: String?
|
||||||
|
public var totalInkLimit: Double?
|
||||||
|
public var channelLimits: [PrintcalChannelLimit]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
ti3Basename: String,
|
||||||
|
workingDirectory: URL? = nil,
|
||||||
|
outputURL: URL,
|
||||||
|
noInkLimit: Bool = false,
|
||||||
|
verify: Bool = false,
|
||||||
|
previousCalPath: String? = nil,
|
||||||
|
totalInkLimit: Double? = nil,
|
||||||
|
channelLimits: [PrintcalChannelLimit] = []
|
||||||
|
) {
|
||||||
|
self.ti3Basename = ti3Basename
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
self.outputURL = outputURL
|
||||||
|
self.noInkLimit = noInkLimit
|
||||||
|
self.verify = verify
|
||||||
|
self.previousCalPath = previousCalPath
|
||||||
|
self.totalInkLimit = totalInkLimit
|
||||||
|
self.channelLimits = channelLimits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `printcal` tool.
|
||||||
|
///
|
||||||
|
/// `printcal` is captured, not streamed. JS never sends `-u` (unapply).
|
||||||
|
public enum PrintcalArgs {
|
||||||
|
|
||||||
|
/// Builds `printcal -v -e [-I] [-z] [-a previous.cal] [-m TAC]
|
||||||
|
/// [-xC pct]... -o out.cal CAL_basename`.
|
||||||
|
public static func build(config: PrintcalConfig) throws -> [String] {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.ti3Basename)
|
||||||
|
guard !cleanBasename.isEmpty else {
|
||||||
|
throw PrintcalArgError.invalidBasename(config.ti3Basename)
|
||||||
|
}
|
||||||
|
|
||||||
|
var args: [String] = ["-v", "-e"]
|
||||||
|
|
||||||
|
if config.noInkLimit {
|
||||||
|
args.append("-I")
|
||||||
|
}
|
||||||
|
if config.verify {
|
||||||
|
args.append("-z")
|
||||||
|
}
|
||||||
|
if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!previous.isEmpty {
|
||||||
|
args.append(contentsOf: ["-a", previous])
|
||||||
|
}
|
||||||
|
if let tac = config.totalInkLimit, tac > 0 {
|
||||||
|
args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
|
||||||
|
} else if let tac = config.totalInkLimit {
|
||||||
|
throw PrintcalArgError.invalidTotalInkLimit(tac)
|
||||||
|
}
|
||||||
|
|
||||||
|
for limit in config.channelLimits {
|
||||||
|
guard (0...100).contains(limit.percent) else {
|
||||||
|
throw PrintcalArgError.invalidPerChannelLimit(limit.channel, limit.percent)
|
||||||
|
}
|
||||||
|
args.append(contentsOf: ["-x\(limit.channel)", String(format: "%.1f", limit.percent)])
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !config.outputURL.path.isEmpty else {
|
||||||
|
throw PrintcalArgError.invalidOutputPath
|
||||||
|
}
|
||||||
|
args.append(contentsOf: ["-o", config.outputURL.path])
|
||||||
|
|
||||||
|
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
|
||||||
|
args.append(calBasename)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Stage 0 calibration dashboard (issue #29, docs/07).
|
||||||
|
struct CalibrationView: View {
|
||||||
|
@Bindable var model: CalibrationViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("Calibrate Printer")
|
||||||
|
.font(.title2.bold())
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
|
||||||
|
Form {
|
||||||
|
Section("Wedge Settings") {
|
||||||
|
Picker("Colour Space", selection: $model.colourSpace) {
|
||||||
|
Text("RGB").tag(ColourSpace.rgb)
|
||||||
|
Text("CMYK").tag(ColourSpace.cmyk)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Steps per channel")
|
||||||
|
Spacer()
|
||||||
|
TextField("", value: $model.steps, format: .number)
|
||||||
|
.frame(width: 60)
|
||||||
|
.accessibilityIdentifier("calSteps")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("White patches")
|
||||||
|
Spacer()
|
||||||
|
TextField("", value: $model.whitePatches, format: .number)
|
||||||
|
.frame(width: 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.colourSpace == .cmyk {
|
||||||
|
HStack {
|
||||||
|
Text("Ink-limit exploration")
|
||||||
|
Spacer()
|
||||||
|
TextField("", text: $model.inkLimit)
|
||||||
|
.frame(width: 60)
|
||||||
|
.accessibilityIdentifier("calInkExplore")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Workflow") {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Generate Target") { model.generateTarget() }
|
||||||
|
.accessibilityIdentifier("btnCalGenerate")
|
||||||
|
.disabled(!model.canGenerate)
|
||||||
|
|
||||||
|
Button("Create Layout & Print") { model.createLayout() }
|
||||||
|
.accessibilityIdentifier("btnCalLayout")
|
||||||
|
.disabled(!model.canGenerate)
|
||||||
|
|
||||||
|
Button("Measure") { model.measureChart() }
|
||||||
|
.accessibilityIdentifier("btnCalMeasure")
|
||||||
|
.disabled(model.calibrationTi3URL == nil)
|
||||||
|
|
||||||
|
Button("Compute Curves") { model.computeCurves() }
|
||||||
|
.accessibilityIdentifier("btnCalCompute")
|
||||||
|
.disabled(!model.canCompute)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let url = model.computedCalURL {
|
||||||
|
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
||||||
|
.onChange(of: model.applyToProfile) { model.updateApplyToProfile() }
|
||||||
|
.accessibilityIdentifier("calApplyToggle")
|
||||||
|
|
||||||
|
Text("Loaded: \(url.lastPathComponent)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !model.calibrationLog.isEmpty {
|
||||||
|
Section("Log") {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
ForEach(model.calibrationLog, id: \.self) { line in
|
||||||
|
Text(line)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minHeight: 80, maxHeight: 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = model.lastError {
|
||||||
|
Section {
|
||||||
|
Text(error)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Return to Profiling") { model.returnToProfiling() }
|
||||||
|
.accessibilityIdentifier("btnCalReturn")
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Stage 0 calibration workflow: generate wedge, print, measure, and
|
||||||
|
/// compute `.cal` curves.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class CalibrationViewModel {
|
||||||
|
|
||||||
|
let workflow: TargetWorkflowViewModel
|
||||||
|
let profile: ProfileWorkflowViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
|
||||||
|
// MARK: - Form state
|
||||||
|
|
||||||
|
var colourSpace: ColourSpace = .cmyk
|
||||||
|
var steps: Int = 21
|
||||||
|
var whitePatches: Int = 4
|
||||||
|
var includeNeutralEmphasis: Bool = false
|
||||||
|
var inkLimit: String = "320"
|
||||||
|
var applyToProfile: Bool = false
|
||||||
|
var computedCalURL: URL?
|
||||||
|
var calibrationLog: [String] = []
|
||||||
|
var isGenerating = false
|
||||||
|
var isComputing = false
|
||||||
|
var lastError: String?
|
||||||
|
|
||||||
|
private var originalBasename: String = ""
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self.profile = profile
|
||||||
|
self.environment = environment
|
||||||
|
}
|
||||||
|
|
||||||
|
private var wizard: WizardViewModel { workflow.wizard }
|
||||||
|
|
||||||
|
// MARK: - Derived
|
||||||
|
|
||||||
|
var canGenerate: Bool {
|
||||||
|
!wizard.basename.isEmpty && wizard.effectiveWorkingDirectory != nil && !isGenerating
|
||||||
|
}
|
||||||
|
|
||||||
|
var canCompute: Bool {
|
||||||
|
calibrationTi3URL != nil && !isComputing
|
||||||
|
}
|
||||||
|
|
||||||
|
var calibrationTi3URL: URL? {
|
||||||
|
guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
|
||||||
|
return cwd.appendingPathComponent("\(calBasename).ti3")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var calBasename: String {
|
||||||
|
originalBasename.isEmpty ? "CAL_\(wizard.basename)" : "CAL_\(originalBasename)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var calOutputURL: URL? {
|
||||||
|
guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
|
||||||
|
return cwd.appendingPathComponent("\(calBasename).cal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generate calibration target
|
||||||
|
|
||||||
|
func generateTarget() {
|
||||||
|
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
|
||||||
|
originalBasename = wizard.basename
|
||||||
|
wizard.basename = calBasename
|
||||||
|
wizard.sessionMode = .calibration
|
||||||
|
|
||||||
|
isGenerating = true
|
||||||
|
calibrationLog = []
|
||||||
|
lastError = nil
|
||||||
|
|
||||||
|
let config = CalibrationTargenConfig(
|
||||||
|
colourSpace: colourSpace,
|
||||||
|
steps: steps,
|
||||||
|
whitePatches: whitePatches,
|
||||||
|
includeNeutralEmphasis: includeNeutralEmphasis,
|
||||||
|
inkLimit: inkLimitValue,
|
||||||
|
basename: originalBasename,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { self.isGenerating = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await self.environment.runner.runCalibrationTargen(config: config) { batch in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.calibrationLog.append(contentsOf: batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.wizard.refreshGating()
|
||||||
|
self.wizard.showNotice("Calibration target generated.")
|
||||||
|
self.wizard.go(to: .layOutPrint)
|
||||||
|
} catch {
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Calibration target failed: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
self.restoreProfileBasename()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout, print, measure
|
||||||
|
|
||||||
|
/// Hand off to the normal Stage 2/3 machinery using the `CAL_` basename.
|
||||||
|
/// After measurement, the user returns and presses Compute Curves.
|
||||||
|
func createLayout() {
|
||||||
|
wizard.sessionMode = .calibration
|
||||||
|
wizard.go(to: .layOutPrint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func measureChart() {
|
||||||
|
wizard.sessionMode = .calibration
|
||||||
|
wizard.go(to: .measure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compute curves
|
||||||
|
|
||||||
|
func computeCurves() {
|
||||||
|
guard canCompute,
|
||||||
|
let cwd = wizard.effectiveWorkingDirectory,
|
||||||
|
let outputURL = calOutputURL else { return }
|
||||||
|
|
||||||
|
// Collision check: the Argyll `printcal` exit error contains
|
||||||
|
// "already exists" when the user declines overwrite. We do not
|
||||||
|
// silently clobber.
|
||||||
|
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||||
|
lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first."
|
||||||
|
wizard.showNotice(lastError!, kind: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isComputing = true
|
||||||
|
calibrationLog = []
|
||||||
|
lastError = nil
|
||||||
|
|
||||||
|
let config = PrintcalConfig(
|
||||||
|
ti3Basename: calBasename,
|
||||||
|
workingDirectory: cwd,
|
||||||
|
outputURL: outputURL,
|
||||||
|
noInkLimit: false,
|
||||||
|
verify: false,
|
||||||
|
previousCalPath: nil,
|
||||||
|
totalInkLimit: inkLimitValue.map { Double($0) },
|
||||||
|
channelLimits: []
|
||||||
|
)
|
||||||
|
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { self.isComputing = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let url = try await self.environment.runner.runPrintcal(config: config) { batch in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.calibrationLog.append(contentsOf: batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.computedCalURL = url
|
||||||
|
self.profile.calibrationFile = url.path
|
||||||
|
self.profile.applyCalibration = self.applyToProfile
|
||||||
|
self.wizard.showNotice("Calibration curves computed.")
|
||||||
|
} catch {
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Calibration curve computation failed: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Apply toggle
|
||||||
|
|
||||||
|
func updateApplyToProfile() {
|
||||||
|
profile.applyCalibration = applyToProfile
|
||||||
|
if applyToProfile, let url = computedCalURL {
|
||||||
|
profile.calibrationFile = url.path
|
||||||
|
} else if applyToProfile {
|
||||||
|
// User toggled on before computing; keep the path if already set.
|
||||||
|
} else {
|
||||||
|
profile.applyCalibration = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func returnToProfiling() {
|
||||||
|
restoreProfileBasename()
|
||||||
|
wizard.sessionMode = .profile
|
||||||
|
wizard.go(to: .generate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreProfileBasename() {
|
||||||
|
if !originalBasename.isEmpty {
|
||||||
|
wizard.basename = originalBasename
|
||||||
|
originalBasename = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inkLimitValue: Int? {
|
||||||
|
guard colourSpace == .cmyk else { return nil }
|
||||||
|
return Int(inkLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,10 @@ struct RootView: View {
|
|||||||
.sheet(isPresented: $showingAbout) {
|
.sheet(isPresented: $showingAbout) {
|
||||||
AboutView { showingAbout = false }
|
AboutView { showingAbout = false }
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $workflow.wizard.showingGamutViewer) {
|
.sheet(isPresented: Binding(
|
||||||
|
get: { workflow.wizard.showingGamutViewer },
|
||||||
|
set: { workflow.wizard.showingGamutViewer = $0 }
|
||||||
|
)) {
|
||||||
GamutView(profileGamURL: workflow.wizard.gamutProfileURL)
|
GamutView(profileGamURL: workflow.wizard.gamutProfileURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +74,9 @@ struct RootView: View {
|
|||||||
Stage4View(model: workflow.profile)
|
Stage4View(model: workflow.profile)
|
||||||
case .verifyInstall:
|
case .verifyInstall:
|
||||||
Stage5View(model: workflow.profile)
|
Stage5View(model: workflow.profile)
|
||||||
default:
|
case .calibrate:
|
||||||
|
CalibrationView(model: workflow.calibration)
|
||||||
|
@unknown default:
|
||||||
StagePlaceholderView(stage: model.stage)
|
StagePlaceholderView(stage: model.stage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,14 +74,13 @@ struct SidebarView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
// Calibrate Printer (`#btnCalibratePrinter`). Disabled until
|
// Calibrate Printer (`#btnCalibratePrinter`).
|
||||||
// Stage 0 lands in issue #29; `#calStatusChip` likewise.
|
|
||||||
Button(action: { model.enterCalibration() }) {
|
Button(action: { model.enterCalibration() }) {
|
||||||
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.disabled(true)
|
.accessibilityIdentifier("btnCalibratePrinter")
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
|
||||||
Button(action: { model.openGamut(profileGamURL: workflow.profile.createdGamutURL) }) {
|
Button(action: { model.openGamut(profileGamURL: workflow.profile.createdGamutURL) }) {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ struct Stage1View: View {
|
|||||||
Button("Browse…") { workflow.browseForTargetFile() }
|
Button("Browse…") { workflow.browseForTargetFile() }
|
||||||
.accessibilityIdentifier("btnBrowse")
|
.accessibilityIdentifier("btnBrowse")
|
||||||
Button("Working Dir…") { workflow.browseForWorkingDirectory() }
|
Button("Working Dir…") { workflow.browseForWorkingDirectory() }
|
||||||
|
.accessibilityIdentifier("btnSelectWorkDir")
|
||||||
Button("Open Existing…") { workflow.openExistingTarget() }
|
Button("Open Existing…") { workflow.openExistingTarget() }
|
||||||
.accessibilityIdentifier("btnOpenExisting")
|
.accessibilityIdentifier("btnOpenExisting")
|
||||||
Button("Import Dataset…") { workflow.importMeasurementDataset() }
|
Button("Import Dataset…") { workflow.importMeasurementDataset() }
|
||||||
|
|||||||
@@ -123,8 +123,10 @@ final class TargetWorkflowViewModel {
|
|||||||
/// across stage switches and can observe settings changes.
|
/// across stage switches and can observe settings changes.
|
||||||
var measurement: MeasurementWorkflowViewModel
|
var measurement: MeasurementWorkflowViewModel
|
||||||
/// Stage 4/5 profile workflow, owned at the app level so it persists
|
/// Stage 4/5 profile workflow, owned at the app level so it persists
|
||||||
/// across stage switches and can apply preset values.
|
/// across stage switches and can observe preset values.
|
||||||
var profile: ProfileWorkflowViewModel
|
var profile: ProfileWorkflowViewModel
|
||||||
|
/// Stage 0 calibration workflow.
|
||||||
|
var calibration: CalibrationViewModel!
|
||||||
|
|
||||||
init(environment: AppEnvironment = .live()) {
|
init(environment: AppEnvironment = .live()) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
@@ -137,6 +139,12 @@ final class TargetWorkflowViewModel {
|
|||||||
wizard: wizard,
|
wizard: wizard,
|
||||||
environment: environment
|
environment: environment
|
||||||
)
|
)
|
||||||
|
self.calibration = nil
|
||||||
|
self.calibration = CalibrationViewModel(
|
||||||
|
workflow: self,
|
||||||
|
profile: self.profile,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
reloadPresets()
|
reloadPresets()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +347,8 @@ final class TargetWorkflowViewModel {
|
|||||||
customLabel: labelIsCustom ? customLabel : nil,
|
customLabel: labelIsCustom ? customLabel : nil,
|
||||||
basename: wizard.basename,
|
basename: wizard.basename,
|
||||||
metadata: labelMetadata),
|
metadata: labelMetadata),
|
||||||
|
calibrationFile: profile.applyCalibration ? profile.calibrationFile : nil,
|
||||||
|
calibrationEmbedOnly: false,
|
||||||
basename: wizard.basename,
|
basename: wizard.basename,
|
||||||
workingDirectory: wizard.effectiveWorkingDirectory
|
workingDirectory: wizard.effectiveWorkingDirectory
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
@Suite("ArgyllRunner Calibration")
|
||||||
|
struct ArgyllRunnerCalibrationTests {
|
||||||
|
|
||||||
|
private func makeRunner() -> ArgyllRunner {
|
||||||
|
let binDir = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
|
||||||
|
return ArgyllRunner(
|
||||||
|
processManager: .shared,
|
||||||
|
binaryResolver: BinaryResolver(overrideDir: binDir)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTestDir() throws -> URL {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("calibration-test-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Calibration targen produces CAL_*.ti1")
|
||||||
|
func calibrationTargenProducesTi1() async throws {
|
||||||
|
let testRoot = try makeTestDir()
|
||||||
|
let runner = makeRunner()
|
||||||
|
let config = CalibrationTargenConfig(
|
||||||
|
colourSpace: .rgb,
|
||||||
|
steps: 21,
|
||||||
|
basename: "demo",
|
||||||
|
workingDirectory: testRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
let url = try await runner.runCalibrationTargen(config: config)
|
||||||
|
|
||||||
|
#expect(url.lastPathComponent == "CAL_demo.ti1")
|
||||||
|
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("printcal captured run creates .cal")
|
||||||
|
func printcalProducesCal() async throws {
|
||||||
|
let testRoot = try makeTestDir()
|
||||||
|
let runner = makeRunner()
|
||||||
|
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
||||||
|
let config = PrintcalConfig(
|
||||||
|
ti3Basename: "CAL_demo",
|
||||||
|
workingDirectory: testRoot,
|
||||||
|
outputURL: output
|
||||||
|
)
|
||||||
|
|
||||||
|
let url = try await runner.runPrintcal(config: config)
|
||||||
|
|
||||||
|
#expect(url.lastPathComponent == "CAL_demo.cal")
|
||||||
|
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("printcal failure throws printcalFailed")
|
||||||
|
func printcalFailureThrows() async throws {
|
||||||
|
let testRoot = try makeTestDir()
|
||||||
|
let runner = makeRunner()
|
||||||
|
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
||||||
|
let config = PrintcalConfig(
|
||||||
|
ti3Basename: "CAL_demo",
|
||||||
|
workingDirectory: testRoot,
|
||||||
|
outputURL: output
|
||||||
|
)
|
||||||
|
|
||||||
|
setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1)
|
||||||
|
defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") }
|
||||||
|
|
||||||
|
await #expect(throws: (any Error).self) {
|
||||||
|
_ = try await runner.runPrintcal(config: config)
|
||||||
|
}
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
@Suite("CalibrationStore")
|
||||||
|
struct CalibrationStoreTests {
|
||||||
|
|
||||||
|
private static let sampleCal = """
|
||||||
|
CTI3
|
||||||
|
DESCRIPTOR "Test printer"
|
||||||
|
COLOR_REP "RGB"
|
||||||
|
DEVICE_CLASS "OUTPUT"
|
||||||
|
MAX_TAC "300"
|
||||||
|
NUMBER_OF_FIELDS 5
|
||||||
|
NUMBER_OF_SETS 3
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID INPUT_VALUE R G B
|
||||||
|
END_DATA_FORMAT
|
||||||
|
BEGIN_DATA
|
||||||
|
1 0 0 0 0
|
||||||
|
2 128 64 64 64
|
||||||
|
3 255 255 255 255
|
||||||
|
END_DATA
|
||||||
|
"""
|
||||||
|
|
||||||
|
@Test("Loads metadata and curves from .cal")
|
||||||
|
func parseCal() async throws {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("test_\(UUID().uuidString).cal")
|
||||||
|
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let store = CalibrationStore(staleDays: 30)
|
||||||
|
try await store.load(url: url)
|
||||||
|
|
||||||
|
let data = await store.data
|
||||||
|
#expect(data?.colorRep == "RGB")
|
||||||
|
#expect(data?.descriptor == "Test printer")
|
||||||
|
#expect(data?.maxTac == 300)
|
||||||
|
#expect(data?.curves.count == 3)
|
||||||
|
|
||||||
|
let r = data?.curves.first { $0.channel == "R" }
|
||||||
|
#expect(r?.output == [0, 64, 255])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Staleness is true for a very old calibration")
|
||||||
|
func staleCalibration() async throws {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("stale_\(UUID().uuidString).cal")
|
||||||
|
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let store = CalibrationStore(staleDays: 0)
|
||||||
|
try await store.load(url: url)
|
||||||
|
let stale = await store.isStale(comparedTo: "Other")
|
||||||
|
#expect(stale == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Printer mismatch is flagged as stale")
|
||||||
|
func printerMismatch() async throws {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("mismatch_\(UUID().uuidString).cal")
|
||||||
|
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let store = CalibrationStore(staleDays: 9999)
|
||||||
|
try await store.load(url: url)
|
||||||
|
await store.setPrinterName("Printer A")
|
||||||
|
let stale = await store.isStale(comparedTo: "Printer B")
|
||||||
|
#expect(stale == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
@Suite("CalibrationTargenArgs")
|
||||||
|
struct CalibrationTargenArgsTests {
|
||||||
|
|
||||||
|
@Test("RGB baseline")
|
||||||
|
func rgbBaseline() throws {
|
||||||
|
let config = CalibrationTargenConfig(
|
||||||
|
colourSpace: .rgb,
|
||||||
|
steps: 21,
|
||||||
|
whitePatches: 4,
|
||||||
|
basename: "demo",
|
||||||
|
workingDirectory: URL(fileURLWithPath: "/tmp")
|
||||||
|
)
|
||||||
|
let args = try CalibrationTargenArgs.build(config: config)
|
||||||
|
#expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("CMYK baseline with ink limit and neutral emphasis")
|
||||||
|
func cmykWithOptions() throws {
|
||||||
|
let config = CalibrationTargenConfig(
|
||||||
|
colourSpace: .cmyk,
|
||||||
|
steps: 25,
|
||||||
|
whitePatches: 4,
|
||||||
|
includeNeutralEmphasis: true,
|
||||||
|
inkLimit: 320,
|
||||||
|
basename: "printer",
|
||||||
|
workingDirectory: URL(fileURLWithPath: "/tmp")
|
||||||
|
)
|
||||||
|
let args = try CalibrationTargenArgs.build(config: config)
|
||||||
|
#expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Rejects out-of-range steps")
|
||||||
|
func rejectsBadSteps() {
|
||||||
|
let config = CalibrationTargenConfig(steps: 5, basename: "demo")
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try CalibrationTargenArgs.build(config: config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Rejects bad CMYK ink limit")
|
||||||
|
func rejectsBadInkLimit() {
|
||||||
|
let config = CalibrationTargenConfig(
|
||||||
|
colourSpace: .cmyk,
|
||||||
|
inkLimit: 500,
|
||||||
|
basename: "demo"
|
||||||
|
)
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try CalibrationTargenArgs.build(config: config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Does not double-prefix an existing CAL_ basename")
|
||||||
|
func noDoublePrefix() throws {
|
||||||
|
let config = CalibrationTargenConfig(basename: "CAL_test")
|
||||||
|
let args = try CalibrationTargenArgs.build(config: config)
|
||||||
|
#expect(args.last == "CAL_test")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
@Suite("PrintcalArgs")
|
||||||
|
struct PrintcalArgsTests {
|
||||||
|
|
||||||
|
private let tmp = URL(fileURLWithPath: "/tmp/out.cal")
|
||||||
|
|
||||||
|
@Test("Default printcal argv")
|
||||||
|
func defaults() throws {
|
||||||
|
let config = PrintcalConfig(
|
||||||
|
ti3Basename: "CAL_demo",
|
||||||
|
outputURL: tmp
|
||||||
|
)
|
||||||
|
let args = try PrintcalArgs.build(config: config)
|
||||||
|
#expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("All options and channel limits")
|
||||||
|
func allOptions() throws {
|
||||||
|
let config = PrintcalConfig(
|
||||||
|
ti3Basename: "demo",
|
||||||
|
outputURL: tmp,
|
||||||
|
noInkLimit: true,
|
||||||
|
verify: true,
|
||||||
|
previousCalPath: "/tmp/old.cal",
|
||||||
|
totalInkLimit: 280,
|
||||||
|
channelLimits: [
|
||||||
|
PrintcalChannelLimit(channel: "C", percent: 95),
|
||||||
|
PrintcalChannelLimit(channel: "M", percent: 90)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
let args = try PrintcalArgs.build(config: config)
|
||||||
|
#expect(args == [
|
||||||
|
"-v", "-e",
|
||||||
|
"-I", "-z",
|
||||||
|
"-a", "/tmp/old.cal",
|
||||||
|
"-m", "280.0",
|
||||||
|
"-xC", "95.0",
|
||||||
|
"-xM", "90.0",
|
||||||
|
"-o", "/tmp/out.cal",
|
||||||
|
"CAL_demo"
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Rejects invalid per-channel limit")
|
||||||
|
func rejectsBadChannelLimit() {
|
||||||
|
let config = PrintcalConfig(
|
||||||
|
ti3Basename: "demo",
|
||||||
|
outputURL: tmp,
|
||||||
|
channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)]
|
||||||
|
)
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try PrintcalArgs.build(config: config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Mock printcal for Stage 0 calibration tests. Creates the .cal named by
|
||||||
|
# the -o argument in the process working directory. Exit code overridable
|
||||||
|
# via ICCERY_MOCK_PRINTCAL_EXIT.
|
||||||
|
output=""
|
||||||
|
basename=""
|
||||||
|
prev=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$prev" = "-o" ]; then
|
||||||
|
output="$arg"
|
||||||
|
fi
|
||||||
|
prev="$arg"
|
||||||
|
done
|
||||||
|
# If no -o, derive from the last positional argument.
|
||||||
|
if [ -z "$output" ]; then
|
||||||
|
for arg in "$@"; do basename="$arg"; done
|
||||||
|
output="$basename.cal"
|
||||||
|
fi
|
||||||
|
if [ "${ICCERY_MOCK_PRINTCAL_EXIT:-0}" -ne 0 ]; then
|
||||||
|
echo "mock printcal failure" >&2
|
||||||
|
exit "$ICCERY_MOCK_PRINTCAL_EXIT"
|
||||||
|
fi
|
||||||
|
echo "ideal power 1.0, device power 0.8"
|
||||||
|
touch "$output"
|
||||||
|
exit 0
|
||||||
@@ -44,7 +44,10 @@ final class Milestone6CGATSUITests: XCTestCase {
|
|||||||
/// Must fail if import presents a save panel or a `.ti1` filter.
|
/// Must fail if import presents a save panel or a `.ti1` filter.
|
||||||
func testImportUsesOpenPanelNotSaveTi1() throws {
|
func testImportUsesOpenPanelNotSaveTi1() throws {
|
||||||
app.launch()
|
app.launch()
|
||||||
app.activate()
|
|
||||||
|
// CGATS import needs a working directory; the env provides one.
|
||||||
|
XCTAssertTrue(app.buttons["btnSelectWorkDir"].waitForExistence(timeout: 5))
|
||||||
|
app.buttons["btnSelectWorkDir"].tap()
|
||||||
|
|
||||||
XCTAssertTrue(app.buttons["btn-import-dataset"].waitForExistence(timeout: 10))
|
XCTAssertTrue(app.buttons["btn-import-dataset"].waitForExistence(timeout: 10))
|
||||||
app.buttons["btn-import-dataset"].click()
|
app.buttons["btn-import-dataset"].click()
|
||||||
@@ -54,8 +57,7 @@ final class Milestone6CGATSUITests: XCTestCase {
|
|||||||
XCTAssertFalse(savePanel.exists, "Import must use an open panel, never a save panel.")
|
XCTAssertFalse(savePanel.exists, "Import must use an open panel, never a save panel.")
|
||||||
|
|
||||||
// The dataset should be accepted and the user should advance to Stage 4.
|
// The dataset should be accepted and the user should advance to Stage 4.
|
||||||
_ = app.otherElements["stage-4"].waitForExistence(timeout: 10)
|
XCTAssertTrue(app.staticTexts["stage4TargetBasename"].waitForExistence(timeout: 10))
|
||||||
XCTAssertTrue(app.otherElements["stage-4"].exists)
|
|
||||||
|
|
||||||
// The canonical .ti3 should be written next to the source file.
|
// The canonical .ti3 should be written next to the source file.
|
||||||
let ti3URL = testRoot.appendingPathComponent("imported.ti3")
|
let ti3URL = testRoot.appendingPathComponent("imported.ti3")
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Milestone 6 — Stage 0 printer calibration UI acceptance.
|
||||||
|
///
|
||||||
|
/// Uses the mock Argyll fixtures and UI-test environment flags so no real
|
||||||
|
/// instrument, printer, or modal file panel is required.
|
||||||
|
@MainActor
|
||||||
|
final class Milestone6CalibrationUITests: XCTestCase {
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private var testWorkDir: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
|
||||||
|
testWorkDir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("cal-ui-test-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: testWorkDir,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let binaryDir = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Fixtures/bin")
|
||||||
|
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchEnvironment = [
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_ARGYLL_BINARY_DIR": binaryDir.path,
|
||||||
|
"ICCERY_TEST_WORKDIR": testWorkDir.path
|
||||||
|
]
|
||||||
|
app.launch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
if let testWorkDir {
|
||||||
|
try? FileManager.default.removeItem(at: testWorkDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCalibrationDashboardOpensAndCanGenerate() throws {
|
||||||
|
// Set up a target and working directory on Stage 1.
|
||||||
|
let basename = app.textFields["targetBasename"]
|
||||||
|
XCTAssertTrue(basename.waitForExistence(timeout: 5))
|
||||||
|
basename.tap()
|
||||||
|
basename.typeText("DemoTarget")
|
||||||
|
|
||||||
|
let workDir = app.buttons["btnSelectWorkDir"]
|
||||||
|
XCTAssertTrue(workDir.waitForExistence(timeout: 5))
|
||||||
|
workDir.tap()
|
||||||
|
|
||||||
|
let generate = app.buttons["btnGenerate"]
|
||||||
|
XCTAssertTrue(generate.waitForExistence(timeout: 5))
|
||||||
|
generate.tap()
|
||||||
|
|
||||||
|
// Open the calibration dashboard once Stage 2 is reached.
|
||||||
|
let advance = app.buttons["btnAdvanceToStage3"]
|
||||||
|
XCTAssertTrue(advance.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
|
let calButton = app.buttons["btnCalibratePrinter"]
|
||||||
|
XCTAssertTrue(calButton.waitForExistence(timeout: 5))
|
||||||
|
calButton.tap()
|
||||||
|
|
||||||
|
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
// Start the calibration wedge. The mock targen will create CAL_DemoTarget.ti1.
|
||||||
|
let calGenerate = app.buttons["btnCalGenerate"]
|
||||||
|
XCTAssertTrue(calGenerate.waitForExistence(timeout: 5))
|
||||||
|
calGenerate.tap()
|
||||||
|
|
||||||
|
// After generation the wizard should advance to Stage 2 (layout) because
|
||||||
|
// a CAL_ .ti1 now exists and the session is in calibration mode.
|
||||||
|
let layout = app.buttons["btnCreateLayout"]
|
||||||
|
XCTAssertTrue(layout.waitForExistence(timeout: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user