XCode で OCRアプリ作成

# 画像を画像ライブラリから複数選択し、画像から文字の抽出 と サーバにアップロード

```
//
//  ModalView.swift
//  OCR_TestApp
//

import SwiftUI
import PhotosUI
import Photos
import Vision
import VisionKit
import AVFoundation

struct ModalView: View {
    // MARK: - 変数
    @EnvironmentObject var signInHandler: SignInHandler
    @Environment(\.dismiss) var dismiss

    @State private var showPhotoPicker = false
    @State private var selectedImages: [UIImage] = []
    @State private var imageProcessingQueue: [UIImage] = []
    @State private var selectedRecognizedText: [String] = []
    @State private var image: UIImage? // カメラで撮影した画像を保持する
    @State private var isProcessingImage = false

    @State private var isCameraPickerPresented = false
    
    // アラート用の状態変数
    @State private var imageToSave: UIImage?
    @State private var capturedImage: UIImage? // キャプチャされた画像
    @State private var isCameraPresented = false
    @State private var isSaveAlertPresented = false
    
    //トースト用
    @State private var isToastVisible = false
    @State private var toastMessage = ""
    
    // MARK: - ボディ
    var body: some View {
        VStack {
            // Header with Close Button
            HStack {
                Button(action: { dismiss() }) {
                    Image(systemName: "x.circle.fill")
                        .resizable()
                        .frame(width: 40, height: 40)
                        .foregroundColor(.red)
                }
                .buttonStyle(PlainButtonStyle())
                .padding()
            }

            // Title
            Text("モーダルウィンドウ")
                .font(.title)
                .padding()

            // Photo Picker and Camera Buttons
            HStack {
                // Photo Picker Button
                Button(action: { showPhotoPicker = true }) {
                    VStack {
                        Image(systemName: "photo.on.rectangle")
                            .resizable()
                            .frame(width: 40, height: 40)
                            .foregroundColor(.blue)
                        Text("写真選択").font(.caption)
                    }
                }
                .buttonStyle(PlainButtonStyle())
                .padding()
                .sheet(isPresented: $showPhotoPicker) {
                    PhotoPickerView(selectedImages: $selectedImages)
                }

                // Camera Button
                Button(action: {
                    requestCameraPermission { granted in
                        if granted {
                            isCameraPresented = true
                        } else {
                            showAlert(title: "カメラアクセス拒否", message: "設定アプリからカメラへのアクセスを許可してください。")
                        }
                    }
                }) {
                    VStack {
                        Image(systemName: "camera")
                            .resizable()
                            .frame(width: 40, height: 40)
                            .foregroundColor(.blue)
                        Text("撮影").font(.caption)
                    }
                }
                .padding()
                .sheet(isPresented: $isCameraPresented) {
                    CameraView(capturedImage: $capturedImage)
                        .onChange(of: capturedImage) { image in
                            if let image = image {
                                imageToSave = image
                                isSaveAlertPresented = true
                            }
                        }
                }

                // Send All Button
                Button(action: {
                    print("Selected Recognized Text: \(selectedRecognizedText)")
                    sendAllImages()
                }) {
                    Text("すべて送信")
                        .foregroundColor(.white)
                        .padding()
                        .background(selectedImages.isEmpty ? Color.gray : Color.green)
                        .cornerRadius(10)
                }
                .disabled(selectedImages.isEmpty)
            }
            // トースト表示
            if isToastVisible {
                VStack {
                    Spacer()
                    ToastView(message: toastMessage)
                        .transition(.opacity)
                        .animation(.easeInOut(duration: 0.3))
                }
                .zIndex(1) // トーストが他の要素の上に表示されるように設定
            }

            // Manual Text Extraction Button
            Button(action: {
                extractTextManually()
            }) {
                Text("テキストを抽出")
                    .foregroundColor(.white)
                    .padding()
                    .background(selectedImages.isEmpty ? Color.gray : Color.orange)
                    .cornerRadius(10)
            }
            .disabled(selectedImages.isEmpty)

            // Display Selected Images
            if !selectedImages.isEmpty {
                ScrollView(.horizontal) {
                    HStack {
                        ForEach(selectedImages, id: \.self) { image in
                            Image(uiImage: image)
                                .resizable()
                                .scaledToFit()
                                .frame(width: 100, height: 100)
                                .clipShape(RoundedRectangle(cornerRadius: 10))
                        }
                    }
                    .padding()
                }
            }

            // Display Recognized Text
            if !selectedRecognizedText.isEmpty {
                ScrollView {
                    VStack(alignment: .leading) {
                        ForEach(selectedRecognizedText, id: \.self) { text in
                            Text(text)
                                .padding()
                                .background(Color.gray.opacity(0.2))
                                .cornerRadius(8)
                        }
                    }
                    .padding()
                }
            }
        }
        .padding()
        .alert(isPresented: $isSaveAlertPresented) {
            Alert(
                title: Text("写真を保存しますか？"),
                message: Text("フォトライブラリに写真を保存しますか？"),
                primaryButton: .default(Text("はい")) {
                    if let imageToSave = imageToSave {
                        saveImageToPhotoLibrary(imageToSave)
                    }
                },
                secondaryButton: .cancel(Text("いいえ"))
            )
        }
        .onAppear {
            checkPermissions()
        }
    }
    
    // MARK: - showToast
    // トースト表示ロジック
    private func showToast(message: String) {
        toastMessage = message
        isToastVisible = true
        
        // トーストを3秒後に非表示にする
        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
            withAnimation {
                isToastVisible = false
            }
        }
    }
    
    // MARK: - 画像権限
    private func checkPermissions() {
        // カメラアクセス権限
        AVCaptureDevice.requestAccess(for: .video) { granted in
            DispatchQueue.main.async {
                if !granted {
                    print("カメラへのアクセスが拒否されました。")
                    // ユーザーに通知
                }
            }
        }

        // 写真ライブラリアクセス権限
        PHPhotoLibrary.requestAuthorization { status in
            DispatchQueue.main.async {
                switch status {
                case .authorized:
                    print("写真ライブラリへのアクセスが許可されました。")
                case .denied, .restricted:
                    print("写真ライブラリへのアクセスが拒否されました。")
                    // ユーザーに通知
                case .notDetermined:
                    print("写真ライブラリへのアクセスがまだリクエストされていません。")
                @unknown default:
                    print("未知の権限ステータス。")
                }
            }
        }
    }
    
    // MARK: - Send All Images
    private func sendAllImages() {
        guard !selectedImages.isEmpty else {
            print("No images selected")
            return
        }
        
        if selectedRecognizedText.isEmpty {
            print("Recognized text is empty, extracting manually...")
            extractTextManually()
        }
        
        var successCount = 0
        var failureCount = 0
        let totalImages = selectedImages.count
        
        for (index, image) in selectedImages.enumerated() {
            if let imageData = image.jpegData(compressionQuality: 0.8) {
                let text = index < selectedRecognizedText.count ? selectedRecognizedText[index] : "テキストなし"
                let dict: [String: String] = [
                    "adress": signInHandler.emailAddress ?? "unknown",
                    "txtval": text
                ]
                
                uploadImage(imageData, additionalFields: dict) { success in
                    if success {
                        successCount += 1
                    } else {
                        failureCount += 1
                    }
                    
                    // 全ての送信処理が完了した時
                    if successCount + failureCount == totalImages {
                        DispatchQueue.main.async {
                            if failureCount == 0 {
                                selectedImages.removeAll()
                                selectedRecognizedText.removeAll()
                                showToast(message: "すべての画像が正常に送信されました！")
                            } else {
                                showToast(message: "\(successCount) 件成功、\(failureCount) 件失敗しました。")
                            }
                        }
                    }
                }
            }
        }
    }

    // MARK: - Upload Image
    private func uploadImage(_ imageData: Data, additionalFields: [String: String], completion: @escaping (Bool) -> Void) {
        guard let url = URL(string: "https://ocrapp.we-labo.com/Swift_Insert.php") else {
            print("Invalid URL")
            completion(false)
            return
        }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        let boundary = UUID().uuidString
        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")

        var body = Data()
        for (key, value) in additionalFields {
            body.append("--\(boundary)\r\n".data(using: .utf8)!)
            body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
            body.append("\(value)\r\n".data(using: .utf8)!)
        }
        body.append("--\(boundary)\r\n".data(using: .utf8)!)
        body.append("Content-Disposition: form-data; name=\"file\"; filename=\"image.jpg\"\r\n".data(using: .utf8)!)
        body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
        body.append(imageData)
        body.append("\r\n".data(using: .utf8)!)
        body.append("--\(boundary)--\r\n".data(using: .utf8)!)

        request.httpBody = body

        URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                print("Error: \(error.localizedDescription)")
                completion(false)
                return
            }

            guard let data = data, let responseString = String(data: data, encoding: .utf8) else {
                print("No data or invalid response")
                completion(false)
                return
            }

            print("Server response: \(responseString)")
            if responseString.contains("success") {
                completion(true)
            } else {
                completion(false)
            }
        }.resume()
    }
    
    // MARK: - Process Images Queue
    private func processImagesInQueue() {
        guard !isProcessingImage else { return }
        guard !imageProcessingQueue.isEmpty else {
            print("すべての画像が処理されました。")
            return
        }
        isProcessingImage = true
        processNextImage()
    }

    private func processNextImage() {
        guard let image = imageProcessingQueue.first else {
            isProcessingImage = false
            return
        }

        imageProcessingQueue.removeFirst()

        recognizeText(in: image) { recognizedText in
            DispatchQueue.main.async {
                self.selectedRecognizedText.append(recognizedText)
                self.isProcessingImage = false
            }
        }
    }
    // MARK: - extractTextManually
    private func extractTextManually() {
        selectedRecognizedText.removeAll() // 前回の結果をクリア
        for image in selectedImages {
            recognizeText(in: image) { recognizedText in
                DispatchQueue.main.async {
                    self.selectedRecognizedText.append(recognizedText)
                }
            }
        }
    }
    
    // MARK: - Recognize Text
    private func recognizeText(in image: UIImage, completion: @escaping (String) -> Void) {
        guard let cgImage = image.cgImage else { return }

        let request = VNRecognizeTextRequest { (request, error) in
            guard let observations = request.results as? [VNRecognizedTextObservation] else { return }
            let recognizedText = observations
                .compactMap { $0.topCandidates(1).first?.string }
                .joined(separator: "\n")
            completion(recognizedText)
        }

        request.recognitionLanguages = ["ja-JP"]
        request.recognitionLevel = .accurate

        let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
        DispatchQueue.global(qos: .userInitiated).async {
            do {
                try handler.perform([request])
            } catch {
                print("Error recognizing text: \(error)")
            }
        }
    }
    
    
    // MARK: - Save Image to Photo Library
    private func saveImageToPhotoLibrary(_ image: UIImage) {
        let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)

        switch status {
        case .authorized, .limited:
            ImageSaveHelper.shared.saveImageToPhotoLibrary(image) { success, errorMessage in
                if success {
                    showAlert(title: "保存成功", message: "写真がライブラリに保存されました。")
                } else {
                    showAlert(title: "保存失敗", message: errorMessage ?? "エラーが発生しました。")
                }
            }
        case .notDetermined:
            PHPhotoLibrary.requestAuthorization(for: .addOnly) { newStatus in
                if newStatus == .authorized || newStatus == .limited {
                    ImageSaveHelper.shared.saveImageToPhotoLibrary(image) { success, errorMessage in
                        if success {
                            showAlert(title: "保存成功", message: "写真がライブラリに保存されました。")
                        } else {
                            showAlert(title: "保存失敗", message: errorMessage ?? "エラーが発生しました。")
                        }
                    }
                } else {
                    DispatchQueue.main.async {
                        showAlert(title: "写真ライブラリへのアクセスが拒否されました", message: "設定から変更してください。")
                    }
                }
            }
        default:
            DispatchQueue.main.async {
                showAlert(title: "写真ライブラリへのアクセスが拒否されました", message: "設定から変更してください。")
            }
        }
    }
    
    // MARK: - カメラ権限の確認
    func requestCameraPermission(completion: @escaping (Bool) -> Void) {
        let status = AVCaptureDevice.authorizationStatus(for: .video)
        switch status {
        case .authorized:
            completion(true)
        case .notDetermined:
            AVCaptureDevice.requestAccess(for: .video) { granted in
                completion(granted)
            }
        default:
            completion(false)
        }
    }
    
    // MARK: - Show Alert
    @State private var alertTitle: String = ""
    @State private var alertMessage: String = ""
    @State private var isAlertPresented: Bool = false

    private func showAlert(title: String, message: String) {
        DispatchQueue.main.async {
            alertTitle = title
            alertMessage = message
            isAlertPresented = true
        }
    }
}

import UIKit

// MARK: - ImageSaveHelper
class ImageSaveHelper: NSObject {
    static let shared = ImageSaveHelper()
    
    func saveImageToPhotoLibrary(_ image: UIImage, completion: @escaping (Bool, String?) -> Void) {
        UIImageWriteToSavedPhotosAlbum(image, self, #selector(saveCompleted(_:didFinishSavingWithError:contextInfo:)), nil)
        self.completion = completion
    }
    
    private var completion: ((Bool, String?) -> Void)?
    
    @objc private func saveCompleted(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) {
        if let error = error {
            completion?(false, error.localizedDescription)
        } else {
            completion?(true, nil)
        }
    }
}


// MARK: - CameraView

import SwiftUI
import UIKit
import AVFoundation

struct CameraView: UIViewControllerRepresentable {
    @Binding var capturedImage: UIImage?

    func makeUIViewController(context: Context) -> UIImagePickerController {
        let picker = UIImagePickerController()

        if UIImagePickerController.isSourceTypeAvailable(.camera) {
            picker.sourceType = .camera
            
            // 利用可能なカメラデバイスをチェックして設定
            if UIImagePickerController.isCameraDeviceAvailable(.rear) {
                picker.cameraDevice = .rear // バックカメラを設定
            } else if UIImagePickerController.isCameraDeviceAvailable(.front) {
                picker.cameraDevice = .front // フロントカメラをフォールバック
            } else {
                print("利用可能なカメラデバイスがありません。")
            }
            
            picker.cameraCaptureMode = .photo // 写真モードを指定
        } else {
            print("このデバイスではカメラがサポートされていません。")
        }

        picker.delegate = context.coordinator
        return picker
    }

    func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
        let parent: CameraView

        init(_ parent: CameraView) {
            self.parent = parent
        }

        func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
            if let image = info[.originalImage] as? UIImage {
                parent.capturedImage = image
            }
            picker.dismiss(animated: true)
        }

        func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
            picker.dismiss(animated: true)
        }
    }
}

// MARK: - ImageTextPair
struct ImageTextPair: Identifiable {
    let id = UUID()
    let image: UIImage
    let text: String
}

// MARK: - ToastView
struct ToastView: View {
    let message: String

    var body: some View {
        Text(message)
            .font(.body)
            .foregroundColor(.white)
            .padding()
            .background(Color.black.opacity(0.8))
            .cornerRadius(10)
            .shadow(radius: 10)
            .padding()
    }
}

```

# 画像からテキストの抽出を実行している関数
```
    // MARK: - extractTextManually
    private func extractTextManually() {
        selectedRecognizedText.removeAll() // 前回の結果をクリア
        for image in selectedImages {
            recognizeText(in: image) { recognizedText in
                DispatchQueue.main.async {
                    self.selectedRecognizedText.append(recognizedText)
                }
            }
        }
    }
```

# OCR-TestApp-Info.plist
```
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Localization native development region</key>
    <string>Japan</string>
    <key>CFBundleURLTypes</key>
    <array>
        <dict>
            <key>CFBundleTypeRole</key>
            <string>Editor</string>
            <key>CFBundleURLName</key>
            <string>REVERSED_CLIENT_ID</string>
            <key>CFBundleURLSchemes</key>
            <array>
                <string>com.googleusercontent.apps.1059940093802-pflvbfa5l18v022tmptujb3e7jkgb1ga</string>
            </array>
        </dict>
        <dict>
            <key>CFBundleTypeRole</key>
            <string>Editor</string>
            <key>CFBundleURLName</key>
            <string>GIDSignInDelegate</string>
            <key>CFBundleURLSchemes</key>
            <array>
                <string>1059940093802-pflvbfa5l18v022tmptujb3e7jkgb1ga.apps.googleusercontent.com</string>
            </array>
        </dict>
    </array>
    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSExceptionDomains</key>
        <dict>
            <key>example.com</key> <!-- Replace with actual domain -->
            <dict>
                <key>NSExceptionAllowsInsecureHTTPLoads</key>
                <true/>
                <key>NSIncludesSubdomains</key>
                <true/>
            </dict>
        </dict>
    </dict>
    <key>NSCameraUsageDescription</key>
    <string>このアプリではテキスト認識のために写真を撮影します。</string>
    <key>NSPhotoLibraryUsageDescription</key>
    <string>テキスト認識に必要な写真を選択するために写真ライブラリにアクセスします。</string>
    <key>NSPhotoLibraryAddUsageDescription</key>
    <string>アプリが写真を保存するために写真ライブラリへのアクセスが必要です。</string>
    <key>NSFileProviderUsageDescription</key>
    <string>アプリケーションがファイルにアクセスするために必要です。</string>
    <key>UIBackgroundModes</key>
    <array>
        <string>fetch</string>
        <string>processing</string>
    </array>
    <key>UIFileSharingEnabled</key>
    <true/>
    <key>LSSupportsOpeningDocumentsInPlace</key>
    <true/>
    <key>GOOGLE_API_KEY</key>
    <string>$(GOOGLE_API_KEY)</string>
</dict>
</plist>
```