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,495 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// CSV/TSV/XLSX/ODS. The delimited formats are parsed in-process; XLSX is read
|
||||
/// and written as OOXML by hand so no dependency is needed; everything else
|
||||
/// goes through LibreOffice.
|
||||
enum SpreadsheetConverter {
|
||||
typealias Rows = [[String]]
|
||||
|
||||
static func convert(
|
||||
source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
try cancellation?.check()
|
||||
switch format {
|
||||
case .csv, .tsv:
|
||||
let rows = try read(source, cancellation: cancellation)
|
||||
try write(rows, to: destination, delimiter: format == .tsv ? "\t" : ",")
|
||||
case .xlsx:
|
||||
let rows = try read(source, cancellation: cancellation)
|
||||
try XLSX.write(rows, to: destination)
|
||||
case .pdf:
|
||||
let rows = try read(source, cancellation: cancellation)
|
||||
try writePDF(rows, at: destination)
|
||||
case .ods, .numbers, .xls:
|
||||
try Office.convert(source, to: format, at: destination, cancellation: cancellation)
|
||||
default:
|
||||
throw ConversionError.unsupported("Spreadsheets cannot become \(format.displayName).")
|
||||
}
|
||||
progress?(1)
|
||||
}
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
static func read(_ url: URL, cancellation: Cancellation? = nil) throws -> Rows {
|
||||
switch FileKind.canonicalExtension(url.pathExtension) {
|
||||
case "csv":
|
||||
let text = try string(from: url)
|
||||
return parseDelimited(text, delimiter: ",")
|
||||
case "tsv":
|
||||
let text = try string(from: url)
|
||||
return parseDelimited(text, delimiter: "\t")
|
||||
case "xlsx":
|
||||
return try XLSX.read(url, cancellation: cancellation)
|
||||
case "ods":
|
||||
return try ODS.read(url, cancellation: cancellation)
|
||||
case "pdf":
|
||||
// Tables in a PDF: best effort, one row per line.
|
||||
let text = PDFConverter.extractText(url)
|
||||
return text
|
||||
.components(separatedBy: "\n")
|
||||
.filter { !$0.trimmed.isEmpty }
|
||||
.map { [$0] }
|
||||
default:
|
||||
throw ConversionError.unsupported("Konverter cannot read .\(url.pathExtension) spreadsheets without LibreOffice.")
|
||||
}
|
||||
}
|
||||
|
||||
private static func string(from url: URL) throws -> String {
|
||||
if let utf8 = try? String(contentsOf: url, encoding: .utf8) { return utf8 }
|
||||
if let latin = try? String(contentsOf: url, encoding: .isoLatin1) { return latin }
|
||||
throw ConversionError.unreadable(url.lastPathComponent)
|
||||
}
|
||||
|
||||
/// RFC 4180-ish: quotes, doubled quotes, embedded newlines.
|
||||
static func parseDelimited(_ text: String, delimiter: Character) -> Rows {
|
||||
var rows: Rows = []
|
||||
var row: [String] = []
|
||||
var field = ""
|
||||
var inQuotes = false
|
||||
var iterator = text.makeIterator()
|
||||
var pending: Character?
|
||||
|
||||
while let character = pending ?? iterator.next() {
|
||||
pending = nil
|
||||
if inQuotes {
|
||||
if character == "\"" {
|
||||
if let next = iterator.next() {
|
||||
if next == "\"" {
|
||||
field.append("\"")
|
||||
} else {
|
||||
inQuotes = false
|
||||
pending = next
|
||||
}
|
||||
} else {
|
||||
inQuotes = false
|
||||
}
|
||||
} else {
|
||||
field.append(character)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch character {
|
||||
case "\"":
|
||||
inQuotes = true
|
||||
case delimiter:
|
||||
row.append(field)
|
||||
field = ""
|
||||
case "\r":
|
||||
break
|
||||
case "\n":
|
||||
row.append(field)
|
||||
field = ""
|
||||
rows.append(row)
|
||||
row = []
|
||||
default:
|
||||
field.append(character)
|
||||
}
|
||||
}
|
||||
if !field.isEmpty || !row.isEmpty {
|
||||
row.append(field)
|
||||
rows.append(row)
|
||||
}
|
||||
// Drop a trailing empty row caused by a final newline.
|
||||
if let last = rows.last, last.allSatisfy({ $0.isEmpty }) { rows.removeLast() }
|
||||
return rows
|
||||
}
|
||||
|
||||
// MARK: - Writing
|
||||
|
||||
static func write(_ rows: Rows, to url: URL, delimiter: String) throws {
|
||||
let text = rows.map { row in
|
||||
row.map { escape($0, delimiter: delimiter) }.joined(separator: delimiter)
|
||||
}.joined(separator: "\n")
|
||||
try text.write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
private static func escape(_ field: String, delimiter: String) -> String {
|
||||
let needsQuotes = field.contains(delimiter) || field.contains("\"") || field.contains("\n") || field.contains("\r")
|
||||
guard needsQuotes else { return field }
|
||||
return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\""
|
||||
}
|
||||
|
||||
static func writePDF(_ rows: Rows, at url: URL) throws {
|
||||
let columnCount = rows.map(\.count).max() ?? 0
|
||||
guard columnCount > 0 else { throw ConversionError.failed("Nothing to write.") }
|
||||
var html = """
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||
body{font:11px -apple-system,sans-serif;margin:24px}
|
||||
table{border-collapse:collapse;width:100%}
|
||||
td,th{border:1px solid #d8d8dd;padding:4px 8px;vertical-align:top}
|
||||
th{background:#f4f4f7;text-align:left}
|
||||
</style></head><body><table>
|
||||
"""
|
||||
for (index, row) in rows.enumerated() {
|
||||
let tag = index == 0 ? "th" : "td"
|
||||
html += "<tr>" + (0..<columnCount).map { column in
|
||||
let value = column < row.count ? row[column] : ""
|
||||
return "<\(tag)>\(Markdown.escape(value))</\(tag)>"
|
||||
}.joined() + "</tr>"
|
||||
}
|
||||
html += "</table></body></html>"
|
||||
let attributed = try? NSAttributedString(
|
||||
data: Data(html.utf8),
|
||||
options: [.documentType: NSAttributedString.DocumentType.html],
|
||||
documentAttributes: nil
|
||||
)
|
||||
if let attributed {
|
||||
try TextDocument.writePDF(attributed, to: url, pageSize: CGSize(width: 842, height: 595))
|
||||
} else {
|
||||
try TextDocument.writePDF(fromText: rows.map { $0.joined(separator: "\t") }.joined(separator: "\n"), to: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal OOXML spreadsheet reader/writer.
|
||||
enum XLSX {
|
||||
static func read(_ url: URL, cancellation: Cancellation? = nil) throws -> SpreadsheetConverter.Rows {
|
||||
let staging = try Unzip.expand(url, cancellation: cancellation)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
|
||||
let sharedStrings = parseSharedStrings(
|
||||
at: staging.appendingPathComponent("xl/sharedStrings.xml")
|
||||
)
|
||||
let sheetURL = firstSheet(at: staging)
|
||||
guard let sheetURL else { throw ConversionError.failed("This workbook has no sheets.") }
|
||||
return try parseSheet(at: sheetURL, sharedStrings: sharedStrings)
|
||||
}
|
||||
|
||||
static func write(_ rows: SpreadsheetConverter.Rows, to url: URL) throws {
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("konverter-xlsx-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
try FileManager.default.createDirectory(
|
||||
at: staging.appendingPathComponent("xl/_rels"),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: staging.appendingPathComponent("_rels"),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: staging.appendingPathComponent("xl/worksheets"),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
try contentTypes.write(to: staging.appendingPathComponent("[Content_Types].xml"), atomically: true, encoding: .utf8)
|
||||
try packageRels.write(to: staging.appendingPathComponent("_rels/.rels"), atomically: true, encoding: .utf8)
|
||||
try workbookRels.write(to: staging.appendingPathComponent("xl/_rels/workbook.xml.rels"), atomically: true, encoding: .utf8)
|
||||
try styles.write(to: staging.appendingPathComponent("xl/styles.xml"), atomically: true, encoding: .utf8)
|
||||
try workbook.write(to: staging.appendingPathComponent("xl/workbook.xml"), atomically: true, encoding: .utf8)
|
||||
try sheetXML(rows).write(to: staging.appendingPathComponent("xl/worksheets/sheet1.xml"), atomically: true, encoding: .utf8)
|
||||
|
||||
let zip = Toolchain.zip
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
try Shell.run(zip, ["-r", "-X", "-q", url.path, "[Content_Types].xml", "_rels", "xl"], currentDirectory: staging)
|
||||
}
|
||||
|
||||
private static func sheetXML(_ rows: SpreadsheetConverter.Rows) -> String {
|
||||
var xml = """
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
|
||||
"""
|
||||
for (rowIndex, row) in rows.enumerated() {
|
||||
xml += "<row r=\"\(rowIndex + 1)\">"
|
||||
for (columnIndex, value) in row.enumerated() {
|
||||
let reference = "\(columnName(columnIndex))\(rowIndex + 1)"
|
||||
if let number = numberValue(value) {
|
||||
xml += "<c r=\"\(reference)\"><v>\(number)</v></c>"
|
||||
} else {
|
||||
xml += "<c r=\"\(reference)\" t=\"inlineStr\"><is><t xml:space=\"preserve\">\(Markdown.escape(value))</t></is></c>"
|
||||
}
|
||||
}
|
||||
xml += "</row>"
|
||||
}
|
||||
xml += "</sheetData></worksheet>"
|
||||
return xml
|
||||
}
|
||||
|
||||
/// Writes a number only when the value really is one, so phone numbers and
|
||||
/// zero-padded ids stay text.
|
||||
private static func numberValue(_ value: String) -> String? {
|
||||
let trimmed = value.trimmed
|
||||
guard !trimmed.isEmpty, trimmed == value else { return nil }
|
||||
guard trimmed.rangeOfCharacter(from: CharacterSet(charactersIn: "0123456789")) != nil else { return nil }
|
||||
guard let number = Double(trimmed), number.isFinite else { return nil }
|
||||
if trimmed.count > 1, trimmed.hasPrefix("0"), !trimmed.hasPrefix("0.") { return nil }
|
||||
return trimmed
|
||||
}
|
||||
|
||||
static func columnName(_ index: Int) -> String {
|
||||
var index = index
|
||||
var name = ""
|
||||
repeat {
|
||||
name = String(UnicodeScalar(UInt8(65 + index % 26))) + name
|
||||
index = index / 26 - 1
|
||||
} while index >= 0
|
||||
return name
|
||||
}
|
||||
|
||||
static func columnIndex(_ reference: String) -> Int {
|
||||
var index = 0
|
||||
for character in reference.uppercased() {
|
||||
guard let ascii = character.asciiValue, ascii >= 65, ascii <= 90 else { break }
|
||||
index = index * 26 + Int(ascii - 64)
|
||||
}
|
||||
return max(0, index - 1)
|
||||
}
|
||||
|
||||
// MARK: - Parsing helpers
|
||||
|
||||
private static func firstSheet(at staging: URL) -> URL? {
|
||||
let worksheets = staging.appendingPathComponent("xl/worksheets")
|
||||
let files = (try? FileManager.default.contentsOfDirectory(at: worksheets, includingPropertiesForKeys: nil)) ?? []
|
||||
return files.filter { $0.pathExtension == "xml" }.sorted { $0.lastPathComponent < $1.lastPathComponent }.first
|
||||
}
|
||||
|
||||
private static func parseSharedStrings(at url: URL) -> [String] {
|
||||
guard let data = try? Data(contentsOf: url) else { return [] }
|
||||
let parser = XMLParser(data: data)
|
||||
let delegate = SharedStringsDelegate()
|
||||
parser.delegate = delegate
|
||||
parser.parse()
|
||||
return delegate.strings
|
||||
}
|
||||
|
||||
private static func parseSheet(at url: URL, sharedStrings: [String]) throws -> SpreadsheetConverter.Rows {
|
||||
guard let data = try? Data(contentsOf: url) else {
|
||||
throw ConversionError.failed("Could not read the worksheet.")
|
||||
}
|
||||
let parser = XMLParser(data: data)
|
||||
let delegate = SheetDelegate(sharedStrings: sharedStrings)
|
||||
parser.delegate = delegate
|
||||
parser.parse()
|
||||
return delegate.rows
|
||||
}
|
||||
|
||||
private final class SharedStringsDelegate: NSObject, XMLParserDelegate {
|
||||
var strings: [String] = []
|
||||
private var current: String?
|
||||
private var buffer = ""
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) {
|
||||
if elementName == "si" { current = ""; buffer = "" }
|
||||
if elementName == "t" { buffer = "" }
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
buffer += string
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) {
|
||||
if elementName == "t" { current = (current ?? "") + buffer }
|
||||
if elementName == "si" { strings.append(current ?? ""); current = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private final class SheetDelegate: NSObject, XMLParserDelegate {
|
||||
let sharedStrings: [String]
|
||||
var rows: SpreadsheetConverter.Rows = []
|
||||
private var row: [String] = []
|
||||
private var pendingColumn = 0
|
||||
private var valueBuffer = ""
|
||||
private var cellType = ""
|
||||
private var isInline = false
|
||||
private var inCell = false
|
||||
|
||||
init(sharedStrings: [String]) {
|
||||
self.sharedStrings = sharedStrings
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) {
|
||||
switch elementName {
|
||||
case "row":
|
||||
row = []
|
||||
case "c":
|
||||
inCell = true
|
||||
cellType = attributes["t"] ?? ""
|
||||
isInline = false
|
||||
valueBuffer = ""
|
||||
if let reference = attributes["r"] {
|
||||
pendingColumn = XLSX.columnIndex(reference)
|
||||
}
|
||||
case "is":
|
||||
isInline = true
|
||||
case "v", "t":
|
||||
valueBuffer = ""
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
if inCell { valueBuffer += string }
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) {
|
||||
switch elementName {
|
||||
case "c":
|
||||
let value: String
|
||||
if cellType == "s", let index = Int(valueBuffer.trimmed), index < sharedStrings.count {
|
||||
value = sharedStrings[index]
|
||||
} else {
|
||||
value = isInline ? valueBuffer : valueBuffer.trimmed
|
||||
}
|
||||
while row.count < pendingColumn { row.append("") }
|
||||
row.append(value)
|
||||
pendingColumn = row.count
|
||||
inCell = false
|
||||
valueBuffer = ""
|
||||
case "row":
|
||||
rows.append(row)
|
||||
row = []
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Static OOXML scaffolding
|
||||
|
||||
private static let contentTypes = """
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
||||
</Types>
|
||||
"""
|
||||
|
||||
private static let packageRels = """
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
</Relationships>
|
||||
"""
|
||||
|
||||
private static let workbookRels = """
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>
|
||||
"""
|
||||
|
||||
private static let workbook = """
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>
|
||||
"""
|
||||
|
||||
private static let styles = """
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
|
||||
<borders count="1"><border/></borders>
|
||||
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
|
||||
</styleSheet>
|
||||
"""
|
||||
}
|
||||
|
||||
/// ODS is a zip of `content.xml`; a light parser is enough for flat tables.
|
||||
enum ODS {
|
||||
static func read(_ url: URL, cancellation: Cancellation? = nil) throws -> SpreadsheetConverter.Rows {
|
||||
let staging = try Unzip.expand(url, cancellation: cancellation)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
guard let data = try? Data(contentsOf: staging.appendingPathComponent("content.xml")) else {
|
||||
throw ConversionError.failed("This ODS file has no content.xml.")
|
||||
}
|
||||
let parser = XMLParser(data: data)
|
||||
let delegate = TableDelegate()
|
||||
parser.delegate = delegate
|
||||
parser.parse()
|
||||
return delegate.rows
|
||||
}
|
||||
|
||||
private final class TableDelegate: NSObject, XMLParserDelegate {
|
||||
private(set) var rows: SpreadsheetConverter.Rows = []
|
||||
private var row: [String] = []
|
||||
private var buffer = ""
|
||||
private var inCell = false
|
||||
private var inRow = false
|
||||
private var repeatColumns = 1
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) {
|
||||
switch elementName {
|
||||
case "table-row":
|
||||
inRow = true
|
||||
row = []
|
||||
case "table-cell":
|
||||
inCell = true
|
||||
buffer = ""
|
||||
repeatColumns = Int(attributes["table:number-columns-repeated"] ?? "1") ?? 1
|
||||
case "p":
|
||||
if inCell, !buffer.isEmpty { buffer += " " }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
if inCell { buffer += string }
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) {
|
||||
switch elementName {
|
||||
case "table-cell":
|
||||
let value = buffer.trimmed
|
||||
for _ in 0..<min(repeatColumns, 256) { row.append(value) }
|
||||
inCell = false
|
||||
case "table-row":
|
||||
if !row.allSatisfy({ $0.isEmpty }) { rows.append(row) }
|
||||
inRow = false
|
||||
case "table":
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Unzip {
|
||||
/// Expands an archive and returns the staging directory the caller must
|
||||
/// delete.
|
||||
static func expand(_ url: URL, cancellation: Cancellation? = nil) throws -> URL {
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("konverter-unzip-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||
do {
|
||||
try Shell.run(Toolchain.unzip, ["-qq", "-o", url.path, "-d", staging.path], cancellation: cancellation)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: staging)
|
||||
throw error
|
||||
}
|
||||
return staging
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user