Add Konverter: a local-only menu bar file converter
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.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user