import Foundation struct SubtitleCue { var start: Double var end: Double var text: String } /// SRT ↔ VTT ↔ TXT, plus the offset tool. enum SubtitleConverter { static func convert( source: URL, to format: Format, at destination: URL, cancellation: Cancellation? = nil ) throws { var cues = try parse(source) try cancellation?.check() cues = cues.map { cue in var copy = cue copy.text = SubtitleConverter.normalized(copy.text) return copy } switch format { case .srt, .vtt: try write(cues, to: destination, format: format) case .txt: try TextDocument.write(cues.map(\.text).joined(separator: "\n"), to: destination) default: throw ConversionError.unsupported("Subtitles cannot become \(format.displayName).") } } static func parse(_ url: URL) throws -> [SubtitleCue] { guard let raw = try? String(contentsOf: url, encoding: .utf8) ?? (try? String(contentsOf: url, encoding: .isoLatin1)) else { throw ConversionError.unreadable(url.lastPathComponent) } let ext = FileKind.canonicalExtension(url.pathExtension) switch ext { case "vtt": return parseBlocks(raw, timeSeparator: ".") case "ass", "ssa": return parseASS(raw) default: return parseBlocks(raw, timeSeparator: nil) } } private static func parseBlocks(_ raw: String, timeSeparator: Character?) -> [SubtitleCue] { let normalized = raw .replacingOccurrences(of: "\r\n", with: "\n") .replacingOccurrences(of: "\r", with: "\n") .replacingOccurrences(of: "\u{FEFF}", with: "") let blocks = normalized.components(separatedBy: "\n\n") var cues: [SubtitleCue] = [] for block in blocks { let lines = block.components(separatedBy: "\n").map { $0.trimmed }.filter { !$0.isEmpty } guard let timeLineIndex = lines.firstIndex(where: { $0.contains("-->") }) else { continue } let timeLine = lines[timeLineIndex] let parts = timeLine.components(separatedBy: "-->") guard parts.count == 2, let start = seconds(from: parts[0], separator: timeSeparator), let end = seconds(from: parts[1], separator: timeSeparator) else { continue } let text = lines.dropFirst(timeLineIndex + 1).joined(separator: "\n") cues.append(SubtitleCue(start: start, end: max(end, start), text: text.isEmpty ? " " : text)) } return cues } private static func parseASS(_ raw: String) -> [SubtitleCue] { var cues: [SubtitleCue] = [] for line in raw.components(separatedBy: "\n") where line.hasPrefix("Dialogue:") { let fields = line.dropFirst("Dialogue:".count).components(separatedBy: ",") guard fields.count >= 10, let start = seconds(from: fields[1], separator: nil), let end = seconds(from: fields[2], separator: nil) else { continue } let text = fields[9...].joined(separator: ",") .replacingOccurrences(of: "\\N", with: "\n") .replacingOccurrences(of: "\\n", with: "\n") cues.append(SubtitleCue(start: start, end: end, text: text)) } return cues } static func write(_ cues: [SubtitleCue], to url: URL, format: Format) throws { var output = format == .vtt ? "WEBVTT\n\n" : "" for (index, cue) in cues.enumerated() { if format == .srt { output += "\(index + 1)\n" output += "\(timecode(cue.start, separator: ",")) --> \(timecode(cue.end, separator: ","))\n" } else { output += "\(timecode(cue.start, separator: ".")) --> \(timecode(cue.end, separator: "."))\n" } output += cue.text + "\n\n" } try TextDocument.write(output, to: url) } static func shift(_ cues: [SubtitleCue], by offset: Double) -> [SubtitleCue] { cues.map { cue in var copy = cue let shift = max(0, cue.start + offset) - cue.start copy.start = max(0, cue.start + offset) copy.end = max(copy.start, cue.end + shift) return copy } } private static func normalized(_ text: String) -> String { var value = text for tag in ["", "", "", "", "", "", "", ""] { value = value.replacingOccurrences(of: tag, with: "") } return value } // MARK: - Timecodes static func timecode(_ seconds: Double, separator: Character = ",") -> String { let clamped = max(0, seconds) let hours = Int(clamped) / 3600 let minutes = (Int(clamped) % 3600) / 60 let wholeSeconds = Int(clamped) % 60 let millis = Int(((clamped - Double(Int(clamped))) * 1000).rounded()) return String(format: "%02d:%02d:%02d%@%03d", hours, minutes, wholeSeconds, String(separator), millis) } private static func seconds(from text: String, separator: Character?) -> Double? { let cleaned = text.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".") let components = cleaned.components(separatedBy: ":") var value: Double = 0 for (index, component) in components.enumerated() { guard let number = Double(component) else { return nil } switch components.count - index { case 1: value += number case 2: value += number * 60 case 3: value += number * 3600 default: return nil } } _ = separator return value } }