Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
153b6194a3 | ||
|
|
629a1fce1d | ||
|
|
460b0a1ffa |
@@ -0,0 +1,372 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors that can occur while parsing CGATS-like data.
|
||||||
|
public enum CGATSParseError: Error, Equatable {
|
||||||
|
case emptyFile
|
||||||
|
case missingBeginDataFormat
|
||||||
|
case missingEndDataFormat
|
||||||
|
case missingBeginData
|
||||||
|
case missingEndData
|
||||||
|
case missingNumberOfFields
|
||||||
|
case missingNumberOfSets
|
||||||
|
case unknownFieldName(String)
|
||||||
|
case malformedRow(line: Int, reason: String)
|
||||||
|
case nonNumericValue(field: String, value: String, line: Int)
|
||||||
|
case outOfBoundsValue(field: String, value: Double, line: Int)
|
||||||
|
case implausibleValue(field: String, value: Double, line: Int)
|
||||||
|
case incorrectArity(line: Int, expected: Int, got: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of a CGATS dataset, keyed by canonical field name.
|
||||||
|
public struct CGATSSample: Sendable, Equatable {
|
||||||
|
public var id: String
|
||||||
|
public var loc: String?
|
||||||
|
public var values: [String: String]
|
||||||
|
|
||||||
|
public init(id: String, loc: String? = nil, values: [String: String] = [:]) {
|
||||||
|
self.id = id
|
||||||
|
self.loc = loc
|
||||||
|
self.values = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed CGATS / CTI3 / CSV dataset.
|
||||||
|
public struct CGATSDataset: Sendable, Equatable {
|
||||||
|
public var format: CGATSFormat
|
||||||
|
public var keywords: [String: String]
|
||||||
|
public var fieldNames: [String]
|
||||||
|
public var samples: [CGATSSample]
|
||||||
|
public var colorRep: String?
|
||||||
|
public var deviceClass: String?
|
||||||
|
public var targetInstrument: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
format: CGATSFormat,
|
||||||
|
keywords: [String: String] = [:],
|
||||||
|
fieldNames: [String] = [],
|
||||||
|
samples: [CGATSSample] = [],
|
||||||
|
colorRep: String? = nil,
|
||||||
|
deviceClass: String? = nil,
|
||||||
|
targetInstrument: String? = nil
|
||||||
|
) {
|
||||||
|
self.format = format
|
||||||
|
self.keywords = keywords
|
||||||
|
self.fieldNames = fieldNames
|
||||||
|
self.samples = samples
|
||||||
|
self.colorRep = colorRep
|
||||||
|
self.deviceClass = deviceClass
|
||||||
|
self.targetInstrument = targetInstrument
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CGATSFormat: String, Sendable, Equatable {
|
||||||
|
case cti3 = "CTI3"
|
||||||
|
case cgats17 = "CGATS.17"
|
||||||
|
case csv = "CSV"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parser for CGATS.17, CTI3, ISO28178, and simple CSV datasets.
|
||||||
|
public enum CGATSParser {
|
||||||
|
|
||||||
|
/// Parse the contents of a CGATS-like file.
|
||||||
|
public static func parse(
|
||||||
|
_ contents: String,
|
||||||
|
sourceURL: URL? = nil
|
||||||
|
) throws(CGATSParseError) -> CGATSDataset {
|
||||||
|
guard !contents.isEmpty else { throw .emptyFile }
|
||||||
|
|
||||||
|
let ext = sourceURL?.pathExtension.lowercased() ?? ""
|
||||||
|
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.hasPrefix("SAMPLE_ID,")
|
||||||
|
|
||||||
|
let (format, lines) = try preprocess(contents, isCSV: isCSV)
|
||||||
|
|
||||||
|
var formatStart: Int?
|
||||||
|
var formatEnd: Int?
|
||||||
|
var dataStart: Int?
|
||||||
|
var dataEnd: Int?
|
||||||
|
var keywords = [String: String]()
|
||||||
|
|
||||||
|
for (index, line) in lines.enumerated() {
|
||||||
|
switch Self.normalizedKeyword(line) {
|
||||||
|
case "BEGIN_DATA_FORMAT": formatStart = index
|
||||||
|
case "END_DATA_FORMAT": formatEnd = index
|
||||||
|
case "BEGIN_DATA": dataStart = index
|
||||||
|
case "END_DATA": dataEnd = index
|
||||||
|
default:
|
||||||
|
if let (key, value) = parseKeyword(line) {
|
||||||
|
keywords[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
|
||||||
|
throw .missingBeginDataFormat
|
||||||
|
}
|
||||||
|
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
|
||||||
|
throw .missingBeginData
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawFieldNames = splitFields(lines[formatStart + 1])
|
||||||
|
let fieldNames = rawFieldNames.map { canonicalFieldName($0) }
|
||||||
|
|
||||||
|
if let numberOfFields = keywords["NUMBER_OF_FIELDS"].flatMap(Int.init),
|
||||||
|
numberOfFields != fieldNames.count {
|
||||||
|
// Warn only; the data format line is the source of truth.
|
||||||
|
} else if keywords["NUMBER_OF_FIELDS"] == nil {
|
||||||
|
// Optional header; do not fail.
|
||||||
|
}
|
||||||
|
|
||||||
|
if let numberOfSets = keywords["NUMBER_OF_SETS"].flatMap(Int.init),
|
||||||
|
numberOfSets != dataEnd - dataStart - 1 {
|
||||||
|
// Warn only; the actual rows are the source of truth.
|
||||||
|
} else if keywords["NUMBER_OF_SETS"] == nil {
|
||||||
|
// Optional header; do not fail.
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RawSample {
|
||||||
|
var id: String
|
||||||
|
var loc: String?
|
||||||
|
var numbers: [String: Double] = [:]
|
||||||
|
var strings: [String: String] = [:]
|
||||||
|
var lineIndex: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawSamples = [RawSample]()
|
||||||
|
var groupMax: [String: Double] = [:]
|
||||||
|
|
||||||
|
for offset in 1...(dataEnd - dataStart - 1) {
|
||||||
|
let lineIndex = dataStart + offset
|
||||||
|
let rawRow = splitFields(lines[lineIndex])
|
||||||
|
guard rawRow.count == fieldNames.count else {
|
||||||
|
throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
|
||||||
|
for (i, name) in fieldNames.enumerated() {
|
||||||
|
let raw = stripInlineComment(rawRow[i])
|
||||||
|
if isNumericField(name) {
|
||||||
|
let cleaned = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
if let number = parseNumber(cleaned) {
|
||||||
|
sample.numbers[name] = number
|
||||||
|
if let group = deviceGroup(name) {
|
||||||
|
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
|
||||||
|
}
|
||||||
|
} else if !cleaned.isEmpty {
|
||||||
|
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sample.strings[name] = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sample.id = sample.strings["SAMPLE_ID"] ?? sample.numbers["SAMPLE_ID"].map { String(format: "%.0f", $0) } ?? String(offset)
|
||||||
|
sample.loc = sample.strings["SAMPLE_LOC"]
|
||||||
|
rawSamples.append(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
var samples = [CGATSSample]()
|
||||||
|
for raw in rawSamples {
|
||||||
|
var values = raw.strings
|
||||||
|
for (name, number) in raw.numbers {
|
||||||
|
var scaled = number
|
||||||
|
if let group = deviceGroup(name), let maxValue = groupMax[group], maxValue > 100 {
|
||||||
|
scaled = number / 2.55
|
||||||
|
}
|
||||||
|
values[name] = validateValue(scaled, field: name, line: raw.lineIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sample = CGATSSample(id: raw.id, loc: raw.loc, values: values)
|
||||||
|
// Keep lookups by canonical keys, but also preserve original aliases.
|
||||||
|
let rawRow = splitFields(lines[raw.lineIndex])
|
||||||
|
for (i, rawName) in rawFieldNames.enumerated() {
|
||||||
|
let canonical = canonicalFieldName(rawName)
|
||||||
|
if canonical != rawName {
|
||||||
|
sample.values[rawName] = rawRow[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
samples.append(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
let colorRep = keywords["COLOR_REP"] ?? inferColorRep(fieldNames: fieldNames)
|
||||||
|
let deviceClass = keywords["DEVICE_CLASS"] ?? inferDeviceClass(fieldNames: fieldNames)
|
||||||
|
|
||||||
|
return CGATSDataset(
|
||||||
|
format: format,
|
||||||
|
keywords: keywords,
|
||||||
|
fieldNames: fieldNames,
|
||||||
|
samples: samples,
|
||||||
|
colorRep: colorRep,
|
||||||
|
deviceClass: deviceClass,
|
||||||
|
targetInstrument: keywords["TARGET_INSTRUMENT"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse from a URL (throws as `Error` for public callers).
|
||||||
|
public static func parse(url: URL) throws -> CGATSDataset {
|
||||||
|
let contents = try String(contentsOf: url)
|
||||||
|
return try parse(contents, sourceURL: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func preprocess(
|
||||||
|
_ contents: String,
|
||||||
|
isCSV: Bool
|
||||||
|
) throws(CGATSParseError) -> (CGATSFormat, [String]) {
|
||||||
|
let allLines = contents.components(separatedBy: .newlines)
|
||||||
|
var lines = [String]()
|
||||||
|
|
||||||
|
var format: CGATSFormat?
|
||||||
|
for var line in allLines {
|
||||||
|
line = stripComment(line)
|
||||||
|
line = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !line.isEmpty else { continue }
|
||||||
|
|
||||||
|
if format == nil {
|
||||||
|
if line.hasPrefix("CTI3") { format = .cti3 }
|
||||||
|
else if line.hasPrefix("CGATS.17") { format = .cgats17 }
|
||||||
|
else if isCSV { format = .csv }
|
||||||
|
}
|
||||||
|
|
||||||
|
if line == "BEGIN_DATA_FORMAT" || line == "END_DATA_FORMAT" ||
|
||||||
|
line == "BEGIN_DATA" || line == "END_DATA" ||
|
||||||
|
(line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("END_DATA_FORMAT") ||
|
||||||
|
line.hasPrefix("BEGIN_DATA") || line.hasPrefix("END_DATA")) {
|
||||||
|
// These are exact keywords; keep them intact.
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.append(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !lines.isEmpty else { throw .emptyFile }
|
||||||
|
|
||||||
|
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
|
||||||
|
// structure so the boundary-based parser below can handle it.
|
||||||
|
if let format, format == .csv,
|
||||||
|
!lines.contains(where: { Self.normalizedKeyword($0) == "BEGIN_DATA_FORMAT" }) {
|
||||||
|
let header = lines[0]
|
||||||
|
let data = lines.dropFirst()
|
||||||
|
lines = [
|
||||||
|
"CTI3",
|
||||||
|
"BEGIN_DATA_FORMAT",
|
||||||
|
header,
|
||||||
|
"END_DATA_FORMAT",
|
||||||
|
"BEGIN_DATA"
|
||||||
|
] + Array(data) + [
|
||||||
|
"END_DATA"
|
||||||
|
]
|
||||||
|
return (.csv, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (format ?? .cti3, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stripComment(_ line: String) -> String {
|
||||||
|
if let range = line.range(of: "#") {
|
||||||
|
return String(line[..<range.lowerBound])
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stripInlineComment(_ token: String) -> String {
|
||||||
|
if let range = token.range(of: "#") {
|
||||||
|
return String(token[..<range.lowerBound]).trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func splitFields(_ line: String) -> [String] {
|
||||||
|
// CTI3/CGATS.17 use whitespace/tabs; CSV uses commas.
|
||||||
|
if line.contains(",") {
|
||||||
|
return line.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
}
|
||||||
|
return line.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseKeyword(_ line: String) -> (key: String, value: String)? {
|
||||||
|
// KEYWORD value or KEYWORD "value"
|
||||||
|
let tokens = splitFields(line)
|
||||||
|
guard let key = tokens.first else { return nil }
|
||||||
|
|
||||||
|
// Data-boundary keywords are not value keywords.
|
||||||
|
let boundaryKeys = Set([
|
||||||
|
"BEGIN_DATA_FORMAT", "END_DATA_FORMAT",
|
||||||
|
"BEGIN_DATA", "END_DATA"
|
||||||
|
])
|
||||||
|
guard !boundaryKeys.contains(key) else { return nil }
|
||||||
|
|
||||||
|
let rawValue = tokens.dropFirst().joined(separator: " ")
|
||||||
|
let value = rawValue.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||||
|
return (key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedKeyword(_ line: String) -> String {
|
||||||
|
line.uppercased().trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Field name normalization
|
||||||
|
|
||||||
|
private static func canonicalFieldName(_ raw: String) -> String {
|
||||||
|
let upper = raw.uppercased()
|
||||||
|
.replacingOccurrences(of: " ", with: "_")
|
||||||
|
.replacingOccurrences(of: "-", with: "_")
|
||||||
|
switch upper {
|
||||||
|
case "SAMPLE_ID", "ID": return "SAMPLE_ID"
|
||||||
|
case "SAMPLE_LOC", "LOC": return "SAMPLE_LOC"
|
||||||
|
case "SAMPLE_NAME": return "SAMPLE_ID"
|
||||||
|
case "LAB_L", "L*", "L_AB": return "LAB_L"
|
||||||
|
case "LAB_A", "A*", "A_AB": return "LAB_A"
|
||||||
|
case "LAB_B", "B*", "B_AB": return "LAB_B"
|
||||||
|
case "XYZ_X", "X": return "XYZ_X"
|
||||||
|
case "XYZ_Y", "Y": return "XYZ_Y"
|
||||||
|
case "XYZ_Z", "Z": return "XYZ_Z"
|
||||||
|
default: return upper
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isNumericField(_ name: String) -> Bool {
|
||||||
|
let numericNames: Set = [
|
||||||
|
"SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"
|
||||||
|
]
|
||||||
|
return !numericNames.contains(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseNumber(_ raw: String) -> Double? {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .decimal
|
||||||
|
return formatter.number(from: raw)?.doubleValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func validateValue(_ value: Double, field: String, line: Int) -> String {
|
||||||
|
var number = value
|
||||||
|
|
||||||
|
// Plausibility checks for Lab and XYZ.
|
||||||
|
if field == "LAB_L" { number = max(0, min(160, number)) }
|
||||||
|
if field == "LAB_A" || field == "LAB_B" { number = max(-128, min(128, number)) }
|
||||||
|
if field.hasPrefix("XYZ_") { number = max(0, min(200, number)) }
|
||||||
|
|
||||||
|
return String(format: "%.4f", number)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deviceGroup(_ name: String) -> String? {
|
||||||
|
if name.hasPrefix("RGB_") { return "RGB" }
|
||||||
|
if name.hasPrefix("CMYK_") { return "CMYK" }
|
||||||
|
if name.hasPrefix("DEVICE_") { return "DEVICE" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func inferColorRep(fieldNames: [String]) -> String? {
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "CMYK" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "RGB" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("LAB_") }) { return "LAB" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("XYZ_") }) { return "XYZ" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func inferDeviceClass(fieldNames: [String]) -> String? {
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "PRINTER" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "DISPLAY" }
|
||||||
|
return "OUTPUT"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Human-readable summary of an imported CGATS dataset.
|
||||||
|
public struct CGATSSummary: Sendable, Equatable {
|
||||||
|
public let patchCount: Int
|
||||||
|
public let colorSpace: String?
|
||||||
|
public let deviceClass: String?
|
||||||
|
public let hasSpectral: Bool
|
||||||
|
public let previewRows: [String]
|
||||||
|
|
||||||
|
public init(dataset: CGATSDataset, previewRowCount: Int = 4) {
|
||||||
|
self.patchCount = dataset.samples.count
|
||||||
|
self.colorSpace = dataset.colorRep
|
||||||
|
self.deviceClass = dataset.deviceClass
|
||||||
|
self.hasSpectral = dataset.fieldNames.contains { $0.hasPrefix("SPECTRAL_") }
|
||||||
|
self.previewRows = Array(dataset.samples.prefix(previewRowCount).map { sample in
|
||||||
|
"\(sample.id)" + (sample.loc.map { " \($0)" } ?? "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from writing a canonical `.ti3` dataset.
|
||||||
|
public enum CGATSWriterError: Error, Equatable {
|
||||||
|
case noSamples
|
||||||
|
case missingRequiredField(String)
|
||||||
|
case invalidValue(field: String, value: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a `CGATSDataset` to Argyll-consumable `.ti3` text.
|
||||||
|
public enum CGATSWriter {
|
||||||
|
|
||||||
|
public static func write(_ dataset: CGATSDataset) throws -> String {
|
||||||
|
guard !dataset.samples.isEmpty, !dataset.fieldNames.isEmpty else {
|
||||||
|
throw CGATSWriterError.noSamples
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = [String]()
|
||||||
|
|
||||||
|
// Header
|
||||||
|
lines.append(dataset.format.rawValue)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("DESCRIPTOR \"ICCery CGATS export\"")
|
||||||
|
if let colorRep = dataset.colorRep {
|
||||||
|
lines.append("COLOR_REP \"\(colorRep)\"")
|
||||||
|
}
|
||||||
|
if let deviceClass = dataset.deviceClass {
|
||||||
|
lines.append("DEVICE_CLASS \"\(deviceClass)\"")
|
||||||
|
}
|
||||||
|
if let instrument = dataset.targetInstrument {
|
||||||
|
lines.append("TARGET_INSTRUMENT \"\(instrument)\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.append("NUMBER_OF_FIELDS \(dataset.fieldNames.count)")
|
||||||
|
lines.append("NUMBER_OF_SETS \(dataset.samples.count)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("BEGIN_DATA_FORMAT")
|
||||||
|
lines.append(dataset.fieldNames.joined(separator: "\t"))
|
||||||
|
lines.append("END_DATA_FORMAT")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("BEGIN_DATA")
|
||||||
|
for sample in dataset.samples {
|
||||||
|
let row = try dataset.fieldNames.map { field in
|
||||||
|
guard let raw = sample.values[field], !raw.isEmpty else {
|
||||||
|
throw CGATSWriterError.missingRequiredField(field)
|
||||||
|
}
|
||||||
|
// Normalize numeric fields to a compact decimal.
|
||||||
|
if isNumeric(field) {
|
||||||
|
return normalizedNumber(raw)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
lines.append(row.joined(separator: "\t"))
|
||||||
|
}
|
||||||
|
lines.append("END_DATA")
|
||||||
|
|
||||||
|
return lines.joined(separator: "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ dataset: CGATSDataset, to url: URL) throws {
|
||||||
|
let text = try write(dataset)
|
||||||
|
try text.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func isNumeric(_ field: String) -> Bool {
|
||||||
|
let nonNumeric: Set = ["SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"]
|
||||||
|
return !nonNumeric.contains(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedNumber(_ raw: String) -> String {
|
||||||
|
guard let number = Double(raw) else { return raw }
|
||||||
|
if number == floor(number) {
|
||||||
|
return String(format: "%.0f", number)
|
||||||
|
}
|
||||||
|
return String(format: "%.4f", number)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,8 @@ enum UITestHooks {
|
|||||||
static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") }
|
static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") }
|
||||||
/// `select_directory` result (working-directory browse).
|
/// `select_directory` result (working-directory browse).
|
||||||
static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") }
|
static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") }
|
||||||
|
/// Dataset import file (`.ti3`, `.txt`, `.cgats`, `.csv`).
|
||||||
|
static var datasetImportURL: URL? { url("ICCERY_TEST_DATASET_IMPORT") }
|
||||||
/// Preset import file.
|
/// Preset import file.
|
||||||
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
|
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
|
||||||
/// Preset export destination.
|
/// Preset export destination.
|
||||||
|
|||||||
@@ -87,9 +87,8 @@ struct Stage1View: View {
|
|||||||
Button("Working Dir…") { workflow.browseForWorkingDirectory() }
|
Button("Working Dir…") { workflow.browseForWorkingDirectory() }
|
||||||
Button("Open Existing…") { workflow.openExistingTarget() }
|
Button("Open Existing…") { workflow.openExistingTarget() }
|
||||||
.accessibilityIdentifier("btnOpenExisting")
|
.accessibilityIdentifier("btnOpenExisting")
|
||||||
Button("Import Dataset…") { /* CGATS import — #94, later */ }
|
Button("Import Dataset…") { workflow.importMeasurementDataset() }
|
||||||
.accessibilityIdentifier("btn-import-dataset")
|
.accessibilityIdentifier("btn-import-dataset")
|
||||||
.disabled(true)
|
|
||||||
}
|
}
|
||||||
Text(workflow.targetDirectory?.path ?? "No working directory selected")
|
Text(workflow.targetDirectory?.path ?? "No working directory selected")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|||||||
@@ -243,6 +243,43 @@ final class TargetWorkflowViewModel {
|
|||||||
|
|
||||||
// MARK: - Issue 8: resume an existing target
|
// MARK: - Issue 8: resume an existing target
|
||||||
|
|
||||||
|
/// `#btn-import-dataset` — open a measured dataset, write a canonical
|
||||||
|
/// `.ti3` to the working directory, and set the target (issue #30).
|
||||||
|
func importMeasurementDataset() {
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.datasetImportURL
|
||||||
|
: fileDialogs.selectDatasetFile()
|
||||||
|
guard let url else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let dataset = try CGATSParser.parse(url: url)
|
||||||
|
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
|
||||||
|
wizard.showNotice("Choose a working directory before importing.", kind: .warning)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let stem = url.deletingPathExtension().lastPathComponent
|
||||||
|
let output = directory.appendingPathComponent("\(stem).ti3")
|
||||||
|
try CGATSWriter.write(dataset, to: output)
|
||||||
|
|
||||||
|
wizard.setTarget(basename: stem, workingDirectory: directory)
|
||||||
|
wizard.refreshGating()
|
||||||
|
wizard.showNotice("Imported \(dataset.samples.count) patches from \(url.lastPathComponent)")
|
||||||
|
|
||||||
|
if wizard.isUnlocked(.verifyInstall) {
|
||||||
|
wizard.go(to: .verifyInstall)
|
||||||
|
} else if wizard.isUnlocked(.buildProfile) {
|
||||||
|
wizard.go(to: .buildProfile)
|
||||||
|
} else {
|
||||||
|
wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning)
|
||||||
|
}
|
||||||
|
} catch let error as CGATSParseError {
|
||||||
|
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
|
||||||
|
} catch {
|
||||||
|
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `#btnOpenExisting` — open `.ti1`/`.ti2` (open dialog, #103).
|
/// `#btnOpenExisting` — open `.ti1`/`.ti2` (open dialog, #103).
|
||||||
/// `.ti1` → Stage 2; `.ti2` → Stage 3 with the resume notice, but
|
/// `.ti1` → Stage 2; `.ti2` → Stage 3 with the resume notice, but
|
||||||
/// only when the sibling `.ti1` exists so the artefact gate holds.
|
/// only when the sibling `.ti1` exists so the artefact gate holds.
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
@Suite("CGATS Parser & Writer")
|
||||||
|
struct CGATSParserTests {
|
||||||
|
|
||||||
|
private static let canonicalCTI3 = """
|
||||||
|
CTI3
|
||||||
|
DESCRIPTOR "Sample target"
|
||||||
|
COLOR_REP "RGB"
|
||||||
|
DEVICE_CLASS "DISPLAY"
|
||||||
|
NUMBER_OF_FIELDS 11
|
||||||
|
NUMBER_OF_SETS 2
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID\tSAMPLE_LOC\tRGB_R\tRGB_G\tRGB_B\tXYZ_X\tXYZ_Y\tXYZ_Z\tLAB_L\tLAB_A\tLAB_B
|
||||||
|
END_DATA_FORMAT
|
||||||
|
BEGIN_DATA
|
||||||
|
1\tA1\t50.0\t0.0\t0.0\t20.0\t10.0\t5.0\t50.0\t60.0\t30.0
|
||||||
|
2\tA2\t0.0\t50.0\t0.0\t10.0\t30.0\t5.0\t60.0\t-50.0\t40.0
|
||||||
|
END_DATA
|
||||||
|
"""
|
||||||
|
|
||||||
|
@Test("Parses CTI3 with canonical field names")
|
||||||
|
func parseCTI3() throws {
|
||||||
|
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
|
||||||
|
#expect(dataset.format == .cti3)
|
||||||
|
#expect(dataset.samples.count == 2)
|
||||||
|
#expect(dataset.colorRep == "RGB")
|
||||||
|
#expect(dataset.deviceClass == "DISPLAY")
|
||||||
|
#expect(dataset.samples[0].id == "1")
|
||||||
|
#expect(dataset.samples[0].loc == "A1")
|
||||||
|
#expect(dataset.samples[1].values["RGB_G"] == "50.0000")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Round-trips parse, write, reparse")
|
||||||
|
func roundTrip() throws {
|
||||||
|
let first = try CGATSParser.parse(Self.canonicalCTI3)
|
||||||
|
let text = try CGATSWriter.write(first)
|
||||||
|
let second = try CGATSParser.parse(text)
|
||||||
|
#expect(second.format == first.format)
|
||||||
|
#expect(second.samples.count == first.samples.count)
|
||||||
|
#expect(second.colorRep == first.colorRep)
|
||||||
|
#expect(second.deviceClass == first.deviceClass)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Parses CSV with comma delimiters")
|
||||||
|
func parseCSV() throws {
|
||||||
|
let csv = """
|
||||||
|
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
|
||||||
|
1,A1,50,0,0,20,10,5,50,60,30
|
||||||
|
2,A2,0,50,0,10,30,5,60,-50,40
|
||||||
|
"""
|
||||||
|
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
|
||||||
|
#expect(dataset.format == .csv)
|
||||||
|
#expect(dataset.samples.count == 2)
|
||||||
|
#expect(dataset.samples[0].values["RGB_R"] == "50.0000")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Converts 0-255 device values to 0-100")
|
||||||
|
func converts255To100() throws {
|
||||||
|
let rgb = """
|
||||||
|
CTI3
|
||||||
|
COLOR_REP RGB
|
||||||
|
NUMBER_OF_FIELDS 6
|
||||||
|
NUMBER_OF_SETS 1
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID RGB_R RGB_G RGB_B XYZ_X XYZ_Y
|
||||||
|
END_DATA_FORMAT
|
||||||
|
BEGIN_DATA
|
||||||
|
1 255 128 0 50 25
|
||||||
|
END_DATA
|
||||||
|
"""
|
||||||
|
let dataset = try CGATSParser.parse(rgb)
|
||||||
|
#expect(dataset.samples[0].values["RGB_R"] == "100.0000")
|
||||||
|
#expect(dataset.samples[0].values["RGB_G"] == "50.1961")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing")
|
||||||
|
func synthesizesMetadata() throws {
|
||||||
|
let cmyk = """
|
||||||
|
CTI3
|
||||||
|
NUMBER_OF_FIELDS 6
|
||||||
|
NUMBER_OF_SETS 1
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID CMYK_C CMYK_M CMYK_Y CMYK_K LAB_L
|
||||||
|
END_DATA_FORMAT
|
||||||
|
BEGIN_DATA
|
||||||
|
1 50 50 50 50 50
|
||||||
|
END_DATA
|
||||||
|
"""
|
||||||
|
let dataset = try CGATSParser.parse(cmyk)
|
||||||
|
#expect(dataset.colorRep == "CMYK")
|
||||||
|
#expect(dataset.deviceClass == "PRINTER")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Rejects empty file")
|
||||||
|
func rejectsEmpty() {
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try CGATSParser.parse("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Rejects malformed arity")
|
||||||
|
func rejectsArity() {
|
||||||
|
let bad = """
|
||||||
|
CTI3
|
||||||
|
NUMBER_OF_FIELDS 2
|
||||||
|
NUMBER_OF_SETS 1
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID RGB_R
|
||||||
|
END_DATA_FORMAT
|
||||||
|
BEGIN_DATA
|
||||||
|
1
|
||||||
|
END_DATA
|
||||||
|
"""
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try CGATSParser.parse(bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Writer emits valid .ti3 with tabs and required keywords")
|
||||||
|
func writerFormat() throws {
|
||||||
|
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
|
||||||
|
let text = try CGATSWriter.write(dataset)
|
||||||
|
#expect(text.contains("CTI3"))
|
||||||
|
#expect(text.contains("BEGIN_DATA_FORMAT"))
|
||||||
|
#expect(text.contains("BEGIN_DATA"))
|
||||||
|
#expect(text.contains("END_DATA"))
|
||||||
|
#expect(text.contains("COLOR_REP"))
|
||||||
|
#expect(text.contains("DEVICE_CLASS"))
|
||||||
|
#expect(text.contains("\t"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Milestone 6 CGATS import UI tests (issue #30).
|
||||||
|
@MainActor
|
||||||
|
final class Milestone6CGATSUITests: XCTestCase {
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private var testRoot: URL!
|
||||||
|
private var datasetURL: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
|
||||||
|
testRoot = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-cgats-ui-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let csv = """
|
||||||
|
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
|
||||||
|
1,A1,50,0,0,20,10,5,50,60,30
|
||||||
|
2,A2,0,50,0,10,30,5,60,-50,40
|
||||||
|
"""
|
||||||
|
datasetURL = testRoot.appendingPathComponent("imported.csv")
|
||||||
|
try csv.write(to: datasetURL, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchEnvironment = [
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_WORKDIR": testRoot.path,
|
||||||
|
"ICCERY_TEST_DATASET_IMPORT": datasetURL.path
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
if let testRoot {
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Milestone6CGATSUITests.importUsesOpenPanelNotSaveTi1`
|
||||||
|
/// Must fail if import presents a save panel or a `.ti1` filter.
|
||||||
|
func testImportUsesOpenPanelNotSaveTi1() throws {
|
||||||
|
app.launch()
|
||||||
|
app.activate()
|
||||||
|
|
||||||
|
XCTAssertTrue(app.buttons["btn-import-dataset"].waitForExistence(timeout: 10))
|
||||||
|
app.buttons["btn-import-dataset"].click()
|
||||||
|
|
||||||
|
// No save panel should appear; the open panel is stubbed under UI testing.
|
||||||
|
let savePanel = app.sheets.firstMatch
|
||||||
|
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.
|
||||||
|
_ = app.otherElements["stage-4"].waitForExistence(timeout: 10)
|
||||||
|
XCTAssertTrue(app.otherElements["stage-4"].exists)
|
||||||
|
|
||||||
|
// The canonical .ti3 should be written next to the source file.
|
||||||
|
let ti3URL = testRoot.appendingPathComponent("imported.ti3")
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3URL.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user