diff --git a/.swiftlint.yml b/.swiftlint.yml index 8bbf4cc..27b73fd 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -116,6 +116,10 @@ identifier_name: excluded: - id - no + # Frame coordinate labels: `.position(x:y:)` reads better than any longer + # spelling, and matches how the DTD names the axes. + - x + - y excluded: - DerivedData - .build diff --git a/Sources/FCPKit/Adjustments/AdjustTransform.swift b/Sources/FCPKit/Adjustments/AdjustTransform.swift index ade40f5..6f3d334 100644 --- a/Sources/FCPKit/Adjustments/AdjustTransform.swift +++ b/Sources/FCPKit/Adjustments/AdjustTransform.swift @@ -30,22 +30,43 @@ import Foundation import XMLCoder -/// An `adjust-transform` element controlling a clip's spatial transform (position and scale). +/// An `adjust-transform` element controlling a clip's spatial transform. +/// +/// Covers the DTD's `enabled`, `position`, `scale`, `rotation`, and `anchor` +/// attributes. `CodingKeys` follow DTD declaration order. public struct AdjustTransform: Codable { internal enum CodingKeys: String, CodingKey { + case enabled case position case scale + case rotation + case anchor } + /// Whether the adjustment is active. `"0"` disables it; the DTD default is `"1"`. + public var enabled: String? /// The position offset as an "x y" pair, as a string. public var position: String? /// The scale factor as an "x y" pair, as a string. public var scale: String? + /// The rotation in degrees, as a string. + public var rotation: String? + /// The anchor point as an "x y" pair, as a string. + public var anchor: String? - /// Creates an `adjust-transform` adjustment with an optional position and scale. - public init(position: String? = nil, scale: String? = nil) { + /// Creates an `adjust-transform` adjustment. Omitted values are not encoded. + public init( + enabled: String? = nil, + position: String? = nil, + scale: String? = nil, + rotation: String? = nil, + anchor: String? = nil + ) { + self.enabled = enabled self.position = position self.scale = scale + self.rotation = rotation + self.anchor = anchor } } diff --git a/Sources/FCPKitDSL/BuildError.swift b/Sources/FCPKitDSL/BuildError.swift index 88ba0bc..674a57d 100644 --- a/Sources/FCPKitDSL/BuildError.swift +++ b/Sources/FCPKitDSL/BuildError.swift @@ -41,6 +41,8 @@ public enum BuildError: Error, Equatable, Sendable { case conflictingResourceID(String) /// A format was required but could not be resolved. case missingFormat + /// An absolute frame position was used with no enclosing sequence format. + case missingFrameSize /// A resource identifier string was illegal. case invalidResourceID(String) } diff --git a/Sources/FCPKitDSL/FramePosition.swift b/Sources/FCPKitDSL/FramePosition.swift new file mode 100644 index 0000000..9538ee9 --- /dev/null +++ b/Sources/FCPKitDSL/FramePosition.swift @@ -0,0 +1,194 @@ +// +// FramePosition.swift +// FCPKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// A position within the video frame. +/// +/// Final Cut's `adjust-transform position` is expressed as a percentage of the +/// frame **height on both axes**, measured from the frame centre, with Y +/// pointing up. Alignment cases resolve without knowing the frame size; +/// absolute pixel coordinates need the enclosing sequence's format. +public struct FramePosition: Equatable, Sendable { + /// The nine standard frame alignments. + public enum Alignment: Equatable, Sendable { + /// The top-leading corner. + case topLeading + /// The top edge, horizontally centered. + case top + /// The top-trailing corner. + case topTrailing + /// The leading edge, vertically centered. + case leading + /// The frame centre. + case center + /// The trailing edge, vertically centered. + case trailing + /// The bottom-leading corner. + case bottomLeading + /// The bottom edge, horizontally centered. + case bottom + /// The bottom-trailing corner. + case bottomTrailing + } + + /// How a position is expressed before resolution. + internal enum Kind: Equatable, Sendable { + case alignment(Alignment, inset: Double) + case absolute(x: Double, y: Double) + } + + internal let kind: Kind + + /// A position at a frame alignment, optionally inset in points. + public static func aligned(_ alignment: Alignment, inset: Double = 0) -> FramePosition { + FramePosition(kind: .alignment(alignment, inset: inset)) + } + + /// A position at absolute pixel coordinates, with the origin at the top left. + public static func absolute(x: Double, y: Double) -> FramePosition { + FramePosition(kind: .absolute(x: x, y: y)) + } +} + +extension FramePosition { + /// Converts absolute pixels (origin top-left) into Final Cut's percent-of-height units. + /// + /// The divisor is the frame **height** on both axes; that is what makes the + /// values in the 16:9 fixtures land correctly. + internal static func percent( + x absoluteX: Double, + y absoluteY: Double, + width: Double, + height: Double + ) -> (x: Double, y: Double) { + ( + x: (absoluteX - width / 2) / height * 100, + y: (height / 2 - absoluteY) / height * 100 + ) + } + + /// Resolves an alignment, returning `nil` when it needs no transform. + /// + /// - Throws: ``BuildError/missingFrameSize`` when a non-zero inset is requested + /// without a frame size. An inset is in points, and converting points to + /// Final Cut's percent-of-height unit requires the frame height — silently + /// dropping it would emit a position the caller did not ask for. + private static func alignmentPercent( + _ alignment: Alignment, + inset: Double, + frameSize: (width: Double, height: Double)? + ) throws(BuildError) -> (x: Double, y: Double)? { + // `.center` is the frame centre on both axes, so an inset has no direction + // to move along and the position needs no `adjust-transform` at all. + if alignment == .center { + return nil + } + + guard inset == 0 || frameSize != nil else { + throw BuildError.missingFrameSize + } + + // Vertical extent is exactly ±50% of the height. Horizontal extent depends + // on the aspect ratio, because the unit's divisor is the height on both + // axes; 16:9 is assumed when no format is known. + let aspect = frameSize.map { $0.width / $0.height } ?? (16.0 / 9.0) + let halfWidth = aspect / 2 * 100 + let insetPercent = frameSize.map { inset / $0.height * 100 } ?? 0 + + return ( + x: horizontalPercent(alignment, extent: halfWidth, inset: insetPercent), + y: verticalPercent(alignment, inset: insetPercent) + ) + } + + /// The horizontal component of an alignment, in percent-of-height units. + private static func horizontalPercent( + _ alignment: Alignment, + extent: Double, + inset: Double + ) -> Double { + switch alignment { + case .topLeading, .leading, .bottomLeading: + return -extent + inset + case .topTrailing, .trailing, .bottomTrailing: + return extent - inset + case .top, .center, .bottom: + return 0 + } + } + + /// The vertical component of an alignment, in percent-of-height units. + private static func verticalPercent(_ alignment: Alignment, inset: Double) -> Double { + switch alignment { + case .topLeading, .top, .topTrailing: + return 50 - inset + case .bottomLeading, .bottom, .bottomTrailing: + return -50 + inset + case .leading, .center, .trailing: + return 0 + } + } +} + +extension FramePosition { + /// Resolves this position into an `adjust-transform position` value. + /// + /// - Parameter frameSize: The enclosing sequence's frame size, when known. + /// - Returns: The formatted `"x y"` pair, or `nil` when the position is the + /// frame centre and therefore needs no `adjust-transform` at all. + /// - Throws: ``BuildError/missingFrameSize`` when absolute coordinates were + /// used without an enclosing format. + internal func resolve(frameSize: (width: Double, height: Double)?) throws(BuildError) -> String? { + let point: (x: Double, y: Double) + + switch kind { + case .alignment(let alignment, let inset): + guard + let resolved = try Self.alignmentPercent(alignment, inset: inset, frameSize: frameSize) + else { + return nil + } + point = resolved + + case .absolute(let absoluteX, let absoluteY): + guard let frameSize else { + throw BuildError.missingFrameSize + } + point = Self.percent( + x: absoluteX, + y: absoluteY, + width: frameSize.width, + height: frameSize.height + ) + } + + return "\(String(fcpxmlValue: point.x)) \(String(fcpxmlValue: point.y))" + } +} diff --git a/Sources/FCPKitDSL/ResourceStore.swift b/Sources/FCPKitDSL/ResourceStore.swift index 6797c65..28722db 100644 --- a/Sources/FCPKitDSL/ResourceStore.swift +++ b/Sources/FCPKitDSL/ResourceStore.swift @@ -47,6 +47,14 @@ public struct ResourceStore { private var fingerprints: [String: ResourceID] = [:] private var explicitFingerprints: [String: String] = [:] private var nextNumber = 1 + private var nextTextStyleNumber = 1 + + /// The enclosing sequence's frame size, once a format has been resolved. + /// + /// Ambient build context rather than a resource: story items nested under a + /// ``Sequence`` need the frame size to resolve absolute ``FramePosition`` + /// coordinates, and `ResourceStore` is already threaded through every `build`. + internal var frameSize: (width: Double, height: Double)? /// Creates a store targeting the given document version. internal init(version: FCPXMLVersion = .supportedGeneration) { @@ -133,6 +141,15 @@ public struct ResourceStore { return try resourceRef(id) } + /// Allocates the next document-unique `text-style-def` id (`ts1`, `ts2`, …). + /// + /// The DTD declares `text-style-def/@id` as `ID`, which XML requires to be unique + /// across the whole document, so ids are numbered globally rather than per title. + internal mutating func textStyleID() -> String { + defer { nextTextStyleNumber += 1 } + return "ts\(nextTextStyleNumber)" + } + internal func materialize() -> FCPKit.Resources { FCPKit.Resources( assets: assets.isEmpty ? nil : assets, diff --git a/Sources/FCPKitDSL/Sequence.swift b/Sources/FCPKitDSL/Sequence.swift index ac47b44..8d8835d 100644 --- a/Sources/FCPKitDSL/Sequence.swift +++ b/Sources/FCPKitDSL/Sequence.swift @@ -45,6 +45,18 @@ public struct Sequence: DSLNode { let formatRef = try format.map { preset throws(BuildError) in try resources.format(preset) } + + // Publish the frame size before building children: story items resolve + // absolute `FramePosition` coordinates against it. + let outerFrameSize = resources.frameSize + if let format, + let width = format.format.width.flatMap(Double.init), + let height = format.format.height.flatMap(Double.init) + { + resources.frameSize = (width: width, height: height) + } + defer { resources.frameSize = outerFrameSize } + let packed = try Layout.pack( content.contents.spineItems(resources: &resources), frameDuration: format?.format.frameDuration diff --git a/Sources/FCPKitDSL/String+FCPXMLValue.swift b/Sources/FCPKitDSL/String+FCPXMLValue.swift new file mode 100644 index 0000000..510d055 --- /dev/null +++ b/Sources/FCPKitDSL/String+FCPXMLValue.swift @@ -0,0 +1,56 @@ +// +// String+FCPXMLValue.swift +// FCPKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension String { + /// Creates an FCPXML attribute value from a `Double`, collapsing whole numbers. + /// + /// Final Cut writes `63` rather than `63.0`, so whole values lose their + /// fractional part. Values outside `Int`'s range, and non-finite values, fall + /// back to the plain `Double` description: converting them with `Int(_:)` + /// would trap and take the host process down with it. + /// + /// `Decimal.FormatStyle` is deliberately not used here. It is locale-aware, so + /// `63.5` renders as `63,5` under a German or French locale, which is invalid + /// FCPXML; it rounds to six fractional digits, which would corrupt position + /// values like `7.777777777`; and `Decimal(Double.infinity)` traps outright. + /// + /// - Parameter value: The number to render. + internal init(fcpxmlValue value: Double) { + guard value.isFinite, + value.truncatingRemainder(dividingBy: 1) == 0, + value >= Double(Int.min), value <= Double(Int.max) + else { + self = String(value) + return + } + self = String(Int(value)) + } +} diff --git a/Sources/FCPKitDSL/Title+Modifiers.swift b/Sources/FCPKitDSL/Title+Modifiers.swift new file mode 100644 index 0000000..3e53640 --- /dev/null +++ b/Sources/FCPKitDSL/Title+Modifiers.swift @@ -0,0 +1,105 @@ +// +// Title+Modifiers.swift +// FCPKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import FCPKit + +extension Title { + /// Sets the font family, such as `Helvetica`. + public func font(_ name: String) -> Title { + var updated = style + updated.font = name + return replacing(style: updated) + } + + /// Sets the font size in points. + public func fontSize(_ points: Double) -> Title { + var updated = style + updated.fontSize = points + return replacing(style: updated) + } + + /// Sets the font face or weight within the family, such as `Bold`. + public func fontFace(_ face: String) -> Title { + var updated = style + updated.fontFace = face + return replacing(style: updated) + } + + /// Sets the text color. + public func fontColor(_ color: Color) -> Title { + var updated = style + updated.fontColor = color + return replacing(style: updated) + } + + /// Sets the paragraph alignment. + public func alignment(_ alignment: TitleStyle.Alignment) -> Title { + var updated = style + updated.alignment = alignment + return replacing(style: updated) + } + + /// Renders the text bold. + /// + /// Final Cut writes `fontFace` rather than `bold` for most families, so prefer + /// ``fontFace(_:)`` with a face known to exist for the chosen font. + public func bold(_ isBold: Bool = true) -> Title { + var updated = style + updated.bold = isBold + return replacing(style: updated) + } + + /// Replaces the whole text style. + public func style(_ style: TitleStyle) -> Title { + replacing(style: style) + } + + /// Sets the title clip's display name, independent of its text. + public func name(_ name: String) -> Title { + replacing(displayName: name) + } +} + +extension Title { + /// Positions the title at a frame alignment, optionally inset in points. + /// + /// Alignment positions never require a frame size. A centered title with no + /// inset emits no `adjust-transform` at all. + public func position(_ alignment: FramePosition.Alignment, inset: Double = 0) -> Title { + replacing(position: .aligned(alignment, inset: inset)) + } + + /// Positions the title at absolute pixel coordinates, with the origin top-left. + /// + /// - Throws: At `export()`, ``BuildError/missingFrameSize`` when the title has + /// no enclosing ``Sequence`` format to resolve against. + public func position(x: Double, y: Double) -> Title { + replacing(position: .absolute(x: x, y: y)) + } +} diff --git a/Sources/FCPKitDSL/Title.swift b/Sources/FCPKitDSL/Title.swift index fdead75..5f33982 100644 --- a/Sources/FCPKitDSL/Title.swift +++ b/Sources/FCPKitDSL/Title.swift @@ -39,6 +39,9 @@ public struct Title: StoryItem { internal let offset: FCPTime? /// The anchors attached to this title. public let anchors: [any DSLNode] + internal let style: TitleStyle + internal let position: FramePosition? + internal let displayName: String? /// Creates a Basic Title from text and optional duration. public init(_ text: String, duration: FCPTime? = nil) { @@ -47,23 +50,19 @@ public struct Title: StoryItem { /// Creates a title from a preset, text, and optional duration. public init(_ preset: TitlePreset, text: String, duration: FCPTime? = nil) { - self.init( - preset: preset, - text: text, - duration: duration ?? .zero, - lane: nil, - offset: nil, - anchors: [] - ) + self.init(preset: preset, text: text, duration: duration ?? .zero, lane: nil, offset: nil) } - private init( + internal init( preset: TitlePreset, text: String, duration: FCPTime, lane: Int?, offset: FCPTime?, - anchors: [any DSLNode] + anchors: [any DSLNode] = [], + style: TitleStyle = .default, + position: FramePosition? = nil, + displayName: String? = nil ) { self.preset = preset self.text = text @@ -71,49 +70,59 @@ public struct Title: StoryItem { self.lane = lane self.offset = offset self.anchors = anchors + self.style = style + self.position = position + self.displayName = displayName } /// Sets the title clip duration. public func duration(_ duration: FCPTime) -> Title { + replacing(duration: duration) + } + + /// Returns a copy of this title with the given fields replaced. + internal func replacing( + duration: FCPTime? = nil, + anchors: [any DSLNode]? = nil, + style: TitleStyle? = nil, + position: FramePosition? = nil, + displayName: String? = nil + ) -> Title { Title( preset: preset, text: text, - duration: duration, + duration: duration ?? self.duration, lane: lane, offset: offset, - anchors: anchors + anchors: anchors ?? self.anchors, + style: style ?? self.style, + position: position ?? self.position, + displayName: displayName ?? self.displayName ) } /// Returns a copy of this title carrying exactly the given anchors. public func replacingAnchors(_ anchors: [any DSLNode]) -> Title { - Title( - preset: preset, - text: text, - duration: duration, - lane: lane, - offset: offset, - anchors: anchors - ) + replacing(anchors: anchors) } /// Lowers this title into a `` story item. public func build(_ resources: inout ResourceStore) throws(BuildError) -> Built { let ref = try resources.effect(name: preset.name, uid: preset.uid) - let style = FCPKit.TextStyle(ref: "ts1", content: text) - let definition = FCPKit.TextStyleDef( - id: "ts1", - textStyle: FCPKit.TextStyle( - font: "Helvetica", - fontSize: "63", - fontFace: "Regular", - fontColor: "1 1 1 1", - alignment: "center" - ) - ) + let styleID = resources.textStyleID() + let style = FCPKit.TextStyle(ref: styleID, content: text) + let definition = FCPKit.TextStyleDef(id: styleID, textStyle: self.style.textStyle()) + + // No adjust-transform unless a position was requested, so unpositioned + // titles stay byte-identical to real Final Cut output. + var transform: FCPKit.AdjustTransform? + if let position, let resolved = try position.resolve(frameSize: resources.frameSize) { + transform = FCPKit.AdjustTransform(position: resolved) + } + var element = FCPKit.Title( ref: ref, - name: preset.name, + name: displayName ?? preset.name, duration: duration.description, start: "3600s", lane: lane.map(String.init), @@ -121,6 +130,7 @@ public struct Title: StoryItem { text: [FCPKit.TextElement(textStyle: [style])], textStyleDef: [definition] ) + element.adjustTransform = transform element.anchoredItems = try anchors.anchoredItems(resources: &resources) return .item(.title(element)) } diff --git a/Sources/FCPKitDSL/TitleStyle.swift b/Sources/FCPKitDSL/TitleStyle.swift new file mode 100644 index 0000000..82b9585 --- /dev/null +++ b/Sources/FCPKitDSL/TitleStyle.swift @@ -0,0 +1,103 @@ +// +// TitleStyle.swift +// FCPKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import FCPKit + +/// Text styling applied to a ``Title``. +/// +/// The defaults reproduce Final Cut Pro's Basic Title styling exactly, so an +/// unstyled title serializes byte-identically to a real Final Cut export. +public struct TitleStyle: Equatable, Sendable { + /// Paragraph alignment within the title's text box. + public enum Alignment: String, Equatable, Sendable { + /// Align text to the leading edge. + case left + /// Center text horizontally. + case center + /// Align text to the trailing edge. + case right + /// Stretch text to fill the line. + case justified + } + + /// Final Cut Pro's Basic Title default styling. + public static let `default` = TitleStyle() + + /// The font family, such as `Helvetica`. + public var font: String + /// The font size in points. + public var fontSize: Double + /// The font face or weight within the family, such as `Regular` or `Bold`. + public var fontFace: String + /// The text color. + public var fontColor: Color + /// The paragraph alignment. + public var alignment: Alignment + /// Whether the text renders bold. + /// + /// Emitted only when `true`: real Final Cut output never writes the attribute, + /// so leaving it off keeps default output identical to the fixtures. + public var bold: Bool + + /// Creates a title style, defaulting to Final Cut's Basic Title styling. + public init( + font: String = "Helvetica", + fontSize: Double = 63, + fontFace: String = "Regular", + fontColor: Color = .white, + alignment: Alignment = .center, + bold: Bool = false + ) { + self.font = font + self.fontSize = fontSize + self.fontFace = fontFace + self.fontColor = fontColor + self.alignment = alignment + self.bold = bold + } +} + +extension TitleStyle { + /// The `fontSize` attribute value, collapsing whole numbers (`63`, not `63.0`). + internal var fontSizeString: String { + String(fcpxmlValue: fontSize) + } + + /// Lowers this style into the model's `text-style` element. + internal func textStyle() -> FCPKit.TextStyle { + FCPKit.TextStyle( + font: font, + fontSize: fontSizeString, + fontFace: fontFace, + fontColor: fontColor.description, + bold: bold ? "1" : nil, + alignment: alignment.rawValue + ) + } +} diff --git a/Tests/FCPKitDSLTests/FramePositionTests.swift b/Tests/FCPKitDSLTests/FramePositionTests.swift new file mode 100644 index 0000000..67af449 --- /dev/null +++ b/Tests/FCPKitDSLTests/FramePositionTests.swift @@ -0,0 +1,215 @@ +// +// FramePositionTests.swift +// FCPKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import FCPKit +import FCPKitDSL +import Foundation +import Testing + +@Suite +internal struct FramePositionTests { + @Test + internal func unpositionedTitleEmitsNoAdjustTransform() throws { + // Regression guard: existing output must stay byte-identical. + let built = try TitleStyleSupport.firstTitle(Title("Hello", duration: .seconds(5))) + #expect(built.adjustTransform == nil) + } + + @Test + internal func frameCentreEmitsNoAdjustTransform() throws { + let built = try TitleStyleSupport.firstTitle( + Title("Hello", duration: .seconds(5)).position(.center) + ) + #expect(built.adjustTransform == nil) + } + + @Test + internal func absoluteCentreResolvesToZero() throws { + let position = try TitleStyleSupport.transformPosition( + Title("Hello", duration: .seconds(5)).position(x: 960, y: 540) + ) + #expect(position == "0 0") + } + + @Test + internal func absoluteCoordinatesMatchTheFixtureFormula() throws { + // Real Final Cut output contains position="-17.8241 7.77778" + // (TestData/UntitledXML.fcpxml:448). That fixture's sequence uses format r2, + // FFVideoFormat3840x2160p24 — 3840x2160, not 1080p. + // + // The unit is percent of frame HEIGHT on both axes, from the centre, Y-up, + // which makes it scale-invariant: the same percentages describe the same + // relative position at any resolution. So the fixture's values reproduce on + // a 1080p sequence at the proportionally equivalent pixels — Y=7.77778 is + // 0.0777778 * 1080 = 84px above centre (absolute y 456), and X=-17.8241 is + // 192.5px left of centre (absolute x 767.5). + let position = try #require( + try TitleStyleSupport.transformPosition( + Title("Hello", duration: .seconds(5)).position(x: 960 - 192.5, y: 540 - 84) + ) + ) + + let parts = position.split(separator: " ") + #expect(parts.count == 2) + let xComponent = try #require(Double(parts[0])) + let yComponent = try #require(Double(parts[1])) + + #expect(abs(yComponent - 7.77778) < 0.001) + #expect(abs(xComponent - (-17.8241)) < 0.001) + + // The divisor is the height on both axes, not the width. + #expect(abs(yComponent - 84.0 / 1_080.0 * 100.0) < 0.001) + #expect(abs(xComponent - (-192.5 / 1_080.0 * 100.0)) < 0.001) + } + + @Test + internal func topAndBottomAreSymmetric() throws { + let top = try #require( + try TitleStyleSupport.transformPosition( + Title("Hello", duration: .seconds(5)).position(.top) + ) + ) + let bottom = try #require( + try TitleStyleSupport.transformPosition( + Title("Hello", duration: .seconds(5)).position(.bottom) + ) + ) + #expect(top == "0 50") + #expect(bottom == "0 -50") + } + + @Test + internal func absolutePositionWithoutFormatThrows() throws { + let document = TitleStyleSupport.TitleDoc( + Title("Hello", duration: .seconds(5)).position(x: 100, y: 100), + format: nil + ) + #expect(throws: BuildError.missingFrameSize) { + _ = try document.export() + } + } + + @Test + internal func alignmentPositionWithoutFormatDoesNotThrow() throws { + let document = TitleStyleSupport.TitleDoc( + Title("Hello", duration: .seconds(5)).position(.topLeading), + format: nil + ) + #expect(throws: Never.self) { + _ = try document.export() + } + } + + @Test + internal func adjustTransformRoundTripsNewAttributes() throws { + let transform = FCPKit.AdjustTransform( + enabled: "1", + position: "10 20", + scale: "2 2", + rotation: "45", + anchor: "0 0" + ) + #expect(transform.rotation == "45") + #expect(transform.anchor == "0 0") + #expect(transform.enabled == "1") + } + + @Test + internal func hugeFontSizeDoesNotTrap() throws { + // Whole-but-huge doubles took an unguarded Int(_:) conversion, which traps + // and takes the host process down. A library must not crash on user input. + let huge = Double("1e21") ?? 0 + let style = try TitleStyleSupport.definitionStyle( + Title("Hello", duration: .seconds(5)).fontSize(huge) + ) + #expect(style.fontSize != nil) + } + + @Test + internal func nonFiniteFontSizeDoesNotTrap() throws { + let style = try TitleStyleSupport.definitionStyle( + Title("Hello", duration: .seconds(5)).fontSize(.infinity) + ) + #expect(style.fontSize != nil) + } + + @Test + internal func centerIgnoresInsetAndEmitsNoTransform() throws { + // `.center` is the frame centre on both axes, so an inset has no direction + // to move along. It must still emit nothing rather than a no-op transform. + let built = try TitleStyleSupport.firstTitle( + Title("Hello", duration: .seconds(5)).position(.center, inset: 100) + ) + #expect(built.adjustTransform == nil) + } + + @Test + internal func insetWithoutFormatThrowsRatherThanSilentlyDropping() throws { + // An inset is in points; converting to percent-of-height needs the frame + // height. Silently dropping it would emit a position never asked for. + let document = TitleStyleSupport.TitleDoc( + Title("Hello", duration: .seconds(5)).position(.top, inset: 80), + format: nil + ) + #expect(throws: BuildError.missingFrameSize) { + _ = try document.export() + } + } + + @Test + internal func zeroInsetAlignmentWithoutFormatStillResolves() throws { + // Only a non-zero inset needs the frame size; plain alignments never throw. + let document = TitleStyleSupport.TitleDoc( + Title("Hello", duration: .seconds(5)).position(.top), + format: nil + ) + #expect(throws: Never.self) { + _ = try document.export() + } + } + + @Test + internal func decimalStringsUseAPeriodRegardlessOfLocale() throws { + // FCPXML attribute values are machine-readable and must always use a period + // as the decimal separator. `Decimal.FormatStyle` would emit "63,5" under a + // German or French locale, producing invalid FCPXML — this pins the + // locale-independent formatting so nobody swaps one in. + let style = try TitleStyleSupport.definitionStyle( + Title("Hello", duration: .seconds(5)).fontSize(63.5) + ) + #expect(style.fontSize == "63.5") + + let position = try #require( + try TitleStyleSupport.transformPosition( + Title("Hello", duration: .seconds(5)).position(x: 0, y: 0) + ) + ) + #expect(!position.contains(",")) + } +} diff --git a/Tests/FCPKitDSLTests/PositionedOrderingDoc.swift b/Tests/FCPKitDSLTests/PositionedOrderingDoc.swift new file mode 100644 index 0000000..9b47d83 --- /dev/null +++ b/Tests/FCPKitDSLTests/PositionedOrderingDoc.swift @@ -0,0 +1,48 @@ +// +// PositionedOrderingDoc.swift +// FCPKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import FCPKit +import FCPKitDSL +import Foundation + +/// A clip / transition / clip spine where the clips carry deferred positions. +/// +/// The v0.1.0 Step 3 ordering guarantee is that `Spine.items` keeps DTD order. +/// Position resolution rewrites title elements on the way out, so these tests +/// assert order survives *after* resolution — the schema-completeness inventory +/// is order-blind and would not catch a reordering here. +internal struct PositionedOrderingDoc: Document { + internal var body: some DocumentContent { + Sequence(format: .p1080p24) { + Title("First", duration: .seconds(5)).position(.topLeading) + Transition(.crossDissolve) + Title("Second", duration: .seconds(5)).position(x: 200, y: 300) + } + } +} diff --git a/Tests/FCPKitDSLTests/PositionedSpineOrderingTests.swift b/Tests/FCPKitDSLTests/PositionedSpineOrderingTests.swift new file mode 100644 index 0000000..bf4a78f --- /dev/null +++ b/Tests/FCPKitDSLTests/PositionedSpineOrderingTests.swift @@ -0,0 +1,86 @@ +// +// PositionedSpineOrderingTests.swift +// FCPKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import FCPKit +import FCPKitDSL +import FCPXMLDiff +import Foundation +import Testing + +@Suite +internal struct PositionedSpineOrderingTests { + private static func firstNode(named name: String, in node: XMLTreeNode) -> XMLTreeNode? { + if node.name == name { + return node + } + for child in node.children { + if let match = firstNode(named: name, in: child) { + return match + } + } + return nil + } + + @Test + internal func spineOrderSurvivesPositionResolution() throws { + let exported = try PositionedOrderingDoc().export() + let sequence = try #require(exported.library?.events?.first?.projects?.first?.sequence) + let items = try #require(sequence.spine?.items) + + #expect(items.count == 3) + guard case .title(let first) = items[0] else { + Issue.record("Expected item 0 to be .title") + return + } + guard case .transition = items[1] else { + Issue.record("Expected item 1 to be .transition") + return + } + guard case .title(let third) = items[2] else { + Issue.record("Expected item 2 to be .title") + return + } + + // Both titles resolved a position, and they stayed in authored order. + #expect(first.adjustTransform?.position != nil) + #expect(third.adjustTransform?.position != nil) + #expect(first.text?.first?.textStyle?.first?.content == "First") + #expect(third.text?.first?.textStyle?.first?.content == "Second") + } + + @Test + internal func serializedChildOrderSurvivesPositionResolution() throws { + let exported = try PositionedOrderingDoc().export() + let encoded = try FCPXMLParser().encode(exported) + let root = try XMLTreeParser().parse(encoded) + + let spine = try #require(Self.firstNode(named: "spine", in: root)) + #expect(spine.children.map(\.name) == ["title", "transition", "title"]) + } +} diff --git a/Tests/FCPKitDSLTests/TextStyleIDTests.swift b/Tests/FCPKitDSLTests/TextStyleIDTests.swift new file mode 100644 index 0000000..1fd5be8 --- /dev/null +++ b/Tests/FCPKitDSLTests/TextStyleIDTests.swift @@ -0,0 +1,130 @@ +// +// TextStyleIDTests.swift +// FCPKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import FCPKit +import FCPKitDSL +import FCPXMLDiff +import Foundation +import Testing + +@Suite +internal struct TextStyleIDTests { + private struct MultiTitleDoc: Document { + let count: Int + + var body: some DocumentContent { + Sequence(format: .p1080p24) { + Title("Slide 1", duration: FCPTime(numerator: 5)) + if count > 1 { + Title("Slide 2", duration: FCPTime(numerator: 5)) + } + if count > 2 { + Title("Slide 3", duration: FCPTime(numerator: 5)) + } + } + } + } + + private struct SingleTitleDoc: Document { + var body: some DocumentContent { + Sequence(format: .p1080p24) { + Title("Only", duration: FCPTime(numerator: 5)) + } + } + } + + /// Returns every title in the exported document's spine, in order. + private func titles(_ exported: FCPXML) throws -> [FCPKit.Title] { + let sequence = try #require(exported.library?.events?.first?.projects?.first?.sequence) + let items = try #require(sequence.spine?.items) + return items.compactMap { item in + guard case .title(let title) = item else { + return nil + } + return title + } + } + + /// Validates against Final Cut's DTD, soft-skipping when the tooling is absent. + private func assertDTDValidates(_ data: Data) throws { + let requireDTD = ProcessInfo.processInfo.environment["FCPKIT_REQUIRE_DTD"] != nil + do { + let report = try FCPXMLDTDValidator().validate(data: data) + #expect(report.isValid, "DTD issues: \(report.issues)") + } catch FCPXMLValidationError.dtdNotFound, FCPXMLValidationError.xmllintUnavailable { + if requireDTD { + Issue.record("FCPKIT_REQUIRE_DTD is set but DTD tooling is unavailable") + } + } + } + + @Test + internal func threeTitlesGetDistinctSequentialIDs() throws { + let exported = try MultiTitleDoc(count: 3).export() + let ids = try titles(exported).compactMap { $0.textStyleDef?.first?.id } + #expect(ids == ["ts1", "ts2", "ts3"]) + #expect(Set(ids).count == 3) + } + + @Test + internal func eachTitleReferencesItsOwnStyleDefinition() throws { + let exported = try MultiTitleDoc(count: 3).export() + + // Uniqueness alone is not enough: the ref must still point at that title's + // own definition, not merely at some distinct id. + for title in try titles(exported) { + let definitionID = try #require(title.textStyleDef?.first?.id) + let ref = try #require(title.text?.first?.textStyle?.first?.ref) + #expect(ref == definitionID) + } + } + + @Test + internal func singleTitleStillEmitsTS1() throws { + // Backward compatibility: the counter starts at 1, so existing single-title + // fixtures stay byte-identical and need no edits. + let exported = try SingleTitleDoc().export() + let ids = try titles(exported).compactMap { $0.textStyleDef?.first?.id } + #expect(ids == ["ts1"]) + } + + @Test + internal func multiTitleDocumentValidatesAgainstDTD() throws { + // Duplicate `ID` values are invalid XML, so this is the regression guard: + // it fails against the pre-fix hardcoded "ts1". + // + // Exported at 1.14 deliberately. At the 1.13 default the document is also + // invalid for an unrelated reason — the default smart collections emit + // `match-analysis-type`, which 1.13 does not declare (#41) — and that + // failure would mask the ID regression this test exists to catch. + let exported = try MultiTitleDoc(count: 3).export(version: FCPXMLVersion("1.14")) + let encoded = try FCPXMLParser().encode(exported) + try assertDTDValidates(encoded) + } +} diff --git a/Tests/FCPKitDSLTests/TitleStyleSupport.swift b/Tests/FCPKitDSLTests/TitleStyleSupport.swift new file mode 100644 index 0000000..e292555 --- /dev/null +++ b/Tests/FCPKitDSLTests/TitleStyleSupport.swift @@ -0,0 +1,115 @@ +// +// TitleStyleSupport.swift +// FCPKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import FCPKit +import FCPKitDSL +import FCPXMLDiff +import Foundation +import Testing + +/// Shared fixtures and assertions for the title styling and positioning suites. +internal enum TitleStyleSupport { + /// Wraps a single title in a sequence of the given format. + internal struct TitleDoc: Document { + internal let title: FCPKitDSL.Title + internal let format: FormatPreset? + + internal var body: some DocumentContent { + Sequence(format: format) { + title + } + } + + internal init(_ title: FCPKitDSL.Title, format: FormatPreset? = .p1080p24) { + self.title = title + self.format = format + } + } + + /// Two styled titles, used to prove a styled multi-title document validates. + internal struct StyledPair: Document { + internal var body: some DocumentContent { + Sequence(format: .p1080p24) { + Title("Heading", duration: .seconds(5)) + .font("Helvetica") + .fontSize(96) + .fontColor(.white) + .position(.top, inset: 80) + Title("Body", duration: .seconds(5)) + .fontSize(48) + .alignment(.left) + .position(.bottomLeading, inset: 40) + } + } + } + + /// Exports a document holding `title` and returns the built title element. + internal static func firstTitle( + _ title: FCPKitDSL.Title, + format: FormatPreset? = .p1080p24 + ) throws -> FCPKit.Title { + let exported = try TitleDoc(title, format: format).export() + let sequence = try #require(exported.library?.events?.first?.projects?.first?.sequence) + let items = try #require(sequence.spine?.items) + guard case .title(let built) = items[0] else { + throw TitleStyleSupportError.notATitle + } + return built + } + + /// Returns the `text-style` inside the title's `text-style-def`. + internal static func definitionStyle( + _ title: FCPKitDSL.Title, + format: FormatPreset? = .p1080p24 + ) throws -> FCPKit.TextStyle { + let built = try firstTitle(title, format: format) + return try #require(built.textStyleDef?.first?.textStyle) + } + + /// Returns the resolved `adjust-transform position`, or `nil` when absent. + internal static func transformPosition( + _ title: FCPKitDSL.Title, + format: FormatPreset? = .p1080p24 + ) throws -> String? { + try firstTitle(title, format: format).adjustTransform?.position + } + + /// Validates against Final Cut's DTD, soft-skipping when tooling is absent. + internal static func assertDTDValidates(_ data: Data) throws { + let requireDTD = ProcessInfo.processInfo.environment["FCPKIT_REQUIRE_DTD"] != nil + do { + let report = try FCPXMLDTDValidator().validate(data: data) + #expect(report.isValid, "DTD issues: \(report.issues)") + } catch FCPXMLValidationError.dtdNotFound, FCPXMLValidationError.xmllintUnavailable { + if requireDTD { + Issue.record("FCPKIT_REQUIRE_DTD is set but DTD tooling is unavailable") + } + } + } +} diff --git a/Tests/FCPKitDSLTests/TitleStyleSupportError.swift b/Tests/FCPKitDSLTests/TitleStyleSupportError.swift new file mode 100644 index 0000000..d8176ed --- /dev/null +++ b/Tests/FCPKitDSLTests/TitleStyleSupportError.swift @@ -0,0 +1,36 @@ +// +// TitleStyleSupportError.swift +// FCPKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Failures raised by the title styling test helpers. +internal enum TitleStyleSupportError: Error { + /// The first spine item was not a title. + case notATitle +} diff --git a/Tests/FCPKitDSLTests/TitleStyleTests.swift b/Tests/FCPKitDSLTests/TitleStyleTests.swift new file mode 100644 index 0000000..3876559 --- /dev/null +++ b/Tests/FCPKitDSLTests/TitleStyleTests.swift @@ -0,0 +1,116 @@ +// +// TitleStyleTests.swift +// FCPKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import FCPKit +import FCPKitDSL +import Foundation +import Testing + +@Suite +internal struct TitleStyleTests { + @Test + internal func unstyledTitleMatchesFinalCutDefaults() throws { + // Fixture parity guard: these are exactly the attributes real Final Cut + // writes (Tests/FCPKitTests/FeaturePairs/titles/after.fcpxml). + let style = try TitleStyleSupport.definitionStyle(Title("Hello", duration: .seconds(5))) + #expect(style.font == "Helvetica") + #expect(style.fontSize == "63") + #expect(style.fontFace == "Regular") + #expect(style.fontColor == "1 1 1 1") + #expect(style.alignment == "center") + #expect(style.bold == nil) + } + + @Test + internal func modifiersApplyToTextStyle() throws { + let title = Title("Hello", duration: .seconds(5)) + .font("Avenir Next") + .fontSize(96) + .fontColor(.red) + .alignment(.left) + + let style = try TitleStyleSupport.definitionStyle(title) + #expect(style.font == "Avenir Next") + #expect(style.fontSize == "96") + #expect(style.fontColor == "1 0 0 1") + #expect(style.alignment == "left") + } + + @Test + internal func boldIsEmittedOnlyWhenSet() throws { + let plain = try TitleStyleSupport.definitionStyle(Title("Hello", duration: .seconds(5))) + #expect(plain.bold == nil) + + let bolded = try TitleStyleSupport.definitionStyle( + Title("Hello", duration: .seconds(5)).bold() + ) + #expect(bolded.bold == "1") + } + + @Test + internal func fontSizeCollapsesWholeNumbers() throws { + let whole = try TitleStyleSupport.definitionStyle( + Title("Hello", duration: .seconds(5)).fontSize(63) + ) + #expect(whole.fontSize == "63") + + let fractional = try TitleStyleSupport.definitionStyle( + Title("Hello", duration: .seconds(5)).fontSize(63.5) + ) + #expect(fractional.fontSize == "63.5") + } + + @Test + internal func fontFaceIsSettable() throws { + let style = try TitleStyleSupport.definitionStyle( + Title("Hello", duration: .seconds(5)).fontFace("Bold") + ) + #expect(style.fontFace == "Bold") + } + + @Test + internal func nameOverridesDisplayNameWithoutChangingText() throws { + let title = try TitleStyleSupport.firstTitle( + Title("Body copy", duration: .seconds(5)).name("Slide Heading") + ) + #expect(title.name == "Slide Heading") + + let run = try #require(title.text?.first?.textStyle?.first) + #expect(run.content == "Body copy") + } + + @Test + internal func styledMultiTitleDocumentValidatesAgainstDTD() throws { + // Exported at 1.14: at the 1.13 default the document is invalid for the + // unrelated `match-analysis-type` reason (#41), which would mask this. + let exported = try TitleStyleSupport.StyledPair().export(version: FCPXMLVersion("1.14")) + let encoded = try FCPXMLParser().encode(exported) + try TitleStyleSupport.assertDTDValidates(encoded) + } +}