PrintService.swift 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948
  1. import Cocoa
  2. import CoreText
  3. import PDFKit
  4. import UniformTypeIdentifiers
  5. enum PrintService {
  6. static func printContacts(_ contacts: [PrintableContact], from window: NSWindow? = nil) {
  7. guard !contacts.isEmpty else {
  8. showNoContactsAlert()
  9. return
  10. }
  11. let text = formattedContactsText(contacts)
  12. guard let document = pdfDocument(from: text) else {
  13. showPrintFailedAlert()
  14. return
  15. }
  16. printPDF(document, title: "Print Contacts", from: window ?? NSApp.keyWindow)
  17. }
  18. static func printCanvas(_ image: NSImage, from window: NSWindow? = nil) {
  19. guard image.size.width > 0, image.size.height > 0 else {
  20. showEmptyCanvasAlert()
  21. return
  22. }
  23. printImage(image, title: "Blank Canvas", from: window)
  24. }
  25. static func printPhoto(_ image: NSImage, title: String = "Photo", from window: NSWindow? = nil) {
  26. guard image.size.width > 0, image.size.height > 0 else {
  27. showPrintFailedAlert()
  28. return
  29. }
  30. printImage(image, title: title, from: window)
  31. }
  32. static func saveCanvasPDF(_ image: NSImage) {
  33. guard image.size.width > 0, image.size.height > 0 else {
  34. showEmptyCanvasAlert()
  35. return
  36. }
  37. guard let document = pdfDocument(from: image) else {
  38. showSaveFailedAlert()
  39. return
  40. }
  41. let panel = NSSavePanel()
  42. panel.title = "Save as PDF"
  43. panel.message = "Choose where to save your canvas."
  44. panel.prompt = "Save"
  45. panel.nameFieldStringValue = "Blank Canvas.pdf"
  46. panel.allowedContentTypes = [.pdf]
  47. panel.canCreateDirectories = true
  48. guard panel.runModal() == .OK, let url = panel.url else { return }
  49. guard document.write(to: url) else {
  50. showSaveFailedAlert()
  51. return
  52. }
  53. }
  54. static func printText(_ text: String, title: String = "Print Text", from window: NSWindow? = nil) {
  55. let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
  56. guard !trimmed.isEmpty else {
  57. showEmptyTextAlert()
  58. return
  59. }
  60. guard let document = pdfDocument(from: trimmed) else {
  61. showPrintFailedAlert()
  62. return
  63. }
  64. printPDF(document, title: title, from: window ?? NSApp.keyWindow)
  65. }
  66. static func saveAttributedTextAsPDF(
  67. _ attributed: NSAttributedString,
  68. defaultName: String = "Scan",
  69. from window: NSWindow? = nil
  70. ) {
  71. guard hasPrintableContent(attributed) else {
  72. showEmptyTextAlert()
  73. return
  74. }
  75. guard let document = pdfDocument(from: attributed) else {
  76. showSaveFailedAlert()
  77. return
  78. }
  79. let panel = NSSavePanel()
  80. panel.title = "Save as PDF"
  81. panel.message = "Choose where to save your document."
  82. panel.prompt = "Save"
  83. panel.nameFieldStringValue = "\(defaultName).pdf"
  84. panel.allowedContentTypes = [.pdf]
  85. panel.canCreateDirectories = true
  86. guard panel.runModal() == .OK, let url = panel.url else { return }
  87. guard document.write(to: url) else {
  88. showSaveFailedAlert()
  89. return
  90. }
  91. }
  92. static func printAttributedText(
  93. _ attributed: NSAttributedString,
  94. title: String = "Scan",
  95. from window: NSWindow? = nil
  96. ) {
  97. guard hasPrintableContent(attributed) else {
  98. showEmptyTextAlert()
  99. return
  100. }
  101. guard let document = pdfDocument(from: attributed) else {
  102. showPrintFailedAlert()
  103. return
  104. }
  105. printPDF(document, title: title, from: window ?? NSApp.keyWindow)
  106. }
  107. static func saveParsedScansAsPDF(
  108. pages: [ParsedScanPage],
  109. defaultName: String = "Scan",
  110. from window: NSWindow? = nil
  111. ) {
  112. guard !pages.isEmpty else {
  113. showEmptyTextAlert()
  114. return
  115. }
  116. guard let document = pdfDocument(from: pages) else {
  117. showSaveFailedAlert()
  118. return
  119. }
  120. let panel = NSSavePanel()
  121. panel.title = "Save as PDF"
  122. panel.message = "Choose where to save your document."
  123. panel.prompt = "Save"
  124. panel.nameFieldStringValue = "\(defaultName).pdf"
  125. panel.allowedContentTypes = [.pdf]
  126. panel.canCreateDirectories = true
  127. guard panel.runModal() == .OK, let url = panel.url else { return }
  128. guard document.write(to: url) else {
  129. showSaveFailedAlert()
  130. return
  131. }
  132. }
  133. static func printParsedScans(
  134. pages: [ParsedScanPage],
  135. title: String = "Scan",
  136. from window: NSWindow? = nil
  137. ) {
  138. guard !pages.isEmpty else {
  139. showEmptyTextAlert()
  140. return
  141. }
  142. guard let document = pdfDocument(from: pages) else {
  143. showPrintFailedAlert()
  144. return
  145. }
  146. printPDF(document, title: title, from: window ?? NSApp.keyWindow)
  147. }
  148. static func saveTextAsPDF(
  149. _ text: String,
  150. defaultName: String = "Scan",
  151. from window: NSWindow? = nil
  152. ) {
  153. let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
  154. guard !trimmed.isEmpty else {
  155. showEmptyTextAlert()
  156. return
  157. }
  158. guard let document = pdfDocument(from: trimmed) else {
  159. showSaveFailedAlert()
  160. return
  161. }
  162. let panel = NSSavePanel()
  163. panel.title = "Save as PDF"
  164. panel.message = "Choose where to save your document."
  165. panel.prompt = "Save"
  166. panel.nameFieldStringValue = "\(defaultName).pdf"
  167. panel.allowedContentTypes = [.pdf]
  168. panel.canCreateDirectories = true
  169. guard panel.runModal() == .OK, let url = panel.url else { return }
  170. guard document.write(to: url) else {
  171. showSaveFailedAlert()
  172. return
  173. }
  174. }
  175. static func print(urls: [URL], from window: NSWindow? = nil) {
  176. guard !urls.isEmpty else { return }
  177. let hostWindow = window ?? NSApp.keyWindow
  178. for url in urls {
  179. let accessed = url.startAccessingSecurityScopedResource()
  180. defer {
  181. if accessed { url.stopAccessingSecurityScopedResource() }
  182. }
  183. printFile(at: url, from: hostWindow)
  184. }
  185. }
  186. static func printFile(at url: URL, from window: NSWindow? = nil) {
  187. let title = url.lastPathComponent
  188. guard let document = printablePDFDocument(from: url) else {
  189. showUnsupportedAlert(for: url)
  190. return
  191. }
  192. printPDF(document, title: title, from: window ?? NSApp.keyWindow)
  193. }
  194. private static func contentType(for url: URL) -> UTType? {
  195. if let values = try? url.resourceValues(forKeys: [.contentTypeKey]),
  196. let type = values.contentType {
  197. return type
  198. }
  199. let ext = url.pathExtension.lowercased()
  200. guard !ext.isEmpty else { return nil }
  201. return UTType(filenameExtension: ext)
  202. }
  203. private static func printablePDFDocument(from url: URL) -> PDFDocument? {
  204. let type = contentType(for: url) ?? UTType(filenameExtension: url.pathExtension) ?? .data
  205. if type.conforms(to: .pdf), let source = PDFDocument(url: url) {
  206. return flattenedPDF(from: source)
  207. }
  208. if type.conforms(to: .image), let image = NSImage(contentsOf: url) {
  209. return pdfDocument(from: image)
  210. }
  211. if let image = NSImage(contentsOf: url), image.isValid {
  212. return pdfDocument(from: image)
  213. }
  214. if let text = DocumentContentService.plainTextForPrinting(from: url)?
  215. .trimmingCharacters(in: .whitespacesAndNewlines),
  216. !text.isEmpty,
  217. let document = pdfDocument(from: text) {
  218. return document
  219. }
  220. if let attributed = DocumentContentService.attributedString(for: url),
  221. !attributed.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
  222. let document = pdfDocument(from: attributed) {
  223. return document
  224. }
  225. return nil
  226. }
  227. private static func printPDF(_ document: PDFDocument, title: String, from window: NSWindow?) {
  228. guard let data = document.dataRepresentation(),
  229. let printable = PDFDocument(data: data) else {
  230. showPrintFailedAlert()
  231. return
  232. }
  233. let printInfo = configuredPrintInfo()
  234. guard let operation = printable.printOperation(
  235. for: printInfo,
  236. scalingMode: .pageScaleToFit,
  237. autoRotate: false
  238. ) else {
  239. showPrintFailedAlert()
  240. return
  241. }
  242. runPrintOperation(operation, title: title, from: window)
  243. }
  244. private static func printImage(_ image: NSImage, title: String, from window: NSWindow?) {
  245. guard let document = pdfDocument(from: image) else {
  246. showPrintFailedAlert()
  247. return
  248. }
  249. printPDF(document, title: title, from: window)
  250. }
  251. private static func runPrintOperation(_ operation: NSPrintOperation, title: String, from window: NSWindow?) {
  252. operation.showsPrintPanel = true
  253. operation.showsProgressPanel = false
  254. operation.jobTitle = title
  255. operation.printPanel.options.insert([
  256. .showsCopies,
  257. .showsPageRange,
  258. .showsPaperSize,
  259. .showsOrientation,
  260. .showsScaling,
  261. ])
  262. NSApp.activate(ignoringOtherApps: true)
  263. let parentWindow = window ?? NSApp.keyWindow ?? NSApp.mainWindow
  264. parentWindow?.makeKeyAndOrderFront(nil)
  265. let observer = observePrintPanelAppearance(relativeTo: parentWindow)
  266. defer { NotificationCenter.default.removeObserver(observer) }
  267. if let parentWindow {
  268. let callback = PrintRatingCallback(window: parentWindow)
  269. operation.runModal(
  270. for: parentWindow,
  271. delegate: callback,
  272. didRun: #selector(PrintRatingCallback.printOperationDidRun(_:success:contextInfo:)),
  273. contextInfo: nil
  274. )
  275. withExtendedLifetime(callback) {}
  276. } else {
  277. operation.run()
  278. }
  279. }
  280. private final class PrintRatingCallback: NSObject {
  281. private let window: NSWindow?
  282. init(window: NSWindow?) {
  283. self.window = window
  284. }
  285. @objc func printOperationDidRun(
  286. _ printOperation: NSPrintOperation,
  287. success: Bool,
  288. contextInfo: UnsafeMutableRawPointer?
  289. ) {
  290. guard success else { return }
  291. Task { @MainActor in
  292. AppRatingManager.shared.recordSuccessfulPrint(from: window)
  293. }
  294. }
  295. }
  296. private static func observePrintPanelAppearance(relativeTo parentWindow: NSWindow?) -> NSObjectProtocol {
  297. NotificationCenter.default.addObserver(
  298. forName: NSWindow.didBecomeKeyNotification,
  299. object: nil,
  300. queue: .main
  301. ) { notification in
  302. guard let panelWindow = notification.object as? NSWindow,
  303. isPrintPanelWindow(panelWindow) else { return }
  304. configurePrintPanelWindow(panelWindow, relativeTo: parentWindow)
  305. DispatchQueue.main.async {
  306. configurePrintPanelWindow(panelWindow, relativeTo: parentWindow)
  307. }
  308. }
  309. }
  310. private static func configurePrintPanelWindow(_ panelWindow: NSWindow, relativeTo parentWindow: NSWindow?) {
  311. hideTrafficLights(on: panelWindow)
  312. if let parentWindow {
  313. center(panelWindow, relativeTo: parentWindow)
  314. }
  315. }
  316. private static func center(_ panelWindow: NSWindow, relativeTo parentWindow: NSWindow) {
  317. let parentFrame = parentWindow.frame
  318. var panelFrame = panelWindow.frame
  319. panelFrame.origin.x = parentFrame.midX - panelFrame.width / 2
  320. panelFrame.origin.y = parentFrame.midY - panelFrame.height / 2
  321. panelWindow.setFrame(panelFrame, display: true)
  322. }
  323. private static func isPrintPanelWindow(_ window: NSWindow) -> Bool {
  324. let className = String(describing: type(of: window))
  325. if className.localizedCaseInsensitiveContains("printpanel") {
  326. return true
  327. }
  328. return window.title == "Print"
  329. }
  330. private static func hideTrafficLights(on window: NSWindow) {
  331. for buttonType: NSWindow.ButtonType in [.closeButton, .miniaturizeButton, .zoomButton] {
  332. window.standardWindowButton(buttonType)?.isHidden = true
  333. }
  334. }
  335. private static func configuredPrintInfo() -> NSPrintInfo {
  336. let printInfo = (NSPrintInfo.shared.copy() as? NSPrintInfo) ?? NSPrintInfo()
  337. let printerName = AppSettings.effectiveDefaultPrinter
  338. if let printer = NSPrinter(name: printerName),
  339. NSPrinter.printerNames.contains(printerName) {
  340. printInfo.printer = printer
  341. }
  342. switch AppSettings.defaultPaperSize {
  343. case .a4: printInfo.paperName = NSPrinter.PaperName("iso-a4")
  344. case .letter: printInfo.paperName = NSPrinter.PaperName("na-letter")
  345. case .legal: printInfo.paperName = NSPrinter.PaperName("na-legal")
  346. }
  347. printInfo.horizontalPagination = .fit
  348. printInfo.verticalPagination = .fit
  349. printInfo.isHorizontallyCentered = true
  350. printInfo.isVerticallyCentered = false
  351. printInfo.topMargin = 36
  352. printInfo.bottomMargin = 36
  353. printInfo.leftMargin = 36
  354. printInfo.rightMargin = 36
  355. return printInfo
  356. }
  357. private static func hasPrintableContent(_ attributed: NSAttributedString) -> Bool {
  358. if !attributed.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
  359. return true
  360. }
  361. var hasAttachment = false
  362. attributed.enumerateAttribute(.attachment, in: NSRange(location: 0, length: attributed.length)) { value, _, stop in
  363. if value != nil {
  364. hasAttachment = true
  365. stop.pointee = true
  366. }
  367. }
  368. return hasAttachment
  369. }
  370. private static let scanFigureFraction: CGFloat = 0.52
  371. private static func pdfDocument(from pages: [ParsedScanPage]) -> PDFDocument? {
  372. guard !pages.isEmpty else { return nil }
  373. let combined = PDFDocument()
  374. for page in pages {
  375. guard let pdfPage = pdfPage(from: page) else { continue }
  376. combined.insert(pdfPage, at: combined.pageCount)
  377. }
  378. return combined.pageCount > 0 ? combined : nil
  379. }
  380. private static func pdfPage(from page: ParsedScanPage) -> PDFPage? {
  381. let bodyEmpty = page.body.plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
  382. let captionEmpty = page.caption?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
  383. if bodyEmpty && captionEmpty {
  384. return PDFPage(image: page.picture)
  385. }
  386. let printInfo = configuredPrintInfo()
  387. let pageSize = printInfo.paperSize
  388. guard pageSize.width > 0, pageSize.height > 0 else { return PDFPage(image: page.picture) }
  389. let contentWidth = pageSize.width - printInfo.leftMargin - printInfo.rightMargin
  390. let contentHeight = pageSize.height - printInfo.topMargin - printInfo.bottomMargin
  391. let pdfData = NSMutableData()
  392. var mediaBox = CGRect(origin: .zero, size: pageSize)
  393. guard let consumer = CGDataConsumer(data: pdfData as CFMutableData),
  394. let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
  395. return PDFPage(image: page.picture)
  396. }
  397. appendParsedScanPage(
  398. page,
  399. to: context,
  400. pageSize: pageSize,
  401. printInfo: printInfo,
  402. contentWidth: contentWidth,
  403. contentHeight: contentHeight
  404. )
  405. context.closePDF()
  406. guard let document = PDFDocument(data: pdfData as Data) else {
  407. return PDFPage(image: page.picture)
  408. }
  409. return document.page(at: 0)
  410. }
  411. private static func appendParsedScanPage(
  412. _ page: ParsedScanPage,
  413. to context: CGContext,
  414. pageSize: NSSize,
  415. printInfo: NSPrintInfo,
  416. contentWidth: CGFloat,
  417. contentHeight: CGFloat
  418. ) {
  419. let bodyText = page.body.plainText.trimmingCharacters(in: .whitespacesAndNewlines)
  420. let captionText = page.caption?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  421. let hasCaption = !captionText.isEmpty
  422. let hasBody = !bodyText.isEmpty
  423. let showsImage = page.showsPicture
  424. let imageFraction: CGFloat
  425. let captionFraction: CGFloat
  426. let textFraction: CGFloat
  427. if showsImage {
  428. imageFraction = hasBody || hasCaption ? 0.48 : 1.0
  429. captionFraction = hasCaption ? 0.05 : 0
  430. textFraction = hasBody ? max(0, 1 - imageFraction - captionFraction) : 0
  431. } else {
  432. imageFraction = 0
  433. captionFraction = hasCaption ? 0.05 : 0
  434. textFraction = hasBody ? max(0, 1 - captionFraction) : 0
  435. }
  436. let imageHeight = contentHeight * imageFraction
  437. let captionHeight = contentHeight * captionFraction
  438. let textHeight = contentHeight * textFraction
  439. context.beginPDFPage(nil)
  440. var cursorY = printInfo.bottomMargin + captionHeight + textHeight
  441. if showsImage {
  442. let imageRect = CGRect(
  443. x: printInfo.leftMargin,
  444. y: cursorY,
  445. width: contentWidth,
  446. height: imageHeight
  447. )
  448. drawScanImage(page.picture, in: imageRect, context: context)
  449. }
  450. if hasCaption {
  451. cursorY = printInfo.bottomMargin + textHeight
  452. let captionRect = CGRect(
  453. x: printInfo.leftMargin,
  454. y: cursorY,
  455. width: contentWidth,
  456. height: captionHeight
  457. )
  458. drawScanText(captionText, in: captionRect, context: context, pageSize: pageSize, alignment: .center)
  459. }
  460. if hasBody {
  461. switch page.body {
  462. case .single(let text):
  463. let textRect = CGRect(
  464. x: printInfo.leftMargin,
  465. y: printInfo.bottomMargin,
  466. width: contentWidth,
  467. height: textHeight
  468. )
  469. drawScanAttributedText(text, in: textRect, context: context, pageSize: pageSize)
  470. case .twoColumn(let left, let right):
  471. let columnGap: CGFloat = 14
  472. let columnWidth = (contentWidth - columnGap) / 2
  473. let leftRect = CGRect(
  474. x: printInfo.leftMargin,
  475. y: printInfo.bottomMargin,
  476. width: columnWidth,
  477. height: textHeight
  478. )
  479. let rightRect = CGRect(
  480. x: printInfo.leftMargin + columnWidth + columnGap,
  481. y: printInfo.bottomMargin,
  482. width: columnWidth,
  483. height: textHeight
  484. )
  485. drawScanAttributedText(left, in: leftRect, context: context, pageSize: pageSize)
  486. drawScanAttributedText(right, in: rightRect, context: context, pageSize: pageSize)
  487. }
  488. }
  489. context.endPDFPage()
  490. }
  491. private static func drawScanImage(_ image: NSImage, in rect: CGRect, context: CGContext) {
  492. var proposedRect = NSRect(origin: .zero, size: image.size)
  493. guard image.size.width > 0, image.size.height > 0,
  494. let cgImage = image.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) else { return }
  495. let scale = min(rect.width / image.size.width, rect.height / image.size.height)
  496. let drawSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
  497. let drawRect = CGRect(
  498. x: rect.midX - drawSize.width / 2,
  499. y: rect.midY - drawSize.height / 2,
  500. width: drawSize.width,
  501. height: drawSize.height
  502. )
  503. context.draw(cgImage, in: drawRect)
  504. }
  505. private static func drawScanText(
  506. _ text: String,
  507. in rect: CGRect,
  508. context: CGContext,
  509. pageSize: NSSize,
  510. alignment: NSTextAlignment = .left
  511. ) {
  512. guard !text.isEmpty else { return }
  513. let paragraphStyle = NSMutableParagraphStyle()
  514. paragraphStyle.alignment = alignment
  515. paragraphStyle.lineBreakMode = .byWordWrapping
  516. let attributes: [NSAttributedString.Key: Any] = [
  517. .font: NSFont.systemFont(ofSize: 11),
  518. .foregroundColor: NSColor.black,
  519. .paragraphStyle: paragraphStyle,
  520. ]
  521. drawScanAttributedText(
  522. NSAttributedString(string: text, attributes: attributes),
  523. in: rect,
  524. context: context,
  525. pageSize: pageSize
  526. )
  527. }
  528. private static func drawScanAttributedText(
  529. _ attributed: NSAttributedString,
  530. in rect: CGRect,
  531. context: CGContext,
  532. pageSize: NSSize
  533. ) {
  534. guard attributed.length > 0 else { return }
  535. let normalized = scanAttributedStringForPrinting(attributed)
  536. context.saveGState()
  537. context.translateBy(x: 0, y: pageSize.height)
  538. context.scaleBy(x: 1, y: -1)
  539. let flippedY = pageSize.height - rect.maxY
  540. let drawRect = CGRect(x: rect.origin.x, y: flippedY, width: rect.width, height: rect.height)
  541. NSGraphicsContext.saveGraphicsState()
  542. NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
  543. normalized.draw(with: drawRect, options: [.usesLineFragmentOrigin, .usesFontLeading])
  544. NSGraphicsContext.restoreGraphicsState()
  545. context.restoreGState()
  546. }
  547. private static func scanAttributedStringForPrinting(_ source: NSAttributedString) -> NSAttributedString {
  548. let mutable = NSMutableAttributedString(attributedString: source)
  549. let fullRange = NSRange(location: 0, length: mutable.length)
  550. guard fullRange.length > 0 else { return mutable }
  551. mutable.addAttribute(.foregroundColor, value: NSColor.black, range: fullRange)
  552. mutable.enumerateAttribute(.font, in: fullRange) { value, range, _ in
  553. let font = (value as? NSFont) ?? NSFont.systemFont(ofSize: 11)
  554. let scaled = NSFontManager.shared.convert(font, toSize: 11)
  555. mutable.addAttribute(.font, value: scaled, range: range)
  556. }
  557. return mutable
  558. }
  559. private static func pdfDocument(from text: String) -> PDFDocument? {
  560. let paragraphStyle = NSMutableParagraphStyle()
  561. paragraphStyle.alignment = .left
  562. paragraphStyle.lineBreakMode = .byWordWrapping
  563. let attributes: [NSAttributedString.Key: Any] = [
  564. .font: NSFont.systemFont(ofSize: 12),
  565. .foregroundColor: NSColor.black,
  566. .paragraphStyle: paragraphStyle,
  567. ]
  568. return pdfDocument(from: NSAttributedString(string: text, attributes: attributes))
  569. }
  570. private static func pdfDocument(from attributed: NSAttributedString) -> PDFDocument? {
  571. let normalized = attributedStringForPrinting(attributed)
  572. guard normalized.length > 0 else { return nil }
  573. if let document = pdfDocumentUsingRenderedImages(from: normalized) {
  574. return document
  575. }
  576. return pdfDocumentUsingCoreText(from: normalized)
  577. }
  578. private static func attributedStringForPrinting(_ source: NSAttributedString) -> NSAttributedString {
  579. let mutable = NSMutableAttributedString(attributedString: source)
  580. let fullRange = NSRange(location: 0, length: mutable.length)
  581. guard fullRange.length > 0 else { return mutable }
  582. mutable.addAttribute(.foregroundColor, value: NSColor.black, range: fullRange)
  583. mutable.enumerateAttribute(.font, in: fullRange) { value, range, _ in
  584. if value == nil {
  585. mutable.addAttribute(.font, value: NSFont.systemFont(ofSize: 12), range: range)
  586. }
  587. }
  588. return mutable
  589. }
  590. private static func pdfDocumentUsingCoreText(from attributed: NSAttributedString) -> PDFDocument? {
  591. let printInfo = configuredPrintInfo()
  592. let pageSize = printInfo.paperSize
  593. guard pageSize.width > 0, pageSize.height > 0 else { return nil }
  594. let frameRect = CGRect(
  595. x: printInfo.leftMargin,
  596. y: printInfo.bottomMargin,
  597. width: pageSize.width - printInfo.leftMargin - printInfo.rightMargin,
  598. height: pageSize.height - printInfo.topMargin - printInfo.bottomMargin
  599. )
  600. let pdfData = NSMutableData()
  601. var mediaBox = CGRect(origin: .zero, size: pageSize)
  602. guard let consumer = CGDataConsumer(data: pdfData as CFMutableData),
  603. let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
  604. return nil
  605. }
  606. let framesetter = CTFramesetterCreateWithAttributedString(attributed as CFAttributedString)
  607. var currentIndex = 0
  608. var pageCount = 0
  609. while currentIndex < attributed.length {
  610. context.beginPDFPage(nil)
  611. let path = CGPath(rect: frameRect, transform: nil)
  612. let range = CFRange(location: currentIndex, length: attributed.length - currentIndex)
  613. let frame = CTFramesetterCreateFrame(framesetter, range, path, nil)
  614. CTFrameDraw(frame, context)
  615. let visibleRange = CTFrameGetVisibleStringRange(frame)
  616. context.endPDFPage()
  617. pageCount += 1
  618. guard visibleRange.length > 0 else { break }
  619. currentIndex += visibleRange.length
  620. }
  621. context.closePDF()
  622. guard pageCount > 0 else { return nil }
  623. return PDFDocument(data: pdfData as Data)
  624. }
  625. /// Renders each text page to a bitmap and embeds it in the PDF, matching the photo print pipeline.
  626. private static func pdfDocumentUsingRenderedImages(from attributed: NSAttributedString) -> PDFDocument? {
  627. let printInfo = configuredPrintInfo()
  628. let pageSize = printInfo.paperSize
  629. guard pageSize.width > 0, pageSize.height > 0 else { return nil }
  630. let textWidth = pageSize.width - printInfo.leftMargin - printInfo.rightMargin
  631. let textHeight = pageSize.height - printInfo.topMargin - printInfo.bottomMargin
  632. let containerSize = NSSize(width: textWidth, height: textHeight)
  633. let textStorage = NSTextStorage(attributedString: attributed)
  634. let layoutManager = NSLayoutManager()
  635. textStorage.addLayoutManager(layoutManager)
  636. let pdfData = NSMutableData()
  637. var mediaBox = CGRect(origin: .zero, size: pageSize)
  638. guard let consumer = CGDataConsumer(data: pdfData as CFMutableData),
  639. let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
  640. return nil
  641. }
  642. var pageCount = 0
  643. while true {
  644. let textContainer = NSTextContainer(size: containerSize)
  645. textContainer.lineFragmentPadding = 0
  646. layoutManager.addTextContainer(textContainer)
  647. layoutManager.ensureLayout(for: textContainer)
  648. let glyphRange = layoutManager.glyphRange(for: textContainer)
  649. guard glyphRange.length > 0 else { break }
  650. let charRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
  651. let pageText = attributed.attributedSubstring(from: charRange)
  652. let pageImage = renderTextPageImage(
  653. pageText: pageText,
  654. pageSize: pageSize,
  655. printInfo: printInfo
  656. )
  657. context.beginPDFPage(nil)
  658. if let cgImage = pageImage.cgImage(forProposedRect: nil, context: nil, hints: nil) {
  659. context.draw(cgImage, in: CGRect(origin: .zero, size: pageSize))
  660. }
  661. context.endPDFPage()
  662. pageCount += 1
  663. if NSMaxRange(glyphRange) >= layoutManager.numberOfGlyphs { break }
  664. }
  665. context.closePDF()
  666. guard pageCount > 0 else { return nil }
  667. return PDFDocument(data: pdfData as Data)
  668. }
  669. private static func renderTextPageImage(
  670. pageText: NSAttributedString,
  671. pageSize: NSSize,
  672. printInfo: NSPrintInfo
  673. ) -> NSImage {
  674. let textRect = NSRect(
  675. x: printInfo.leftMargin,
  676. y: printInfo.bottomMargin,
  677. width: pageSize.width - printInfo.leftMargin - printInfo.rightMargin,
  678. height: pageSize.height - printInfo.topMargin - printInfo.bottomMargin
  679. )
  680. let image = NSImage(size: pageSize)
  681. image.lockFocus()
  682. NSColor.white.setFill()
  683. NSRect(origin: .zero, size: pageSize).fill()
  684. pageText.draw(
  685. with: textRect,
  686. options: [.usesLineFragmentOrigin, .usesFontLeading]
  687. )
  688. image.unlockFocus()
  689. return image
  690. }
  691. private static func flattenedPDF(from source: PDFDocument) -> PDFDocument? {
  692. guard source.pageCount > 0 else { return nil }
  693. let printInfo = configuredPrintInfo()
  694. let pageSize = printInfo.paperSize
  695. guard pageSize.width > 0, pageSize.height > 0 else { return nil }
  696. let pdfData = NSMutableData()
  697. var mediaBox = CGRect(origin: .zero, size: pageSize)
  698. guard let consumer = CGDataConsumer(data: pdfData as CFMutableData),
  699. let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
  700. return nil
  701. }
  702. for index in 0..<source.pageCount {
  703. guard let page = source.page(at: index) else { continue }
  704. let pageBounds = page.bounds(for: .mediaBox)
  705. guard pageBounds.width > 0, pageBounds.height > 0 else { continue }
  706. context.beginPDFPage(nil)
  707. context.saveGState()
  708. let scale = min(pageSize.width / pageBounds.width, pageSize.height / pageBounds.height)
  709. let drawWidth = pageBounds.width * scale
  710. let drawHeight = pageBounds.height * scale
  711. let offsetX = (pageSize.width - drawWidth) / 2
  712. let offsetY = (pageSize.height - drawHeight) / 2
  713. context.translateBy(x: offsetX, y: offsetY)
  714. context.scaleBy(x: scale, y: scale)
  715. context.translateBy(x: -pageBounds.origin.x, y: -pageBounds.origin.y)
  716. page.draw(with: .mediaBox, to: context)
  717. context.restoreGState()
  718. context.endPDFPage()
  719. }
  720. context.closePDF()
  721. guard !(pdfData as Data).isEmpty else { return nil }
  722. return PDFDocument(data: pdfData as Data)
  723. }
  724. private static func pdfDocument(from image: NSImage) -> PDFDocument? {
  725. let printInfo = configuredPrintInfo()
  726. let pageSize = printInfo.paperSize
  727. guard pageSize.width > 0, pageSize.height > 0 else { return nil }
  728. let pdfData = NSMutableData()
  729. var mediaBox = CGRect(origin: .zero, size: pageSize)
  730. guard let consumer = CGDataConsumer(data: pdfData as CFMutableData),
  731. let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
  732. return nil
  733. }
  734. context.beginPDFPage(nil)
  735. let imageSize = image.size
  736. if imageSize.width > 0, imageSize.height > 0,
  737. let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
  738. let scale = min(pageSize.width / imageSize.width, pageSize.height / imageSize.height)
  739. let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
  740. let drawRect = CGRect(
  741. x: (pageSize.width - drawSize.width) / 2,
  742. y: (pageSize.height - drawSize.height) / 2,
  743. width: drawSize.width,
  744. height: drawSize.height
  745. )
  746. context.draw(cgImage, in: drawRect)
  747. }
  748. context.endPDFPage()
  749. context.closePDF()
  750. return PDFDocument(data: pdfData as Data)
  751. }
  752. private static func formattedContactsText(_ contacts: [PrintableContact]) -> String {
  753. contacts.map { contact in
  754. var lines = [contact.displayName]
  755. if let organization = contact.organization, !organization.isEmpty,
  756. organization.caseInsensitiveCompare(contact.displayName) != .orderedSame {
  757. lines.append("Organization: \(organization)")
  758. }
  759. for phone in contact.phoneNumbers {
  760. lines.append("Phone: \(phone)")
  761. }
  762. for email in contact.emailAddresses {
  763. lines.append("Email: \(email)")
  764. }
  765. for address in contact.postalAddresses {
  766. lines.append("Address:\n\(address)")
  767. }
  768. return lines.joined(separator: "\n")
  769. }
  770. .joined(separator: "\n\n" + String(repeating: "─", count: 40) + "\n\n")
  771. }
  772. private static func showNoContactsAlert() {
  773. let alert = NSAlert()
  774. alert.messageText = "No Contacts Selected"
  775. alert.informativeText = "Select at least one contact to print."
  776. alert.alertStyle = .informational
  777. alert.addButton(withTitle: "OK")
  778. alert.runModal()
  779. }
  780. private static func showPrintFailedAlert() {
  781. let alert = NSAlert()
  782. alert.messageText = "Print Failed"
  783. alert.informativeText = "The text could not be prepared for printing."
  784. alert.alertStyle = .warning
  785. alert.addButton(withTitle: "OK")
  786. alert.runModal()
  787. }
  788. private static func showSaveFailedAlert() {
  789. let alert = NSAlert()
  790. alert.messageText = "Save Failed"
  791. alert.informativeText = "The canvas could not be saved as a PDF."
  792. alert.alertStyle = .warning
  793. alert.addButton(withTitle: "OK")
  794. alert.runModal()
  795. }
  796. static func showEmptyCanvasAlert() {
  797. let alert = NSAlert()
  798. alert.messageText = "Nothing to Print"
  799. alert.informativeText = "Add a drawing, text, or image to the canvas before printing."
  800. alert.alertStyle = .informational
  801. alert.addButton(withTitle: "OK")
  802. alert.runModal()
  803. }
  804. private static func showEmptyTextAlert() {
  805. let alert = NSAlert()
  806. alert.messageText = "No Text to Print"
  807. alert.informativeText = "Enter some text before printing."
  808. alert.alertStyle = .informational
  809. alert.addButton(withTitle: "OK")
  810. alert.runModal()
  811. }
  812. private static func showUnsupportedAlert(for url: URL) {
  813. let alert = NSAlert()
  814. alert.messageText = "Unsupported File"
  815. alert.informativeText = "\"\(url.lastPathComponent)\" cannot be printed."
  816. alert.alertStyle = .warning
  817. alert.addButton(withTitle: "OK")
  818. alert.runModal()
  819. }
  820. }