Drag files in Finder, hold a modifier, and drop on a wheel of outputs. Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation, the bundled CLI tools, and optional ffmpeg/LibreOffice. Includes a --self-test suite (41 checks) and a --convert CLI mode so the conversion matrix can be verified without the UI.
147 lines
5.7 KiB
Swift
147 lines
5.7 KiB
Swift
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 ["<i>", "</i>", "<b>", "</b>", "<u>", "</u>", "<font>", "</font>"] {
|
|
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
|
|
}
|
|
}
|