PrintService.swift 34 KB

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