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))" }.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 } }