権限キーを追加する
macOS のスクリーン録画許可 (Screen Recording)
これはアプリが画面をキャプチャする場合に必須。
- キー名(生):
NSMicrophoneUsageDescriptionとかの iOS系じゃない - macOS では実はこれ ↓
▶ NSCameraUsageDescription や NSMicrophoneUsageDescription のような専用キーは無い
スクリーン録画には Info.plist キーは不要
ただし 利用する API に応じて説明文が必要。
ScreenCaptureKit は Info.plist 許可不要
→ 代わりに macOS システム側の「画面収録」許可ダイアログが自動で出る
🛠 Sandbox をチェックする
- Xcode 左ペイン →
Projectを選択 - TARGETS →
YourApp Signing & CapabilitiesApp Sandboxの項目を確認
以下を必ずチェック:
■ App Sandbox
- ☑ User Selected File (Read/Write) → スクショ保存する場合
- Screen Recording → この項目は Xcode 14 以降で自動表示される場合もある
もし “Screen Recording” がない → OK(macOS のシステム許可が自動で出る)
📌 ScreenCaptureKit 使用時に必要な Info.plist のキー
実際には 特別な説明文は不要
でも OCR などで画像を処理するならユーザー安心のため説明文を入れることはある。
例として:
任意
| キー名 | 説明 |
|---|---|
NSPhotoLibraryAddUsageDescription | スクショをフォトへ保存する場合 |
NSPhotoLibraryUsageDescription | 写真へアクセスする場合 |
💬 Info.plist に手動で追加する例
|
1 2 |
<key>NSPhotoLibraryAddUsageDescription</key> <string>アプリがスクリーンショットを保存するためフォトライブラリへアクセスします。</string> |
デバッグコード
スクショ出来ているか判定
負の値が入っていると切り取りが出来ないので入力 rect によりローカル計算が必要かどうか変わる
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
func captureScreenRegion99(rect: CGRect, display: SCDisplay) async throws -> Void { print("=== デバッグ情報 ===") print("入力rect: \(rect)") print("Display frame: \(display.frame)") print("Display size: \(display.width) x \(display.height)") let filter = SCContentFilter(display: display, excludingWindows: []) let config = SCStreamConfiguration() config.width = display.width config.height = display.height config.scalesToFit = false config.showsCursor = false let image = try await SCScreenshotManager.captureImage( contentFilter: filter, configuration: config ) print("取得した画像サイズ: \(image.width) x \(image.height)") let scale = CGFloat(display.width) / CGFloat(display.frame.width) print("Scale: \(scale)") // rectをディスプレイのローカル座標に変換 let displayOrigin = display.frame.origin let localRect = CGRect( x: rect.origin.x - displayOrigin.x, y: rect.origin.y - displayOrigin.y, width: rect.width, height: rect.height ) print("Local rect: \(localRect)") let scaledRect = CGRect( x: localRect.origin.x * scale, y: localRect.origin.y * scale, width: localRect.width * scale, height: localRect.height * scale ) print("Scaled rect: \(scaledRect)") // Y座標を反転 let flippedY = CGFloat(image.height) - scaledRect.origin.y - scaledRect.height let cropRect = CGRect( x: scaledRect.origin.x, y: flippedY, width: scaledRect.width, height: scaledRect.height ) print("Crop rect: \(cropRect)") // cropRectが画像範囲内かチェック let imageBounds = CGRect(x: 0, y: 0, width: image.width, height: image.height) print("Image bounds: \(imageBounds)") print("cropRect is valid: \(imageBounds.contains(cropRect.origin) && cropRect.maxX <= CGFloat(image.width) && cropRect.maxY <= CGFloat(image.height))") guard let croppedImage = image.cropping(to: cropRect) else { print("❌ 切り取り失敗") throw NSError(domain: "ScreenCapture", code: -2, userInfo: [NSLocalizedDescriptionKey: "画像の切り取りに失敗しました"]) } print("✅ 切り取り成功: \(croppedImage.width) x \(croppedImage.height)") } |
OCR本体
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
import Vision import CoreGraphics func recognizeText1(from cgImage: CGImage, completion: @escaping (String) -> Void) { let request = VNRecognizeTextRequest { request, error in // エラーハンドリングを追加 if let error = error { print("OCRエラー: \(error.localizedDescription)") completion("") return } guard let results = request.results as? [VNRecognizedTextObservation] else { completion("") return } let text = results .compactMap { $0.topCandidates(1).first?.string } .joined(separator: "\n") // メインスレッドでコールバック DispatchQueue.main.async { completion(text) } } // 認識レベルを設定(accurate推奨) request.recognitionLevel = .accurate // 認識対象言語(macOS 13以降) if #available(macOS 13.0, *) { request.recognitionLanguages = ["ja", "en"] } // 自動言語補正を有効化 request.usesLanguageCorrection = true // 実行 let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) DispatchQueue.global(qos: .userInitiated).async { do { try handler.perform([request]) } catch { print("Vision実行エラー: \(error.localizedDescription)") DispatchQueue.main.async { completion("") } } } } |
🟨 画像形式判別
|
1 2 3 4 |
func isImageFile(_ url: URL) -> Bool { let ex = url.pathExtension.lowercased() return ["png", "jpg", "jpeg", "heic", "bmp", "tiff"].contains(ex) } |
🟧 NSImage → CGImage 変換
|
1 2 3 4 5 6 |
extension NSImage { func toCGImage() -> CGImage? { var rect = CGRect(origin: .zero, size: self.size) return self.cgImage(forProposedRect: &rect, context: nil, hints: nil) } } |
🟧 ゴミ文字判定ロジック
|
1 2 3 4 5 6 |
func isMostlyGarbage(_ text: String) -> Bool { let allowed = CharacterSet.alphanumerics.union(.whitespaces) let filtered = text.unicodeScalars.filter { allowed.contains($0) } let ratio = Double(filtered.count) / Double(text.unicodeScalars.count) return ratio < 0.3 // 許容率30%未満 → ゴミと判定 } |
SwiftUIで言語選択設定画面を作成するコード
言語設定ではサブに英語を入れなくても良くなってきている、勝手に英語もサブで処理する。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 |
import SwiftUI import Vision // MARK: - 言語設定を管理するViewModel class OCRLanguageSettings: ObservableObject { @Published var selectedLanguages: Set<String> = [] @Published var availableLanguages: [String] = [] private let userDefaultsKey = "selectedOCRLanguages" init() { loadAvailableLanguages() loadSelectedLanguages() } // サポートされている言語を取得 private func loadAvailableLanguages() { let request = VNRecognizeTextRequest() if let languages = try? request.supportedRecognitionLanguages() { availableLanguages = languages.sorted() } } // 保存された選択言語を読み込み private func loadSelectedLanguages() { if let saved = UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] { selectedLanguages = Set(saved) } else { // 初回起動時はシステム言語を設定 selectedLanguages = Set(getRecommendedOCRLanguages()) } } // 選択言語を保存 func saveSelectedLanguages() { UserDefaults.standard.set(Array(selectedLanguages), forKey: userDefaultsKey) } // 言語の選択/解除 func toggleLanguage(_ language: String) { if selectedLanguages.contains(language) { selectedLanguages.remove(language) } else { selectedLanguages.insert(language) } saveSelectedLanguages() } // システム推奨言語を取得 private func getRecommendedOCRLanguages() -> [String] { guard let primaryLanguage = Locale.preferredLanguages.first else { return ["en-US"] } let languageCode = String(primaryLanguage.prefix(2)) switch languageCode { case "ja": return ["ja-JP"] case "en": return ["en-US"] case "fr": return ["fr-FR"] case "de": return ["de-DE"] default: return ["en-US"] } } // 選択された言語を配列で取得(OCR実行時に使用) func getSelectedLanguagesArray() -> [String] { if selectedLanguages.isEmpty { return getRecommendedOCRLanguages() } return Array(selectedLanguages).sorted() } } // MARK: - 言語設定画面 struct OCRLanguageSettingsView: View { @StateObject private var settings = OCRLanguageSettings() @State private var showingResetAlert = false var body: some View { NavigationView { VStack(spacing: 0) { // 選択中の言語数を表示 if !settings.selectedLanguages.isEmpty { HStack { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) Text("\(settings.selectedLanguages.count)個の言語を選択中") .font(.subheadline) .foregroundColor(.secondary) Spacer() } .padding() .background(Color.secondary.opacity(0.1)) } // 言語リスト List { ForEach(settings.availableLanguages, id: \.self) { language in LanguageRow( language: language, isSelected: settings.selectedLanguages.contains(language), onToggle: { settings.toggleLanguage(language) } ) } } .listStyle(.inset) } .navigationTitle("OCR言語設定") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("リセット") { showingResetAlert = true } } } .alert("言語設定をリセット", isPresented: $showingResetAlert) { Button("キャンセル", role: .cancel) { } Button("リセット", role: .destructive) { resetToSystemLanguages() } } message: { Text("システムの推奨言語に戻しますか?") } } } private func resetToSystemLanguages() { settings.selectedLanguages.removeAll() settings.loadSelectedLanguages() } } // MARK: - 言語行コンポーネント struct LanguageRow: View { let language: String let isSelected: Bool let onToggle: () -> Void var body: some View { Button(action: onToggle) { HStack { VStack(alignment: .leading, spacing: 4) { Text(languageDisplayName) .font(.body) Text(language) .font(.caption) .foregroundColor(.secondary) } Spacer() Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .foregroundColor(isSelected ? .blue : .gray) .imageScale(.large) } .contentShape(Rectangle()) } .buttonStyle(.plain) } // 言語コードから表示名を取得 private var languageDisplayName: String { let locale = Locale(identifier: language) return locale.localizedString(forIdentifier: language) ?? language } } // MARK: - プレビュー #Preview { OCRLanguageSettingsView() } |
Toggleスイッチ版(よりネイティブな見た目):
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
struct OCRLanguageSettingsView_ToggleStyle: View { @StateObject private var settings = OCRLanguageSettings() var body: some View { NavigationView { List { Section { ForEach(settings.availableLanguages, id: \.self) { language in Toggle(isOn: Binding( get: { settings.selectedLanguages.contains(language) }, set: { _ in settings.toggleLanguage(language) } )) { VStack(alignment: .leading, spacing: 4) { Text(languageDisplayName(for: language)) .font(.body) Text(language) .font(.caption) .foregroundColor(.secondary) } } } } header: { Text("認識する言語を選択") } footer: { Text("選択した言語のテキストを認識できます。言語が多すぎると処理速度が低下する場合があります。") } } .navigationTitle("OCR言語設定") } } private func languageDisplayName(for languageCode: String) -> String { let locale = Locale(identifier: languageCode) return locale.localizedString(forIdentifier: languageCode) ?? languageCode } } |
OCR実行時の使用例:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
class OCRManager: ObservableObject { private let languageSettings = OCRLanguageSettings() func performOCR(on image: CGImage, completion: @escaping ([String]) -> Void) { let request = VNRecognizeTextRequest { request, error in guard error == nil, let observations = request.results as? [VNRecognizedTextObservation] else { completion([]) return } let recognizedText = observations.compactMap { observation in observation.topCandidates(1).first?.string } DispatchQueue.main.async { completion(recognizedText) } } // 設定から言語を取得 request.recognitionLanguages = languageSettings.getSelectedLanguagesArray() request.recognitionLevel = .accurate request.usesLanguageCorrection = true let handler = VNImageRequestHandler(cgImage: image, options: [:]) DispatchQueue.global(qos: .userInitiated).async { try? handler.perform([request]) } } } |
指定したディスプレイのスクショ撮影(指定した矩形を切り抜く)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
import CoreGraphics import ScreenCaptureKit func captureScreenRegion3(rect: CGRect, display: SCDisplay) async throws -> CGImage? { // スクリーンショット設定 let filter = SCContentFilter(display: display, excludingWindows: []) let config = SCStreamConfiguration() config.width = display.width config.height = display.height config.scalesToFit = false config.showsCursor = false // スクリーンショットを撮影 let image = try await SCScreenshotManager.captureImage( contentFilter: filter, configuration: config ) // 指定範囲を切り取り let scale = CGFloat(display.width) / CGFloat(display.frame.width) // rectをディスプレイのローカル座標に変換 //rect の状態に注意---------------------- let displayOrigin = display.frame.origin let localRect = CGRect( x: rect.origin.x - displayOrigin.x, y: rect.origin.y - displayOrigin.y, width: rect.width, height: rect.height ) let scaledRect = CGRect( x: localRect.origin.x * scale, y: localRect.origin.y * scale, width: localRect.width * scale, height: localRect.height * scale ) // Y座標を反転 let flippedY = CGFloat(image.height) - scaledRect.origin.y - scaledRect.height let cropRect = CGRect( x: scaledRect.origin.x, y: flippedY, width: scaledRect.width, height: scaledRect.height ) guard let croppedImage = image.cropping(to: cropRect) else { throw NSError(domain: "ScreenCapture", code: -2, userInfo: [NSLocalizedDescriptionKey: "画像の切り取りに失敗しました"]) } return croppedImage } |
PDFファイルの判別法
🟦 PDF の中身を判別する仕組み
✔ パターン1:テキストベースのPDF
・実際の”テキストオブジェクト”が内部に存在する
・ 選択やコピーができる
・ 軽い(数+KB~数百KB)
・ 文字情報を PDFKit から直接取得できる
→ Vision OCR は不要
✔ パターン2:画像ベースのPDF(スキャンPDF)
・ スキャナーで取り込んだ画像をそのまま貼っている
・ 文字は画像のピクセルに含まれているだけ
・PDFとしては「画像を貼っただけ」
・ 選択できない
・重い(数MB~数+MB)
→ Vision OCR が必要
🟩 Swift(PDFKit)で判別可能
PDFKit の PDFPage にはこういうメソッドがあります:
✔ ① string(PDFPage.string)
PDFページから抽出可能なテキストを返す。
|
1 2 3 4 5 |
if let text = page.string, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { print("テキストPDF") } else { print("画像PDF") } |
ただし注意点:
- レイアウトが複雑だったり埋め込みフォントによってはテキストが取得できないこともある(例:変なPDF生成ツール)
- それでもかなり実用的
✔ ② 画像を抽出してチェックする方法(確実)
|
1 |
let image = page.thumbnail(of: CGSize(width: 100, height: 100), for: .mediaBox) |
ここで Vision の
VNDetectTextRectanglesRequestを使えば
「画像に文字があるか?」も判別できる。
🟩 Swift で PDF を判別するコード例
|
1 2 3 4 5 6 7 8 9 10 11 |
func isTextBasedPDF(url: URL) -> Bool { guard let pdf = PDFDocument(url: url) else { return false } for i in 0..<pdf.pageCount { guard let page = pdf.page(at: i) else { continue } if let text = page.string, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true // 1ページでもテキストがあれば「テキストPDF」 } } return false } |
画像判定(OCR判定)の方が確実なパターンもある
複雑なフォント・アウトライン化(文字が図形になってる)などは
PDFPage.string では取得できないけど、
OCR を通せば文字があることが分かることもある。
コメント