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: "
") + "