From 93f5bd7a530ed377cd22decd325eefccfae9260c Mon Sep 17 00:00:00 2001 From: bwees Date: Fri, 17 Jul 2026 00:33:57 -0500 Subject: [PATCH] fix: parsing spec errors --- Sources/GDTF.swift | 4 +- Sources/Types.swift | 46 +++++++---- Sources/XMLProcessor.swift | 82 ++++++++++--------- .../SwiftGDTFTests/SpecLegalInputsTests.swift | 79 ++++++++++++++++++ 4 files changed, 154 insertions(+), 57 deletions(-) create mode 100644 Tests/SwiftGDTFTests/SpecLegalInputsTests.swift diff --git a/Sources/GDTF.swift b/Sources/GDTF.swift index e35e9a9..cb89567 100644 --- a/Sources/GDTF.swift +++ b/Sources/GDTF.swift @@ -87,7 +87,7 @@ public struct FixtureAttribute: Codable { } public struct SubPhysicalUnit: Codable { - public var type: SubPhysicalType + public var type: SubPhysicalType? public var physicalUnit: PhysicalUnit = .none public var physicalFrom: Double = 0 public var physicalTo: Double = 1 @@ -280,7 +280,7 @@ public struct SubChannelSet: Codable { public var name: String public var physicalFrom: Double public var physicalTo: Double - public var subPhysicalUnit: SubPhysicalUnit + public var subPhysicalUnit: SubPhysicalUnit? public var wheelSlotIndex: Int? public var dmxProfile: DMXProfile? } diff --git a/Sources/Types.swift b/Sources/Types.swift index 4de9ecf..3a74d58 100644 --- a/Sources/Types.swift +++ b/Sources/Types.swift @@ -195,8 +195,11 @@ public extension DMXValue { } init(from rawValue: String) { - let split: [Int] = rawValue.split(separator: "/").map { Int($0) ?? 0 } - self.init(value: split[0], byteCount: split[1]) + let parts = rawValue.split(separator: "/") + let value = parts.first.flatMap { Int($0) } ?? 0 + // The byte-count component may carry an "s" suffix (byte-shifting form, e.g. "255/1s"). + let byteCount = parts.count > 1 ? (Int(parts[1].filter(\.isNumber)) ?? 1) : 1 + self.init(value: value, byteCount: byteCount) } } @@ -213,13 +216,19 @@ public struct ColorCIE: Codable { } extension ColorCIE { - init(from rawValue: String) { - let split: [Double] = rawValue.split(separator: ",").map { Double($0) ?? 0 } - + init(from rawValue: String) { + let split = rawValue.split(separator: ",").map { Double($0.trimmingCharacters(in: .whitespaces)) ?? 0 } + + // "None" or a malformed value falls back to the white point rather than crashing. + guard split.count >= 2 else { + self = ColorCIE(x: 0.3127, y: 0.3290, Y: 1.0) + return + } + self.x = split[0] self.y = split[1] - - if (split.count == 3) { + + if split.count >= 3 { self.Y = split[2] > 1 ? split[2] / 100 : split[2] } else { self.Y = 1.0 @@ -232,20 +241,27 @@ public struct Rotation: Codable { } extension Rotation { + static let identity: [[Double]] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]] + init(from rawValue: String) { + // "None" is a legal rotationtype value. + guard rawValue != "None" else { + self.matrix = Rotation.identity + return + } + var strMatrix = rawValue strMatrix = strMatrix.replacingOccurrences(of: "}{", with: ",") strMatrix = strMatrix.replacingOccurrences(of: "{", with: "") strMatrix = strMatrix.replacingOccurrences(of: "}", with: "") - - let flatMatrix: [Double] = strMatrix.split(separator: ",").map{ Double($0) ?? 0 } - assert(flatMatrix.count == 9) - /// convert 1D array into 3x3 2D array (matrix) - let matrix: [[Double]] = stride(from: 0, to: flatMatrix.count,by: 3) - .map{ Array(flatMatrix[$0..<$0 + 3]) } - - self.matrix = matrix + let flatMatrix: [Double] = strMatrix.split(separator: ",").map { Double($0) ?? 0 } + guard flatMatrix.count == 9 else { + self.matrix = Rotation.identity + return + } + + self.matrix = stride(from: 0, to: flatMatrix.count, by: 3).map { Array(flatMatrix[$0..<$0 + 3]) } } } diff --git a/Sources/XMLProcessor.swift b/Sources/XMLProcessor.swift index 648bb30..d57363e 100644 --- a/Sources/XMLProcessor.swift +++ b/Sources/XMLProcessor.swift @@ -34,8 +34,8 @@ extension FixtureType: XMLDecodable { guard let element = xml.element else { throw XMLParsingError.elementMissing } self.name = try element.attribute(named: "Name").text - self.shortName = try element.attribute(named: "ShortName").text - self.longName = try element.attribute(named: "LongName").text + self.shortName = element.attribute(by: "ShortName")?.text ?? "" + self.longName = element.attribute(by: "LongName")?.text ?? "" self.manufacturer = try element.attribute(named: "Manufacturer").text self.description = try element.attribute(named: "Description").text self.fixtureTypeID = try element.attribute(named: "FixtureTypeID").uuid @@ -54,8 +54,8 @@ extension FixtureInfo: XMLDecodable { guard let element = xml.element else { throw XMLParsingError.elementMissing } self.name = try element.attribute(named: "Name").text - self.shortName = try element.attribute(named: "ShortName").text - self.longName = try element.attribute(named: "LongName").text + self.shortName = element.attribute(by: "ShortName")?.text ?? "" + self.longName = element.attribute(by: "LongName")?.text ?? "" self.manufacturer = try element.attribute(named: "Manufacturer").text self.description = try element.attribute(named: "Description").text self.fixtureTypeID = try element.attribute(named: "FixtureTypeID").text @@ -142,8 +142,8 @@ extension SubPhysicalUnit: XMLDecodable { self.physicalTo = element.attribute(by: "PhysicalTo")?.double ?? 1 self.physicalUnit = (try? element.attribute(by: "PhysicalUnit")?.toEnum()) ?? .none - - self.type = try element.attribute(named: "Type").toEnum() + + self.type = try? element.attribute(by: "Type")?.toEnum() } } @@ -165,8 +165,8 @@ extension Slot: XMLDecodableWithIndex { guard let element = xml.element else { throw XMLParsingError.elementMissing } self.name = try element.attribute(named: "Name").text - self.color = try ColorCIE(from: element.attribute(named: "Color").text) - + self.color = element.attribute(by: "Color").map { ColorCIE(from: $0.text) } ?? ColorCIE(x: 0.3127, y: 0.3290, Y: 1.0) + self.filter = try element.attribute(by: "Filter")?.resolveNode(base: tree["PhysicalDescriptions"]["Filters"], tree: tree) self.mediaFileName = FileResource(name: element.attribute(by: "MediaFileName")?.text, fileExtension: "png") @@ -223,8 +223,8 @@ extension Emitter: XMLDecodable { guard let element = xml.element else { throw XMLParsingError.elementMissing } self.name = try element.attribute(named: "Name").text - self.color = try ColorCIE(from: element.attribute(named: "Color").text) - self.dominantWavelength = try element.attribute(named: "DominantWaveLength").double + self.color = element.attribute(by: "Color").map { ColorCIE(from: $0.text) } + self.dominantWavelength = element.attribute(by: "DominantWaveLength")?.double self.diodePart = element.attribute(by: "DiodePart")?.text @@ -264,8 +264,8 @@ extension Filter: XMLDecodable { guard let element = xml.element else { throw XMLParsingError.elementMissing } self.name = try element.attribute(named: "Name").text - self.color = try ColorCIE(from: element.attribute(named: "Color").text) - + self.color = element.attribute(by: "Color").map { ColorCIE(from: $0.text) } ?? ColorCIE(x: 0.3127, y: 0.3290, Y: 1.0) + self.measurements = try xml.parseChildrenToArray(tree: tree) } } @@ -275,7 +275,13 @@ extension ColorSpace: XMLDecodable { guard let element = xml.element else { throw XMLParsingError.elementMissing } self.name = element.attribute(by: "Name")?.text ?? "Default" - self.mode = try element.attribute(named: "Mode").toEnum() + self.mode = (try? element.attribute(by: "Mode")?.toEnum()) ?? .srgb + + // Custom-gamut primaries (used when Mode = Custom). + self.red = element.attribute(by: "Red").map { ColorCIE(from: $0.text) } + self.green = element.attribute(by: "Green").map { ColorCIE(from: $0.text) } + self.blue = element.attribute(by: "Blue").map { ColorCIE(from: $0.text) } + self.whitePoint = element.attribute(by: "WhitePoint").map { ColorCIE(from: $0.text) } } } @@ -283,7 +289,7 @@ extension DMXProfile: XMLDecodable { init(xml: XMLIndexer, tree: XMLIndexer) throws { guard let element = xml.element else { throw XMLParsingError.elementMissing } - self.name = try element.attribute(named: "Name").text + self.name = element.attribute(by: "Name")?.text ?? "" self.points = try xml.parseChildrenToArray(tree: tree) } } @@ -292,12 +298,12 @@ extension Point: XMLDecodable { init(xml: XMLIndexer, tree: XMLIndexer) throws { guard let element = xml.element else { throw XMLParsingError.elementMissing } - self.dmxPercentage = try Double(element.attribute(named: "DMXPercentage").text) ?? 0 - - self.cfc0 = try element.attribute(named: "CFC0").double ?? 0 - self.cfc1 = try element.attribute(named: "CFC1").double ?? 0 - self.cfc2 = try element.attribute(named: "CFC2").double ?? 0 - self.cfc3 = try element.attribute(named: "CFC3").double ?? 0 + self.dmxPercentage = element.attribute(by: "DMXPercentage")?.double ?? 0 + + self.cfc0 = element.attribute(by: "CFC0")?.double ?? 0 + self.cfc1 = element.attribute(by: "CFC1")?.double ?? 0 + self.cfc2 = element.attribute(by: "CFC2")?.double ?? 0 + self.cfc3 = element.attribute(by: "CFC3")?.double ?? 0 } } @@ -348,14 +354,10 @@ extension DMXChannel: XMLDecodable { // TODO: Handle overrides from geometry nodes - if let dmxBreak = element.attribute(by: "DMXBreak") { - self.dmxBreak = dmxBreak.int ?? 0 - - if dmxBreak.text != "None" { - self.offset = dmxBreak.text.split(separator: ",").map { Int($0) ?? 0 } - } - } else { - self.dmxBreak = 0 + self.dmxBreak = element.attribute(by: "DMXBreak")?.int ?? 1 + + if let offset = element.attribute(by: "Offset"), offset.text != "None" { + self.offset = offset.text.split(separator: ",").map { Int($0) ?? 0 } } @@ -409,7 +411,7 @@ extension LogicalChannel: XMLDecodable { self.snap = (try? element.attribute(by: "Snap")?.toEnum()) ?? .no self.master = (try? element.attribute(by: "Master")?.toEnum()) ?? .none - self.mibFade = element.attribute(by: "MIBFade")?.double ?? 0 + self.mibFade = (element.attribute(by: "MibFade") ?? element.attribute(by: "MIBFade"))?.double ?? 0 self.dmxChangeTimeLimit = element.attribute(by: "DMXChangeTimeLimit")?.double ?? 0 self.channelFunctions = try xml.parseChildrenToArray(tree: tree) @@ -446,8 +448,8 @@ extension ChannelFunction: XMLDecodableWithIndex { // Filter self.filter = try element.attribute(by: "Filter")?.resolveNode(base: tree["PhysicalDescriptions"]["Filters"], tree: tree) - // ColorSpace - self.colorSpace = try element.attribute(by: "ColorSpace")?.resolveNode(base: tree["PhysicalDescriptions"]["AdditionalColorSpaces"], tree: tree) + // ColorSpace (default child or an AdditionalColorSpaces entry) + self.colorSpace = try? element.attribute(by: "ColorSpace")?.resolveNode(base: tree["PhysicalDescriptions"], tree: tree) // Mode Master self.modeMaster = element.attribute(by: "ModeMaster")?.text @@ -458,7 +460,7 @@ extension ChannelFunction: XMLDecodableWithIndex { } // DMX Profile - self.dmxProfile = try? element.attribute(by: "DMXProfile")?.resolveNode(base: tree["DMXProfiles"], tree: tree) + self.dmxProfile = try? element.attribute(by: "DMXProfile")?.resolveNode(base: tree["PhysicalDescriptions"]["DMXProfiles"], tree: tree) self.minimum = element.attribute(by: "Min")?.double ?? self.physicalFrom self.maximum = element.attribute(by: "Max")?.double ?? self.physicalTo @@ -498,16 +500,16 @@ extension SubChannelSet: XMLDecodableWithParent { self.physicalFrom = (try? element.attribute(named: "PhysicalFrom"))?.double ?? 0 self.physicalTo = (try? element.attribute(named: "PhysicalTo"))?.double ?? 1 - // needs the parent - guard let attributeName = try parent.element?.attribute(named: "Attribute") else { - throw XMLParsingError.attributeMissing(named: "Attribute", on: parent.element) + // SubPhysicalUnit is optional; resolve it against the parent function's attribute. + if let subPhysical = element.attribute(by: "SubPhysicalUnit"), + let attributeName = parent.element?.attribute(by: "Attribute") { + let associatedAttribute = try tree["AttributeDefinitions"]["Attributes"].findChild(with: "Name", being: attributeName.text) + self.subPhysicalUnit = try subPhysical.resolveNode(base: associatedAttribute, tree: tree) + } else { + self.subPhysicalUnit = nil } - - let associatedAttribute = try tree["AttributeDefinitions"]["Attributes"].findChild(with: "Name", being: attributeName.text) - self.subPhysicalUnit = try element.attribute(named: "SubPhysicalUnit").resolveNode(base: associatedAttribute, tree: tree) - - self.dmxProfile = try? element.attribute(named: "DMXProfile").resolveNode(base: tree["DMXProfiles"], tree: tree) + self.dmxProfile = try? element.attribute(by: "DMXProfile")?.resolveNode(base: tree["PhysicalDescriptions"]["DMXProfiles"], tree: tree) } } diff --git a/Tests/SwiftGDTFTests/SpecLegalInputsTests.swift b/Tests/SwiftGDTFTests/SpecLegalInputsTests.swift new file mode 100644 index 0000000..035a4e1 --- /dev/null +++ b/Tests/SwiftGDTFTests/SpecLegalInputsTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import SwiftGDTF + +/// Tests covering spec-legal inputs +final class SpecLegalInputsTests: XCTestCase { + + // MARK: DMXValue (dmxtype: "value/byteCount" with optional trailing "s") + + func testDMXValueBasic() { + let v = DMXValue(from: "255/1") + XCTAssertEqual(v.value, 255) + XCTAssertEqual(v.byteCount, 1) + XCTAssertEqual(v.bytes, [255]) + } + + func testDMXValueSixteenBit() { + let v = DMXValue(from: "65535/2") + XCTAssertEqual(v.byteCount, 2) + XCTAssertEqual(v.bytes, [255, 255]) + } + + func testDMXValueByteShiftingSuffix() { + // "255/1s" is the byte-shifting form; the "s" must not corrupt byteCount. + let v = DMXValue(from: "255/1s") + XCTAssertEqual(v.value, 255) + XCTAssertEqual(v.byteCount, 1) + XCTAssertEqual(v.bytes, [255]) + } + + func testDMXValueMissingByteCountDoesNotCrash() { + let v = DMXValue(from: "5") + XCTAssertEqual(v.value, 5) + XCTAssertEqual(v.byteCount, 1) + } + + // MARK: ColorCIE (vector3type: "x,y,Y"; "None" is legal) + + func testColorCIEFull() { + let c = ColorCIE(from: "0.3127,0.3290,100") + XCTAssertEqual(c.x, 0.3127, accuracy: 1e-6) + XCTAssertEqual(c.y, 0.3290, accuracy: 1e-6) + XCTAssertEqual(c.Y, 1.0, accuracy: 1e-6) // Y > 1 is normalized /100 + } + + func testColorCIETwoComponents() { + let c = ColorCIE(from: "0.5,0.5") + XCTAssertEqual(c.x, 0.5, accuracy: 1e-6) + XCTAssertEqual(c.Y, 1.0, accuracy: 1e-6) + } + + func testColorCIENoneFallsBackToWhite() { + let c = ColorCIE(from: "None") + XCTAssertEqual(c.x, 0.3127, accuracy: 1e-6) + XCTAssertEqual(c.y, 0.3290, accuracy: 1e-6) + } + + func testColorCIEWhitespaceTolerated() { + let c = ColorCIE(from: "0.5, 0.5, 50") + XCTAssertEqual(c.x, 0.5, accuracy: 1e-6) + XCTAssertEqual(c.y, 0.5, accuracy: 1e-6) + } + + // MARK: Rotation (rotationtype: "{..}{..}{..}"; "None" is legal) + + func testRotationMatrix() { + let r = Rotation(from: "{1,0,0}{0,1,0}{0,0,1}") + XCTAssertEqual(r.matrix, [[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + } + + func testRotationNoneIsIdentity() { + let r = Rotation(from: "None") + XCTAssertEqual(r.matrix, [[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + } + + func testRotationMalformedDoesNotCrash() { + let r = Rotation(from: "{1,2}") + XCTAssertEqual(r.matrix, [[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + } +}