OCRFileView.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. import Cocoa
  2. import UniformTypeIdentifiers
  3. import Vision
  4. // MARK: - OCR Service
  5. enum OCRError: LocalizedError {
  6. case invalidImage
  7. case recognitionFailed
  8. case noTextFound
  9. var errorDescription: String? {
  10. switch self {
  11. case .invalidImage:
  12. return "The selected file could not be opened as an image."
  13. case .recognitionFailed:
  14. return "Text recognition failed. Please try another image."
  15. case .noTextFound:
  16. return "No text was found in this image."
  17. }
  18. }
  19. }
  20. enum OCRFileService {
  21. private static let photoExtensions: Set<String> = [
  22. "jpg", "jpeg", "png", "heic", "heif", "gif", "tiff", "tif", "bmp", "webp",
  23. ]
  24. static func present(from window: NSWindow?) {
  25. let hostWindow = window ?? NSApp.keyWindow
  26. guard PremiumAccess.require(feature: .ocrFile, from: hostWindow) else { return }
  27. let panel = NSOpenPanel()
  28. panel.title = "OCR File"
  29. panel.message = "Select an image to extract text from."
  30. panel.prompt = "Select Image"
  31. panel.canChooseDirectories = false
  32. panel.canChooseFiles = true
  33. panel.allowsMultipleSelection = false
  34. panel.allowsOtherFileTypes = false
  35. panel.allowedContentTypes = photoExtensions.compactMap { UTType(filenameExtension: $0) }
  36. panel.directoryURL = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first
  37. let completion: (NSApplication.ModalResponse) -> Void = { response in
  38. guard response == .OK, let url = panel.url else { return }
  39. let hostWindow = window ?? NSApp.keyWindow
  40. guard let viewController = PremiumAccess.mainViewController(from: hostWindow) else { return }
  41. viewController.presentOCRFile(imageURL: url)
  42. }
  43. if let window = window ?? NSApp.keyWindow {
  44. panel.beginSheetModal(for: window, completionHandler: completion)
  45. } else if panel.runModal() == .OK {
  46. completion(.OK)
  47. }
  48. }
  49. static func recognizeText(from url: URL) async throws -> String {
  50. let accessed = url.startAccessingSecurityScopedResource()
  51. defer {
  52. if accessed { url.stopAccessingSecurityScopedResource() }
  53. }
  54. guard let image = NSImage(contentsOf: url),
  55. let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
  56. throw OCRError.invalidImage
  57. }
  58. return try await recognizeText(in: cgImage)
  59. }
  60. static func recognizeText(from image: NSImage) async throws -> String {
  61. guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
  62. throw OCRError.invalidImage
  63. }
  64. return try await recognizeText(in: cgImage)
  65. }
  66. /// Recognizes text from each image independently. Returns an empty string for pages with no text.
  67. static func recognizeTextPages(from images: [NSImage]) async -> [String] {
  68. var results: [String] = []
  69. for image in images {
  70. if let text = try? await recognizeText(from: image) {
  71. results.append(text.trimmingCharacters(in: .whitespacesAndNewlines))
  72. } else {
  73. results.append("")
  74. }
  75. }
  76. return results
  77. }
  78. /// Recognizes text from multiple scan pages and joins them with blank lines between pages.
  79. static func recognizeText(from images: [NSImage]) async -> String {
  80. let pageTexts = await recognizeTextPages(from: images).filter { !$0.isEmpty }
  81. return pageTexts.joined(separator: "\n\n")
  82. }
  83. static func recognizeText(in cgImage: CGImage) async throws -> String {
  84. let observations = try await recognizeTextObservations(in: cgImage)
  85. let text = textFromObservations(observations)
  86. guard !text.isEmpty else { throw OCRError.noTextFound }
  87. return text
  88. }
  89. static func recognizeTextObservations(in cgImage: CGImage) async throws -> [VNRecognizedTextObservation] {
  90. try await withCheckedThrowingContinuation { continuation in
  91. let request = VNRecognizeTextRequest { request, error in
  92. if let error {
  93. continuation.resume(throwing: error)
  94. return
  95. }
  96. guard let observations = request.results as? [VNRecognizedTextObservation] else {
  97. continuation.resume(throwing: OCRError.recognitionFailed)
  98. return
  99. }
  100. continuation.resume(returning: observations)
  101. }
  102. request.recognitionLevel = .accurate
  103. request.usesLanguageCorrection = true
  104. let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
  105. do {
  106. try handler.perform([request])
  107. } catch {
  108. continuation.resume(throwing: error)
  109. }
  110. }
  111. }
  112. static func textFromObservations(_ observations: [VNRecognizedTextObservation]) -> String {
  113. let sorted = observations.sorted { lhs, rhs in
  114. let leftY = 1 - lhs.boundingBox.midY
  115. let rightY = 1 - rhs.boundingBox.midY
  116. if abs(leftY - rightY) > 0.02 { return leftY < rightY }
  117. return lhs.boundingBox.minX < rhs.boundingBox.minX
  118. }
  119. return sorted
  120. .compactMap { $0.topCandidates(1).first?.string }
  121. .joined(separator: "\n")
  122. .trimmingCharacters(in: .whitespacesAndNewlines)
  123. }
  124. }
  125. // MARK: - Overlay
  126. final class OCRFileOverlayView: NSView, AppearanceRefreshable {
  127. var onDismiss: (() -> Void)?
  128. private let imageURL: URL
  129. private let backButton = OCRFileToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
  130. private let titleLabel = NSTextField(labelWithString: "OCR File")
  131. private let freeUsesBanner = FreeUsesBannerView()
  132. private let textContainer = NSView()
  133. private let textScrollView = NSScrollView()
  134. private let textView = NSTextView()
  135. private let loadingIndicator = NSProgressIndicator()
  136. private let statusLabel = NSTextField(labelWithString: "Extracting text…")
  137. private let printButton = OCRFilePrintButton()
  138. init(imageURL: URL) {
  139. self.imageURL = imageURL
  140. super.init(frame: .zero)
  141. translatesAutoresizingMaskIntoConstraints = false
  142. setup()
  143. refreshAppearance()
  144. NotificationCenter.default.addObserver(
  145. self,
  146. selector: #selector(appearanceDidChange),
  147. name: .appearanceDidChange,
  148. object: nil
  149. )
  150. recognizeText()
  151. }
  152. deinit {
  153. NotificationCenter.default.removeObserver(self)
  154. }
  155. @objc private func appearanceDidChange() {
  156. refreshAppearance()
  157. }
  158. @available(*, unavailable)
  159. required init?(coder: NSCoder) { nil }
  160. func refreshAppearance() {
  161. layer?.backgroundColor = AppTheme.background.cgColor
  162. titleLabel.textColor = AppTheme.textPrimary
  163. textContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
  164. textContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
  165. statusLabel.textColor = AppTheme.textSecondary
  166. backButton.refreshAppearance()
  167. printButton.refreshAppearance()
  168. freeUsesBanner.refreshAppearance()
  169. }
  170. func present(in parent: NSView) {
  171. guard superview == nil else { return }
  172. parent.addSubview(self)
  173. NSLayoutConstraint.activate([
  174. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  175. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  176. topAnchor.constraint(equalTo: parent.topAnchor),
  177. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  178. ])
  179. alphaValue = 0
  180. NSAnimationContext.runAnimationGroup { context in
  181. context.duration = 0.2
  182. animator().alphaValue = 1
  183. }
  184. }
  185. func dismiss(animated: Bool = true) {
  186. let remove = { [weak self] in
  187. self?.removeFromSuperview()
  188. self?.onDismiss?()
  189. }
  190. guard animated else {
  191. remove()
  192. return
  193. }
  194. NSAnimationContext.runAnimationGroup({ context in
  195. context.duration = 0.15
  196. animator().alphaValue = 0
  197. }, completionHandler: remove)
  198. }
  199. private func setup() {
  200. wantsLayer = true
  201. titleLabel.font = AppTheme.semiboldFont(size: 18)
  202. titleLabel.alignment = .center
  203. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  204. textContainer.wantsLayer = true
  205. textContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
  206. textContainer.layer?.borderWidth = 1.5
  207. textContainer.applyCardShadow()
  208. textContainer.translatesAutoresizingMaskIntoConstraints = false
  209. textScrollView.translatesAutoresizingMaskIntoConstraints = false
  210. textScrollView.hasVerticalScroller = true
  211. textScrollView.hasHorizontalScroller = false
  212. textScrollView.autohidesScrollers = true
  213. textScrollView.borderType = .noBorder
  214. textScrollView.drawsBackground = false
  215. textView.isRichText = false
  216. textView.isEditable = true
  217. textView.isSelectable = true
  218. textView.drawsBackground = false
  219. textView.textColor = AppTheme.textPrimary
  220. textView.font = AppTheme.regularFont(size: 14)
  221. textView.textContainerInset = NSSize(width: 16, height: 16)
  222. textView.isAutomaticQuoteSubstitutionEnabled = true
  223. textView.isAutomaticDashSubstitutionEnabled = true
  224. textView.isAutomaticTextReplacementEnabled = true
  225. textView.isHidden = true
  226. textScrollView.documentView = textView
  227. loadingIndicator.style = .spinning
  228. loadingIndicator.controlSize = .regular
  229. loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
  230. loadingIndicator.startAnimation(nil)
  231. statusLabel.font = AppTheme.regularFont(size: 14)
  232. statusLabel.alignment = .center
  233. statusLabel.translatesAutoresizingMaskIntoConstraints = false
  234. backButton.translatesAutoresizingMaskIntoConstraints = false
  235. printButton.translatesAutoresizingMaskIntoConstraints = false
  236. printButton.isEnabled = false
  237. backButton.onClick = { [weak self] in self?.dismiss() }
  238. printButton.onClick = { [weak self] in self?.printText() }
  239. addSubview(backButton)
  240. addSubview(titleLabel)
  241. addSubview(freeUsesBanner)
  242. addSubview(textContainer)
  243. textContainer.addSubview(textScrollView)
  244. textContainer.addSubview(loadingIndicator)
  245. textContainer.addSubview(statusLabel)
  246. addSubview(printButton)
  247. NSLayoutConstraint.activate([
  248. backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
  249. backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
  250. backButton.widthAnchor.constraint(equalToConstant: 40),
  251. backButton.heightAnchor.constraint(equalToConstant: 40),
  252. titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
  253. titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
  254. freeUsesBanner.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
  255. freeUsesBanner.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 16),
  256. textContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
  257. textContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
  258. textContainer.topAnchor.constraint(equalTo: freeUsesBanner.bottomAnchor, constant: 16),
  259. textContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
  260. textScrollView.leadingAnchor.constraint(equalTo: textContainer.leadingAnchor, constant: 12),
  261. textScrollView.trailingAnchor.constraint(equalTo: textContainer.trailingAnchor, constant: -12),
  262. textScrollView.topAnchor.constraint(equalTo: textContainer.topAnchor, constant: 12),
  263. textScrollView.bottomAnchor.constraint(equalTo: textContainer.bottomAnchor, constant: -12),
  264. loadingIndicator.centerXAnchor.constraint(equalTo: textContainer.centerXAnchor),
  265. loadingIndicator.centerYAnchor.constraint(equalTo: textContainer.centerYAnchor, constant: -12),
  266. statusLabel.topAnchor.constraint(equalTo: loadingIndicator.bottomAnchor, constant: 12),
  267. statusLabel.leadingAnchor.constraint(equalTo: textContainer.leadingAnchor, constant: 24),
  268. statusLabel.trailingAnchor.constraint(equalTo: textContainer.trailingAnchor, constant: -24),
  269. printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
  270. printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
  271. printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
  272. ])
  273. }
  274. private func recognizeText() {
  275. Task { @MainActor in
  276. do {
  277. let text = try await OCRFileService.recognizeText(from: imageURL)
  278. showRecognizedText(text)
  279. } catch {
  280. showRecognitionError(error)
  281. }
  282. }
  283. }
  284. private func showRecognizedText(_ text: String) {
  285. loadingIndicator.stopAnimation(nil)
  286. loadingIndicator.isHidden = true
  287. statusLabel.isHidden = true
  288. textView.string = text
  289. textView.isHidden = false
  290. printButton.isEnabled = true
  291. window?.makeFirstResponder(textView)
  292. }
  293. private func showRecognitionError(_ error: Error) {
  294. loadingIndicator.stopAnimation(nil)
  295. loadingIndicator.isHidden = true
  296. statusLabel.isHidden = true
  297. let alert = NSAlert()
  298. alert.messageText = "OCR Failed"
  299. alert.informativeText = error.localizedDescription
  300. alert.alertStyle = .warning
  301. alert.addButton(withTitle: "OK")
  302. alert.beginSheetModal(for: window ?? NSApp.keyWindow ?? NSWindow()) { [weak self] _ in
  303. self?.dismiss()
  304. }
  305. }
  306. private func printText() {
  307. PrintService.printText(textView.string, title: "OCR File", from: window)
  308. }
  309. }
  310. // MARK: - Toolbar Button
  311. private final class OCRFileToolbarButton: NSControl, AppearanceRefreshable {
  312. var onClick: (() -> Void)?
  313. private let symbolName: String
  314. private let iconView = NSImageView()
  315. init(symbolName: String, accessibilityLabel: String) {
  316. self.symbolName = symbolName
  317. super.init(frame: .zero)
  318. toolTip = accessibilityLabel
  319. wantsLayer = true
  320. layer?.cornerRadius = 12
  321. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
  322. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
  323. iconView.image = image.withSymbolConfiguration(config)
  324. }
  325. iconView.translatesAutoresizingMaskIntoConstraints = false
  326. addSubview(iconView)
  327. NSLayoutConstraint.activate([
  328. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  329. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  330. ])
  331. refreshAppearance()
  332. }
  333. @available(*, unavailable)
  334. required init?(coder: NSCoder) { nil }
  335. func refreshAppearance() {
  336. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  337. layer?.borderColor = AppTheme.paywallBorder.cgColor
  338. layer?.borderWidth = 1.5
  339. iconView.contentTintColor = AppTheme.textPrimary
  340. }
  341. override func mouseUp(with event: NSEvent) {
  342. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  343. onClick?()
  344. }
  345. override func resetCursorRects() {
  346. addCursorRect(bounds, cursor: .pointingHand)
  347. }
  348. }
  349. // MARK: - Print Button
  350. private final class OCRFilePrintButton: NSControl, AppearanceRefreshable {
  351. var onClick: (() -> Void)?
  352. private let titleLabel = NSTextField(labelWithString: "Print Text")
  353. private let iconView = NSImageView()
  354. private var hoverTracker: HoverTracker?
  355. private var isHovered = false
  356. private var isEnabledState = true
  357. override var isEnabled: Bool {
  358. get { isEnabledState }
  359. set {
  360. isEnabledState = newValue
  361. alphaValue = newValue ? 1 : 0.45
  362. }
  363. }
  364. init() {
  365. super.init(frame: .zero)
  366. wantsLayer = true
  367. layer?.cornerRadius = 22
  368. titleLabel.font = AppTheme.semiboldFont(size: 16)
  369. titleLabel.textColor = .white
  370. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  371. if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
  372. let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  373. iconView.image = image.withSymbolConfiguration(config)
  374. }
  375. iconView.contentTintColor = .white
  376. iconView.translatesAutoresizingMaskIntoConstraints = false
  377. addSubview(titleLabel)
  378. addSubview(iconView)
  379. NSLayoutConstraint.activate([
  380. heightAnchor.constraint(equalToConstant: 52),
  381. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
  382. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  383. iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
  384. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  385. iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
  386. iconView.widthAnchor.constraint(equalToConstant: 20),
  387. iconView.heightAnchor.constraint(equalToConstant: 20),
  388. ])
  389. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  390. self?.setHovered(hovering)
  391. }
  392. refreshAppearance()
  393. }
  394. @available(*, unavailable)
  395. required init?(coder: NSCoder) { nil }
  396. func refreshAppearance() {
  397. let color = isHovered
  398. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  399. : AppTheme.blue
  400. layer?.backgroundColor = color.cgColor
  401. titleLabel.textColor = .white
  402. iconView.contentTintColor = .white
  403. }
  404. private func setHovered(_ hovering: Bool) {
  405. guard isEnabledState else { return }
  406. isHovered = hovering
  407. animateHover {
  408. let color = hovering
  409. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  410. : AppTheme.blue
  411. layer?.backgroundColor = color.cgColor
  412. layer?.transform = hovering
  413. ? CATransform3DMakeScale(1.03, 1.03, 1)
  414. : CATransform3DIdentity
  415. }
  416. }
  417. override func mouseUp(with event: NSEvent) {
  418. guard isEnabledState else { return }
  419. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  420. onClick?()
  421. }
  422. override func resetCursorRects() {
  423. guard isEnabledState else { return }
  424. addCursorRect(bounds, cursor: .pointingHand)
  425. }
  426. }