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:
2026-10-20 00:36:40 +08:00
commit 2d031e58a4
47 changed files with 9149 additions and 0 deletions
@@ -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
}
}