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)
+ }
+}