DocumentContentService.swift 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import Cocoa
  2. import PDFKit
  3. import QuickLookUI
  4. import UniformTypeIdentifiers
  5. enum DocumentContentService {
  6. static let textExtensions: Set<String> = ["txt", "md", "csv", "json", "xml", "html", "htm"]
  7. static let wordProcessorExtensions: Set<String> = ["doc", "docx", "odt"]
  8. static let officeExtensions: Set<String> = [
  9. "doc", "docx", "xls", "xlsx", "ppt", "pptx", "pages", "numbers", "key", "odt",
  10. ]
  11. static func contentType(for url: URL) -> UTType? {
  12. if let values = try? url.resourceValues(forKeys: [.contentTypeKey]),
  13. let type = values.contentType {
  14. return type
  15. }
  16. let ext = url.pathExtension.lowercased()
  17. guard !ext.isEmpty else { return nil }
  18. return UTType(filenameExtension: ext)
  19. }
  20. static func makePreviewView(for url: URL) -> NSView? {
  21. let ext = url.pathExtension.lowercased()
  22. let type = contentType(for: url)
  23. if type?.conforms(to: .pdf) == true, let document = PDFDocument(url: url) {
  24. let pdfView = PDFView()
  25. pdfView.document = document
  26. pdfView.autoScales = true
  27. pdfView.displayMode = .singlePageContinuous
  28. pdfView.backgroundColor = .clear
  29. return pdfView
  30. }
  31. if type?.conforms(to: .image) == true, let image = NSImage(contentsOf: url) {
  32. return makeImagePreview(image)
  33. }
  34. if let image = NSImage(contentsOf: url), image.isValid {
  35. return makeImagePreview(image)
  36. }
  37. if ext == "rtf" || type?.conforms(to: .rtf) == true,
  38. let data = try? Data(contentsOf: url),
  39. let attributed = NSAttributedString(rtf: data, documentAttributes: nil) {
  40. return makeScrollableTextPreview(attributed)
  41. }
  42. if type?.conforms(to: .html) == true,
  43. let data = try? Data(contentsOf: url),
  44. let attributed = NSAttributedString(html: data, documentAttributes: nil) {
  45. return makeScrollableTextPreview(attributed)
  46. }
  47. if textExtensions.contains(ext) || type?.conforms(to: .plainText) == true || type?.conforms(to: .text) == true,
  48. let text = readText(at: url) {
  49. return makeScrollableTextPreview(NSAttributedString(string: text))
  50. }
  51. return QuickLookPreviewHostView(url: url)
  52. }
  53. static func attributedString(for url: URL) -> NSAttributedString? {
  54. let ext = url.pathExtension.lowercased()
  55. let type = contentType(for: url)
  56. if ext == "rtf" || type?.conforms(to: .rtf) == true,
  57. let data = try? Data(contentsOf: url) {
  58. return NSAttributedString(rtf: data, documentAttributes: nil)
  59. }
  60. if type?.conforms(to: .html) == true,
  61. let data = try? Data(contentsOf: url) {
  62. return NSAttributedString(html: data, documentAttributes: nil)
  63. }
  64. if wordProcessorExtensions.contains(ext),
  65. let rtfData = rtfData(from: url) {
  66. return NSAttributedString(rtf: rtfData, documentAttributes: nil)
  67. }
  68. if let text = readText(at: url) {
  69. return NSAttributedString(string: text)
  70. }
  71. if officeExtensions.contains(ext),
  72. let text = plainText(from: url) {
  73. return NSAttributedString(string: text)
  74. }
  75. return nil
  76. }
  77. static func plainTextForPrinting(from url: URL) -> String? {
  78. let ext = url.pathExtension.lowercased()
  79. if officeExtensions.contains(ext) || wordProcessorExtensions.contains(ext) {
  80. return plainText(from: url)
  81. }
  82. return readText(at: url)
  83. }
  84. static func readText(at url: URL) -> String? {
  85. let encodings: [String.Encoding] = [.utf8, .utf16, .windowsCP1252, .isoLatin1, .macOSRoman]
  86. for encoding in encodings {
  87. if let text = try? String(contentsOf: url, encoding: encoding),
  88. !text.isEmpty {
  89. return text
  90. }
  91. }
  92. return nil
  93. }
  94. static func rtfData(from url: URL) -> Data? {
  95. convert(from: url, to: "rtf")
  96. }
  97. static func plainText(from url: URL) -> String? {
  98. guard let data = convert(from: url, to: "txt") else { return nil }
  99. return String(data: data, encoding: .utf8)
  100. }
  101. /// Reads the file in-process (security-scoped access) and pipes bytes to textutil via stdin.
  102. private static func convert(from url: URL, to format: String) -> Data? {
  103. guard let inputData = try? Data(contentsOf: url), !inputData.isEmpty else { return nil }
  104. var arguments = ["-convert", format, "-stdin", "-stdout"]
  105. let ext = url.pathExtension.lowercased()
  106. if !ext.isEmpty {
  107. arguments.append(contentsOf: ["-format", ext])
  108. }
  109. let process = Process()
  110. process.executableURL = URL(fileURLWithPath: "/usr/bin/textutil")
  111. process.arguments = arguments
  112. let inputPipe = Pipe()
  113. let outputPipe = Pipe()
  114. process.standardInput = inputPipe
  115. process.standardOutput = outputPipe
  116. process.standardError = Pipe()
  117. do {
  118. try process.run()
  119. let inputHandle = inputPipe.fileHandleForWriting
  120. inputHandle.write(inputData)
  121. try inputHandle.close()
  122. process.waitUntilExit()
  123. guard process.terminationStatus == 0 else { return nil }
  124. let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
  125. return outputData.isEmpty ? nil : outputData
  126. } catch {
  127. return nil
  128. }
  129. }
  130. static func makeScrollableTextPreview(_ text: NSAttributedString) -> NSView {
  131. let scrollView = NSScrollView()
  132. scrollView.hasVerticalScroller = true
  133. scrollView.hasHorizontalScroller = false
  134. scrollView.autohidesScrollers = true
  135. scrollView.borderType = .noBorder
  136. scrollView.drawsBackground = false
  137. let textView = NSTextView()
  138. textView.isEditable = false
  139. textView.isSelectable = true
  140. textView.drawsBackground = false
  141. textView.textStorage?.setAttributedString(text)
  142. if textView.font == nil {
  143. textView.font = AppTheme.regularFont(size: 13)
  144. }
  145. textView.textColor = AppTheme.textPrimary
  146. scrollView.documentView = textView
  147. return scrollView
  148. }
  149. private static func makeImagePreview(_ image: NSImage) -> NSView {
  150. let scrollView = NSScrollView()
  151. scrollView.hasVerticalScroller = true
  152. scrollView.hasHorizontalScroller = true
  153. scrollView.autohidesScrollers = true
  154. scrollView.borderType = .noBorder
  155. scrollView.drawsBackground = false
  156. let imageView = NSImageView()
  157. imageView.image = image
  158. imageView.imageScaling = .scaleProportionallyDown
  159. imageView.imageAlignment = .alignCenter
  160. imageView.translatesAutoresizingMaskIntoConstraints = false
  161. let container = NSView()
  162. container.translatesAutoresizingMaskIntoConstraints = false
  163. container.addSubview(imageView)
  164. let width = max(image.size.width, 1)
  165. let height = max(image.size.height, 1)
  166. NSLayoutConstraint.activate([
  167. imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  168. imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  169. imageView.topAnchor.constraint(equalTo: container.topAnchor),
  170. imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  171. container.widthAnchor.constraint(equalToConstant: width),
  172. container.heightAnchor.constraint(equalToConstant: height),
  173. ])
  174. scrollView.documentView = container
  175. return scrollView
  176. }
  177. }
  178. final class QuickLookPreviewHostView: NSView {
  179. private let previewView: QLPreviewView
  180. init(url: URL) {
  181. previewView = QLPreviewView(frame: .zero, style: .normal)!
  182. super.init(frame: .zero)
  183. translatesAutoresizingMaskIntoConstraints = false
  184. previewView.translatesAutoresizingMaskIntoConstraints = false
  185. previewView.autostarts = true
  186. previewView.previewItem = url as NSURL
  187. addSubview(previewView)
  188. NSLayoutConstraint.activate([
  189. previewView.leadingAnchor.constraint(equalTo: leadingAnchor),
  190. previewView.trailingAnchor.constraint(equalTo: trailingAnchor),
  191. previewView.topAnchor.constraint(equalTo: topAnchor),
  192. previewView.bottomAnchor.constraint(equalTo: bottomAnchor),
  193. ])
  194. }
  195. @available(*, unavailable)
  196. required init?(coder: NSCoder) { nil }
  197. }