import AppKit import CoreText import Foundation /// Text extraction and rendering shared by the document, ebook, PDF and /// spreadsheet paths. `textutil` does the heavy lifting for Office formats; /// CoreText lays text out when we have to make a PDF ourselves. enum TextDocument { static func plainText(from url: URL) -> String? { switch FileKind.canonicalExtension(url.pathExtension) { case "txt", "md", "csv", "tsv", "srt", "vtt": return (try? String(contentsOf: url, encoding: .utf8)) ?? (try? String(contentsOf: url, encoding: .isoLatin1)) case "html": if let html = try? String(contentsOf: url, encoding: .utf8) { return textFromHTML(html) } return nil case "epub": return try? EbookConverter.plainText(url) case "pdf": return PDFConverter.extractText(url) default: return textutilText(from: url) } } static func textFromHTML(_ html: String) -> String { guard let data = html.data(using: .utf8), let attributed = try? NSAttributedString( data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil ) else { return Markdown.decodeEntities(Markdown.stripTags(html)) } return attributed.string } static func textutilText(from url: URL) -> String? { guard let result = try? Shell.run(Toolchain.textutil, ["-convert", "txt", "-stdout", url.path]) else { return nil } return result.stdout } static func html(from url: URL) throws -> String { switch FileKind.canonicalExtension(url.pathExtension) { case "md": let source = try String(contentsOf: url, encoding: .utf8) return Markdown.html(from: source, title: url.deletingPathExtension().lastPathComponent) case "html": return try String(contentsOf: url, encoding: .utf8) case "txt": let source = try String(contentsOf: url, encoding: .utf8) let escaped = Markdown.escape(source) return "
\(escaped)
" case "pdf": let text = PDFConverter.extractText(url) return htmlDocument(fromPlainText: text, title: url.deletingPathExtension().lastPathComponent) case "epub": return try EbookConverter.html(url) default: let result = try Shell.run(Toolchain.textutil, ["-convert", "html", "-stdout", url.path]) return result.stdout } } static func attributedString(from url: URL) -> NSAttributedString? { if let data = try? Data(contentsOf: url), let attributed = try? NSAttributedString( data: data, options: [.documentType: documentType(for: url), .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil ) { return attributed } // Fall back to HTML produced by textutil, which every Office format can // be converted to. if let html = try? html(from: url), let data = html.data(using: .utf8) { return try? NSAttributedString( data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil ) } return nil } private static func documentType(for url: URL) -> NSAttributedString.DocumentType { switch FileKind.canonicalExtension(url.pathExtension) { case "rtf": return .rtf case "rtfd": return .rtfd case "html": return .html case "doc": return .docFormat case "docx": return .officeOpenXML case "odt": return .openDocument default: return .plain } } static func htmlDocument(fromPlainText text: String, title: String) -> String { let paragraphs = text .components(separatedBy: "\n\n") .map { $0.trimmed } .filter { !$0.isEmpty } .map { "

" + Markdown.escape($0).replacingOccurrences(of: "\n", with: "
") + "

" } .joined(separator: "\n") return """ \(Markdown.escape(title)) \(paragraphs) """ } static func write(_ text: String, to url: URL) throws { try text.write(to: url, atomically: true, encoding: .utf8) } /// Converts text/HTML to an Office format with `textutil`. static func textutilConvert(_ source: URL, to format: Format, at destination: URL, cancellation: Cancellation? = nil) throws { guard let identifier = textutilIdentifier(for: format) else { throw ConversionError.unsupported("textutil cannot write \(format.displayName).") } try Shell.run( Toolchain.textutil, ["-convert", identifier, "-output", destination.path, source.path], cancellation: cancellation ) } static func textutilConvert(sourceHTML: String, to format: Format, at destination: URL, cancellation: Cancellation? = nil) throws { let temporary = FileManager.default.temporaryDirectory .appendingPathComponent("konverter-\(UUID().uuidString).html") defer { try? FileManager.default.removeItem(at: temporary) } try sourceHTML.write(to: temporary, atomically: true, encoding: .utf8) try textutilConvert(temporary, to: format, at: destination, cancellation: cancellation) } private static func textutilIdentifier(for format: Format) -> String? { switch format { case .txt: return "txt" case .html: return "html" case .rtf: return "rtf" case .docx: return "docx" case .doc: return "doc" case .odt: return "odt" default: return nil } } // MARK: - PDF layout /// Paginates an attributed string into a PDF at A4 with 56 pt margins. static func writePDF(_ attributed: NSAttributedString, to url: URL, pageSize: CGSize = CGSize(width: 595, height: 842)) throws { let margin: CGFloat = 56 var mediaBox = CGRect(origin: .zero, size: pageSize) guard let context = CGContext(url as CFURL, mediaBox: &mediaBox, nil) else { throw ConversionError.failed("Could not create \(url.lastPathComponent).") } let text = NSMutableAttributedString(attributedString: attributed) if text.length == 0 { text.append(NSAttributedString(string: " ")) } // A readable default for documents that carry no fonts. text.enumerateAttribute(.font, in: NSRange(location: 0, length: text.length)) { value, range, _ in if value == nil { text.addAttribute(.font, value: NSFont.systemFont(ofSize: 12), range: range) } } let framesetter = CTFramesetterCreateWithAttributedString(text as CFAttributedString) let textRect = CGRect( x: margin, y: margin, width: pageSize.width - margin * 2, height: pageSize.height - margin * 2 ) let path = CGPath(rect: textRect, transform: nil) var cursor = 0 let total = text.length repeat { context.beginPDFPage(nil) let frame = CTFramesetterCreateFrame( framesetter, CFRange(location: cursor, length: 0), path, nil ) CTFrameDraw(frame, context) context.endPDFPage() let visible = CTFrameGetVisibleStringRange(frame) if visible.length <= 0 { break } cursor = visible.location + visible.length } while cursor < total context.closePDF() } static func writePDF(fromText text: String, to url: URL, pageSize: CGSize = CGSize(width: 595, height: 842)) throws { let style = NSMutableParagraphStyle() style.lineSpacing = 3 let attributed = NSAttributedString( string: text, attributes: [ .font: NSFont.systemFont(ofSize: 12), .paragraphStyle: style ] ) try writePDF(attributed, to: url, pageSize: pageSize) } }