PrintService.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. import Cocoa
  2. import PDFKit
  3. import UniformTypeIdentifiers
  4. enum PrintService {
  5. static func printContacts(_ contacts: [PrintableContact], from window: NSWindow? = nil) {
  6. guard !contacts.isEmpty else {
  7. showNoContactsAlert()
  8. return
  9. }
  10. let text = formattedContactsText(contacts)
  11. guard let document = pdfDocument(from: text) else {
  12. showPrintFailedAlert()
  13. return
  14. }
  15. printPDF(document, title: "Print Contacts", from: window ?? NSApp.keyWindow)
  16. }
  17. static func printCanvas(_ image: NSImage, from window: NSWindow? = nil) {
  18. guard image.size.width > 0, image.size.height > 0 else {
  19. showEmptyCanvasAlert()
  20. return
  21. }
  22. printImage(image, title: "Blank Canvas", from: window)
  23. }
  24. static func printPhoto(_ image: NSImage, title: String = "Photo", from window: NSWindow? = nil) {
  25. guard image.size.width > 0, image.size.height > 0 else {
  26. showPrintFailedAlert()
  27. return
  28. }
  29. printImage(image, title: title, from: window)
  30. }
  31. static func saveCanvasPDF(_ image: NSImage) {
  32. guard image.size.width > 0, image.size.height > 0 else {
  33. showEmptyCanvasAlert()
  34. return
  35. }
  36. guard let document = pdfDocument(from: image) else {
  37. showSaveFailedAlert()
  38. return
  39. }
  40. let panel = NSSavePanel()
  41. panel.title = "Save as PDF"
  42. panel.message = "Choose where to save your canvas."
  43. panel.prompt = "Save"
  44. panel.nameFieldStringValue = "Blank Canvas.pdf"
  45. panel.allowedContentTypes = [.pdf]
  46. panel.canCreateDirectories = true
  47. guard panel.runModal() == .OK, let url = panel.url else { return }
  48. guard document.write(to: url) else {
  49. showSaveFailedAlert()
  50. return
  51. }
  52. }
  53. static func printText(_ text: String, title: String = "Print Text", from window: NSWindow? = nil) {
  54. let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
  55. guard !trimmed.isEmpty else {
  56. showEmptyTextAlert()
  57. return
  58. }
  59. guard let document = pdfDocument(from: trimmed) else {
  60. showPrintFailedAlert()
  61. return
  62. }
  63. printPDF(document, title: title, from: window ?? NSApp.keyWindow)
  64. }
  65. static func print(urls: [URL], from window: NSWindow? = nil) {
  66. guard !urls.isEmpty else { return }
  67. let hostWindow = window ?? NSApp.keyWindow
  68. for url in urls {
  69. let accessed = url.startAccessingSecurityScopedResource()
  70. defer {
  71. if accessed { url.stopAccessingSecurityScopedResource() }
  72. }
  73. printFile(at: url, from: hostWindow)
  74. }
  75. }
  76. private static func printFile(at url: URL, from window: NSWindow?) {
  77. let type = contentType(for: url) ?? UTType(filenameExtension: url.pathExtension) ?? .data
  78. if type.conforms(to: .pdf), let document = PDFDocument(url: url) {
  79. printPDF(document, title: url.lastPathComponent, from: window)
  80. } else if type.conforms(to: .image), let image = NSImage(contentsOf: url) {
  81. printImage(image, title: url.lastPathComponent, from: window)
  82. } else if let image = NSImage(contentsOf: url) {
  83. printImage(image, title: url.lastPathComponent, from: window)
  84. } else if type.conforms(to: .plainText) || type.conforms(to: .text),
  85. let text = try? String(contentsOf: url, encoding: .utf8),
  86. let document = pdfDocument(from: text) {
  87. printPDF(document, title: url.lastPathComponent, from: window)
  88. } else if type.conforms(to: .rtf),
  89. let data = try? Data(contentsOf: url),
  90. let attributed = NSAttributedString(rtf: data, documentAttributes: nil) {
  91. printAttributedText(attributed, title: url.lastPathComponent, from: window)
  92. } else if type.conforms(to: .html),
  93. let data = try? Data(contentsOf: url),
  94. let attributed = NSAttributedString(html: data, documentAttributes: nil) {
  95. printAttributedText(attributed, title: url.lastPathComponent, from: window)
  96. } else {
  97. showUnsupportedAlert(for: url)
  98. }
  99. }
  100. private static func contentType(for url: URL) -> UTType? {
  101. if let values = try? url.resourceValues(forKeys: [.contentTypeKey]),
  102. let type = values.contentType {
  103. return type
  104. }
  105. let ext = url.pathExtension.lowercased()
  106. guard !ext.isEmpty else { return nil }
  107. return UTType(filenameExtension: ext)
  108. }
  109. private static func printPDF(_ document: PDFDocument, title: String, from window: NSWindow?) {
  110. let printInfo = configuredPrintInfo()
  111. guard let operation = document.printOperation(
  112. for: printInfo,
  113. scalingMode: .pageScaleToFit,
  114. autoRotate: false
  115. ) else {
  116. showPrintFailedAlert()
  117. return
  118. }
  119. runPrintOperation(operation, title: title, from: window)
  120. }
  121. private static func printImage(_ image: NSImage, title: String, from window: NSWindow?) {
  122. guard let document = pdfDocument(from: image) else {
  123. showPrintFailedAlert()
  124. return
  125. }
  126. printPDF(document, title: title, from: window)
  127. }
  128. private static func printAttributedText(_ text: NSAttributedString, title: String, from window: NSWindow?) {
  129. let printInfo = configuredPrintInfo()
  130. let pageSize = printInfo.paperSize
  131. let textView = NSTextView(frame: NSRect(origin: .zero, size: pageSize))
  132. textView.textStorage?.setAttributedString(text)
  133. textView.isEditable = false
  134. let operation = NSPrintOperation(view: textView, printInfo: printInfo)
  135. runPrintOperation(operation, title: title, from: window)
  136. }
  137. private static func runPrintOperation(_ operation: NSPrintOperation, title: String, from window: NSWindow?) {
  138. operation.showsPrintPanel = true
  139. operation.showsProgressPanel = false
  140. operation.jobTitle = title
  141. operation.printPanel.options.insert([
  142. .showsCopies,
  143. .showsPageRange,
  144. .showsPaperSize,
  145. .showsOrientation,
  146. .showsScaling,
  147. ])
  148. NSApp.activate(ignoringOtherApps: true)
  149. let parentWindow = window ?? NSApp.keyWindow ?? NSApp.mainWindow
  150. parentWindow?.makeKeyAndOrderFront(nil)
  151. let observer = observePrintPanelAppearance(relativeTo: parentWindow)
  152. defer { NotificationCenter.default.removeObserver(observer) }
  153. if let parentWindow {
  154. operation.runModal(for: parentWindow, delegate: nil, didRun: nil, contextInfo: nil)
  155. } else {
  156. _ = operation.run()
  157. }
  158. }
  159. private static func observePrintPanelAppearance(relativeTo parentWindow: NSWindow?) -> NSObjectProtocol {
  160. NotificationCenter.default.addObserver(
  161. forName: NSWindow.didBecomeKeyNotification,
  162. object: nil,
  163. queue: .main
  164. ) { notification in
  165. guard let panelWindow = notification.object as? NSWindow,
  166. isPrintPanelWindow(panelWindow) else { return }
  167. configurePrintPanelWindow(panelWindow, relativeTo: parentWindow)
  168. DispatchQueue.main.async {
  169. configurePrintPanelWindow(panelWindow, relativeTo: parentWindow)
  170. }
  171. }
  172. }
  173. private static func configurePrintPanelWindow(_ panelWindow: NSWindow, relativeTo parentWindow: NSWindow?) {
  174. hideTrafficLights(on: panelWindow)
  175. if let parentWindow {
  176. center(panelWindow, relativeTo: parentWindow)
  177. }
  178. }
  179. private static func center(_ panelWindow: NSWindow, relativeTo parentWindow: NSWindow) {
  180. let parentFrame = parentWindow.frame
  181. var panelFrame = panelWindow.frame
  182. panelFrame.origin.x = parentFrame.midX - panelFrame.width / 2
  183. panelFrame.origin.y = parentFrame.midY - panelFrame.height / 2
  184. panelWindow.setFrame(panelFrame, display: true)
  185. }
  186. private static func isPrintPanelWindow(_ window: NSWindow) -> Bool {
  187. let className = String(describing: type(of: window))
  188. if className.localizedCaseInsensitiveContains("printpanel") {
  189. return true
  190. }
  191. return window.title == "Print"
  192. }
  193. private static func hideTrafficLights(on window: NSWindow) {
  194. for buttonType: NSWindow.ButtonType in [.closeButton, .miniaturizeButton, .zoomButton] {
  195. window.standardWindowButton(buttonType)?.isHidden = true
  196. }
  197. }
  198. private static func configuredPrintInfo() -> NSPrintInfo {
  199. let printInfo = (NSPrintInfo.shared.copy() as? NSPrintInfo) ?? NSPrintInfo()
  200. let printerName = AppSettings.effectiveDefaultPrinter
  201. if let printer = NSPrinter(name: printerName),
  202. NSPrinter.printerNames.contains(printerName) {
  203. printInfo.printer = printer
  204. }
  205. switch AppSettings.defaultPaperSize {
  206. case .a4: printInfo.paperName = NSPrinter.PaperName("iso-a4")
  207. case .letter: printInfo.paperName = NSPrinter.PaperName("na-letter")
  208. case .legal: printInfo.paperName = NSPrinter.PaperName("na-legal")
  209. }
  210. printInfo.horizontalPagination = .fit
  211. printInfo.verticalPagination = .fit
  212. printInfo.isHorizontallyCentered = true
  213. printInfo.isVerticallyCentered = false
  214. printInfo.topMargin = 36
  215. printInfo.bottomMargin = 36
  216. printInfo.leftMargin = 36
  217. printInfo.rightMargin = 36
  218. return printInfo
  219. }
  220. private static func pdfDocument(from text: String) -> PDFDocument? {
  221. let printInfo = configuredPrintInfo()
  222. let pageSize = printInfo.paperSize
  223. guard pageSize.width > 0, pageSize.height > 0 else { return nil }
  224. let paragraphStyle = NSMutableParagraphStyle()
  225. paragraphStyle.alignment = .left
  226. paragraphStyle.lineBreakMode = .byWordWrapping
  227. let attributes: [NSAttributedString.Key: Any] = [
  228. .font: NSFont.systemFont(ofSize: 12),
  229. .foregroundColor: NSColor.black,
  230. .paragraphStyle: paragraphStyle,
  231. ]
  232. let attributed = NSAttributedString(string: text, attributes: attributes)
  233. let textStorage = NSTextStorage(attributedString: attributed)
  234. let layoutManager = NSLayoutManager()
  235. textStorage.addLayoutManager(layoutManager)
  236. let textWidth = pageSize.width - printInfo.leftMargin - printInfo.rightMargin
  237. let textHeight = pageSize.height - printInfo.topMargin - printInfo.bottomMargin
  238. let containerSize = NSSize(width: textWidth, height: textHeight)
  239. let pdfData = NSMutableData()
  240. var mediaBox = CGRect(origin: .zero, size: pageSize)
  241. guard let consumer = CGDataConsumer(data: pdfData as CFMutableData),
  242. let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
  243. return nil
  244. }
  245. while true {
  246. let textContainer = NSTextContainer(size: containerSize)
  247. textContainer.lineFragmentPadding = 0
  248. layoutManager.addTextContainer(textContainer)
  249. let glyphRange = layoutManager.glyphRange(for: textContainer)
  250. guard glyphRange.length > 0 else { break }
  251. let charRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
  252. let pageText = attributed.attributedSubstring(from: charRange)
  253. context.beginPDFPage(nil)
  254. drawTextPage(
  255. pageText,
  256. in: context,
  257. pageSize: pageSize,
  258. printInfo: printInfo,
  259. textWidth: textWidth,
  260. textHeight: textHeight
  261. )
  262. context.endPDFPage()
  263. if NSMaxRange(glyphRange) >= layoutManager.numberOfGlyphs { break }
  264. }
  265. context.closePDF()
  266. return PDFDocument(data: pdfData as Data)
  267. }
  268. private static func drawTextPage(
  269. _ text: NSAttributedString,
  270. in context: CGContext,
  271. pageSize: NSSize,
  272. printInfo: NSPrintInfo,
  273. textWidth: CGFloat,
  274. textHeight: CGFloat
  275. ) {
  276. context.saveGState()
  277. context.translateBy(x: 0, y: pageSize.height)
  278. context.scaleBy(x: 1, y: -1)
  279. let drawRect = CGRect(
  280. x: printInfo.leftMargin,
  281. y: printInfo.topMargin,
  282. width: textWidth,
  283. height: textHeight
  284. )
  285. NSGraphicsContext.saveGraphicsState()
  286. NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
  287. text.draw(
  288. with: drawRect,
  289. options: [.usesLineFragmentOrigin, .usesFontLeading]
  290. )
  291. NSGraphicsContext.restoreGraphicsState()
  292. context.restoreGState()
  293. }
  294. private static func pdfDocument(from image: NSImage) -> PDFDocument? {
  295. let printInfo = configuredPrintInfo()
  296. let pageSize = printInfo.paperSize
  297. guard pageSize.width > 0, pageSize.height > 0 else { return nil }
  298. let pdfData = NSMutableData()
  299. var mediaBox = CGRect(origin: .zero, size: pageSize)
  300. guard let consumer = CGDataConsumer(data: pdfData as CFMutableData),
  301. let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
  302. return nil
  303. }
  304. context.beginPDFPage(nil)
  305. let imageSize = image.size
  306. if imageSize.width > 0, imageSize.height > 0,
  307. let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
  308. let scale = min(pageSize.width / imageSize.width, pageSize.height / imageSize.height)
  309. let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
  310. let drawRect = CGRect(
  311. x: (pageSize.width - drawSize.width) / 2,
  312. y: (pageSize.height - drawSize.height) / 2,
  313. width: drawSize.width,
  314. height: drawSize.height
  315. )
  316. context.draw(cgImage, in: drawRect)
  317. }
  318. context.endPDFPage()
  319. context.closePDF()
  320. return PDFDocument(data: pdfData as Data)
  321. }
  322. private static func formattedContactsText(_ contacts: [PrintableContact]) -> String {
  323. contacts.map { contact in
  324. var lines = [contact.displayName]
  325. if let organization = contact.organization, !organization.isEmpty,
  326. organization.caseInsensitiveCompare(contact.displayName) != .orderedSame {
  327. lines.append("Organization: \(organization)")
  328. }
  329. for phone in contact.phoneNumbers {
  330. lines.append("Phone: \(phone)")
  331. }
  332. for email in contact.emailAddresses {
  333. lines.append("Email: \(email)")
  334. }
  335. for address in contact.postalAddresses {
  336. lines.append("Address:\n\(address)")
  337. }
  338. return lines.joined(separator: "\n")
  339. }
  340. .joined(separator: "\n\n" + String(repeating: "─", count: 40) + "\n\n")
  341. }
  342. private static func showNoContactsAlert() {
  343. let alert = NSAlert()
  344. alert.messageText = "No Contacts Selected"
  345. alert.informativeText = "Select at least one contact to print."
  346. alert.alertStyle = .informational
  347. alert.addButton(withTitle: "OK")
  348. alert.runModal()
  349. }
  350. private static func showPrintFailedAlert() {
  351. let alert = NSAlert()
  352. alert.messageText = "Print Failed"
  353. alert.informativeText = "The text could not be prepared for printing."
  354. alert.alertStyle = .warning
  355. alert.addButton(withTitle: "OK")
  356. alert.runModal()
  357. }
  358. private static func showSaveFailedAlert() {
  359. let alert = NSAlert()
  360. alert.messageText = "Save Failed"
  361. alert.informativeText = "The canvas could not be saved as a PDF."
  362. alert.alertStyle = .warning
  363. alert.addButton(withTitle: "OK")
  364. alert.runModal()
  365. }
  366. static func showEmptyCanvasAlert() {
  367. let alert = NSAlert()
  368. alert.messageText = "Nothing to Print"
  369. alert.informativeText = "Add a drawing, text, or image to the canvas before printing."
  370. alert.alertStyle = .informational
  371. alert.addButton(withTitle: "OK")
  372. alert.runModal()
  373. }
  374. private static func showEmptyTextAlert() {
  375. let alert = NSAlert()
  376. alert.messageText = "No Text to Print"
  377. alert.informativeText = "Enter some text before printing."
  378. alert.alertStyle = .informational
  379. alert.addButton(withTitle: "OK")
  380. alert.runModal()
  381. }
  382. private static func showUnsupportedAlert(for url: URL) {
  383. let alert = NSAlert()
  384. alert.messageText = "Unsupported File"
  385. alert.informativeText = "\"\(url.lastPathComponent)\" cannot be printed."
  386. alert.alertStyle = .warning
  387. alert.addButton(withTitle: "OK")
  388. alert.runModal()
  389. }
  390. }