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 = """
"""
for (index, row) in rows.enumerated() {
let tag = index == 0 ? "th" : "td"
html += "" + (0..\(Markdown.escape(value))\(tag)>"
}.joined() + "
"
}
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 = """
"""
for (rowIndex, row) in rows.enumerated() {
xml += ""
for (columnIndex, value) in row.enumerated() {
let reference = "\(columnName(columnIndex))\(rowIndex + 1)"
if let number = numberValue(value) {
xml += "\(number)"
} else {
xml += "\(Markdown.escape(value))"
}
}
xml += "
"
}
xml += ""
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 = """
"""
private static let packageRels = """
"""
private static let workbookRels = """
"""
private static let workbook = """
"""
private static let styles = """
"""
}
/// 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.. 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
}
}