DEV Community

Xiao Ling
Xiao Ling

Posted on • Originally published at dynamsoft.com

How to Build a Remote Document Scanner in SwiftUI to Digitize Documents Over Network

In office environments, network-connected physical document scanners from manufacturers like HP , Canon , Epson , and Brother often support protocols such as ESCL (AirPrint) or ICA (Image Capture). However, for scanners that do not natively support these protocols, you can use third-party services like Dynamic Web TWAIN to enable network scanning. In this article, we will demonstrate how to build a remote document scanner app in SwiftUI to digitize documents over the network. The app leverages the Dynamic Web TWAIN REST API to communicate with scanners using protocols such as TWAIN, SANE, ICA, ESCL, and WIA.

Remote Document Scanner Demo Video

Prerequisites

  • Obtain a trial license for Dynamic Web TWAIN.
  • Install the Dynamic Web TWAIN service on your local machine that has a connected scanner.
  • Navigate to http://127.0.0.1:18625/ to enable remote access by binding the IP address of your machine. Without this step, the service is inaccessible from other devices on the network. dynamsoft-service-config

Setting Up a New SwiftUI Project

  1. Create a new Multiplatform app project in Xcode to support both macOS and iOS.
  2. Navigate to Project Settings > Signing & Capabilities > App Sandbox, and enable:

    • Outgoing Connections (Client) and Incoming Connections (Server) (Required for macOS apps to communicate with the Dynamic Web TWAIN service).
    • User Selected File (Read/Write) permission (Allows saving scanned PDFs locally on macOS).

    app sandbox setting

  3. Navigate to Project Settings > Build Settings, and add the NSLocalNetworkUsageDescription key. This is required for the iOS app to request local network access to communicate with the Dynamic Web TWAIN service.

    local network usage description

Converting the Dynamic Web TWAIN RESTful API Wrapper from C# to Swift

Previously, we wrote ScannerController.cs in C# to invoke the Dynamsoft RESTful API. Instead of rewriting it from scratch, we can quickly convert the C# code to Swift using AI tools like ChatGPT or Gemini.

Creating the ScannerController.swift File

Create a new file named ScannerController.swift and paste the converted Swift code:

import SwiftUI

struct ScannerType {
    static let TWAINSCANNER: Int = 0x10
    static let WIASCANNER: Int = 0x20
    static let TWAINX64SCANNER: Int = 0x40
    static let ICASCANNER: Int = 0x80
    static let SANESCANNER: Int = 0x100
    static let ESCLSCANNER: Int = 0x200
    static let WIFIDIRECTSCANNER: Int = 0x400
    static let WIATWAINSCANNER: Int = 0x800
}

class ScannerController {
    static let SCAN_SUCCESS = "success"
    static let SCAN_ERROR = "error"

    private let httpClient = URLSession.shared

    func getDevices(host: String, scannerType: Int? = nil) async -> [[String: Any]] {
        var devices: [[String: Any]] = []

        do {
            let response = try await getDevicesHttpResponse(host: host, scannerType: scannerType)
            if response.statusCode == 200 {
                if let data = response.data, let responseBody = String(data: data, encoding: .utf8),
                    !responseBody.isEmpty
                {
                    devices = try JSONDecoder().decode([[String: AnyCodable]].self, from: data).map
                    { $0.mapValues { $0.value } }
                }
            }
        } catch {
            print(error.localizedDescription)
        }

        return devices
    }

    func getDevicesHttpResponse(host: String, scannerType: Int? = nil) async throws -> (
        statusCode: Int, data: Data?
    ) {
        var url = URL(string: "\(host)/DWTAPI/Scanners")!
        if let scannerType = scannerType {
            url = URL(string: "\(host)/DWTAPI/Scanners?type=\(scannerType)")!
        }

        var request = URLRequest(url: url)
        request.httpMethod = "GET"

        let (data, response) = try await httpClient.data(for: request)
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NSError(domain: "InvalidResponse", code: 0, userInfo: nil)
        }
        return (httpResponse.statusCode, data)
    }

    func scanDocument(host: String, parameters: [String: Any]) async -> [String: String] {
        var dict: [String: String] = [:]

        do {
            let response = try await scanDocumentHttpResponse(host: host, parameters: parameters)
            if let data = response.data, let text = String(data: data, encoding: .utf8) {
                if response.statusCode == 200 || response.statusCode == 201 {
                    dict[ScannerController.SCAN_SUCCESS] = text
                } else {
                    dict[ScannerController.SCAN_ERROR] = text
                }
            }
        } catch {
            dict[ScannerController.SCAN_ERROR] = error.localizedDescription
        }

        return dict
    }

    func scanDocumentHttpResponse(host: String, parameters: [String: Any]) async throws -> (
        statusCode: Int, data: Data?
    ) {
        let url = URL(string: "\(host)/DWTAPI/ScanJobs")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONSerialization.data(withJSONObject: parameters)

        let (data, response) = try await httpClient.data(for: request)
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NSError(domain: "InvalidResponse", code: 0, userInfo: nil)
        }
        return (httpResponse.statusCode, data)
    }

    func deleteJob(host: String, jobId: String) async throws -> (statusCode: Int, data: Data?) {
        let url = URL(string: "\(host)/DWTAPI/ScanJobs/\(jobId)")!
        var request = URLRequest(url: url)
        request.httpMethod = "DELETE"

        let (data, response) = try await httpClient.data(for: request)
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NSError(domain: "InvalidResponse", code: 0, userInfo: nil)
        }
        return (httpResponse.statusCode, data)
    }

    func getImageFile(host: String, jobId: String, directory: String) async -> String {
        do {
            let response = try await getImageStreamHttpResponse(host: host, jobId: jobId)
            if response.statusCode == 200, let data = response.data {
                let timestamp = Int(Date().timeIntervalSince1970 * 1000)
                let filename = "image_\(timestamp).jpg"
                let imagePath = URL(fileURLWithPath: directory).appendingPathComponent(filename)
                    .path
                try data.write(to: URL(fileURLWithPath: imagePath))
                return filename
            }
        } catch {
            print("No more images.")
        }

        return ""
    }

    func getImageFiles(host: String, jobId: String, directory: String) async -> [String] {
        var images: [String] = []

        while true {
            let filename = await getImageFile(host: host, jobId: jobId, directory: directory)
            if filename.isEmpty {
                break
            } else {
                images.append(filename)
            }
        }

        return images
    }

    func getImageStreams(host: String, jobId: String) async -> [[UInt8]] {
        var streams: [[UInt8]] = []

        while true {
            let bytes = await getImageStream(host: host, jobId: jobId)
            if bytes.isEmpty {
                break
            } else {
                streams.append(bytes)
            }
        }

        return streams
    }

    func getImageStream(host: String, jobId: String) async -> [UInt8] {
        do {
            let response = try await getImageStreamHttpResponse(host: host, jobId: jobId)

            if response.statusCode == 200, let data = response.data {
                return Array(data)
            } else if response.statusCode == 410 {
                return []
            }
        } catch {
            return []
        }

        return []
    }

    func getImageStreamHttpResponse(host: String, jobId: String) async throws -> (
        statusCode: Int, data: Data?
    ) {
        let timestamp = Int(Date().timeIntervalSince1970 * 1000)
        let url = URL(string: "\(host)/DWTAPI/ScanJobs/\(jobId)/NextDocument?\(timestamp)")!

        var request = URLRequest(url: url)
        request.httpMethod = "GET"

        let (data, response) = try await httpClient.data(for: request)
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NSError(domain: "InvalidResponse", code: 0, userInfo: nil)
        }
        return (httpResponse.statusCode, data)
    }
}
...
Enter fullscreen mode Exit fullscreen mode

Building the Remote Document Scanner App in SwiftUI

API Comparison (macOS vs. iOS)

Although SwiftUI is cross-platform, macOS and iOS require different APIs for handling images and files:

macOS iOS Purpose
NSImage UIImage Image storage
NSSavePanel UIDocumentPickerViewController PDF saving UI
PDFDocument/PDFPage UIGraphicsPDFRenderer PDF rendering
Image(nsImage:) Image(uiImage:) SwiftUI Image display

Features & UI Design

Features of the Remote Document Scanner App

  • List the available scanners via an IP address.
  • Select a scanner and start scanning.
  • Display the scanned images in a scroll view.
  • Save the scanned images as a PDF file.

UI Implementation in ContentView.swift

In ContentView, add the following UI components:

struct ContentView: View {
    @StateObject private var viewModel = ScannerViewModel()
    @State private var showAlert = false
    @State private var alertMessage = ""

    var body: some View {
        VStack {
            Picker("Select Scanner", selection: $viewModel.selectedScannerName) {
                ForEach(viewModel.rawScanners.indices, id: \.self) { index in
                    if let name = viewModel.rawScanners[index]["name"] as? String {
                        Text(name).tag(name as String?)
                    }
                }
            }
            .pickerStyle(.menu)
            .padding()

            HStack {
                Button("Fetch Scanners") {
                    Task { await viewModel.fetchScanners() }
                }
                Button("Scan Document") {
                    Task { await viewModel.scanDocument() }
                }
                .disabled(viewModel.selectedScannerName == nil)
            }
            .padding()

            ScrollViewReader { proxy in
                List {
                    ForEach(Array(viewModel.scannedImages.enumerated()), id: \.offset) {
                        index, image in
                        let image = viewModel.scannedImages[index]
                        #if os(macOS)
                        Image(nsImage: image).resizable()
                            .scaledToFit()
                            .frame(height: 400)
                            .id(index)
                        #else
                        Image(uiImage: image).resizable()
                            .scaledToFit()
                            .frame(height: 400)
                            .id(index)
                        #endif
                    }
                }
                .onChange(of: viewModel.scannedImages.count) {
                    withAnimation {
                        proxy.scrollTo(viewModel.scannedImages.count - 1, anchor: .bottom)
                    }
                }
            }

            Button("Save to PDF") {
                viewModel.saveImagesToPDF()
            }
            .padding()
        }
        .onAppear {
            Task { await viewModel.fetchScanners() }
        }
        .alert("Scanner Error", isPresented: $showAlert) {
            Button("OK") { }
        } message: {
            Text(alertMessage)
        }
        .frame(width: 500, height: 600)
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • The Fetch Scanners button fetches the available scanners and lists them in the Picker component.
  • The Scan Document button triggers remote document scanning.
  • The ScrollViewReader component displays the scanned images.
  • The Save to PDF button saves the scanned images as a PDF file.

Scanner View Model Implementation

The ScannerViewModel class serves the following purposes:

  • Acts as the central manager for scanner operations.
  • Bridges the UI (SwiftUI) with the scanner hardware/API.
  • Handles platform-specific implementations for macOS and iOS.

Key Functionalities

1. Initializing the Scanner Controller

The ScannerViewModel initializes the ScannerController, sets up the IP address, license key, and scanning parameters. Be sure to replace the IP and license key with your own values.

class ScannerViewModel: ObservableObject {
    @Published var rawScanners: [[String: Any]] = []
    @Published var selectedScannerName: String?
    @Published var scannedImages: [PlatformImage] = []

    private let scannerController = ScannerController()
    private let apiURL = "http://192.168.8.72:18622"
    private let licenseKey = "LICENSE-KEY"
    private let scanConfig: [String: Any] = [
        "IfShowUI": false,
        "PixelType": 2,
        "Resolution": 200,
        "IfFeederEnabled": false,
        "IfDuplexEnabled": false
    ]
}
Enter fullscreen mode Exit fullscreen mode
2. Fetching Available Scanners

The function below retrieves a list of available scanners. You can specify a scanner type to filter the results.

func fetchScanners() async {
    let jsonArray = await scannerController.getDevices(
        host: apiURL,
        scannerType: ScannerType.TWAINX64SCANNER | ScannerType.ESCLSCANNER
    )

    await MainActor.run {
        rawScanners = jsonArray
        selectedScannerName = jsonArray.first?["name"] as? String
    }
}
Enter fullscreen mode Exit fullscreen mode
3. Scanning a Document

The scanDocument() function sends a scan request to the selected scanner. It returns a job ID, which is later used to fetch scanned images.

func scanDocument() async {
    guard let scanner = rawScanners.first(where: { $0["name"] as? String == selectedScannerName }),
            let device = scanner["device"]
    else { return }

    let parameters: [String: Any] = [
        "license": licenseKey,
        "device": device,
        "config": scanConfig
    ]

    let result = await scannerController.scanDocument(
        host: apiURL,
        parameters: parameters
    )

    if let jobId = result[ScannerController.SCAN_SUCCESS] {
        await fetchImages(jobId: jobId)
    }
}
Enter fullscreen mode Exit fullscreen mode
4. Fetching Scanned Images

This function retrieves the scanned images from the scanner and converts them into NSImage (macOS) or UIImage (iOS).

private func fetchImages(jobId: String) async {
    let streams = await scannerController.getImageStreams(host: apiURL, jobId: jobId)

    for bytes in streams {
        let data = Data(bytes: bytes, count: bytes.count)
        await MainActor.run {
            #if os(macOS)
            guard let image = NSImage(data: data) else { return }
            #else
            guard let image = UIImage(data: data) else { return }
            #endif
            scannedImages.append(image)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
5. Saving Images as a PDF

This function saves scanned images as a PDF file and allows users to store it locally.

func saveImagesToPDF() {
    #if os(macOS)
    let pdfDocument = PDFDocument()
    for (index, image) in scannedImages.enumerated() {
        if let pdfPage = PDFPage(image: image) {
            pdfDocument.insert(pdfPage, at: index)
        }
    }

    let savePanel = NSSavePanel()
    savePanel.allowedContentTypes = [.pdf]
    savePanel.nameFieldStringValue = "ScannedDocument.pdf"

    if savePanel.runModal() == .OK, let url = savePanel.url {
        pdfDocument.write(to: url)
    }
    #else
    guard let pdfData = createPDFData() else { return }
    let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ScannedDocument.pdf")

    do {
        try pdfData.write(to: tempURL)
        let controller = UIDocumentPickerViewController(forExporting: [tempURL])
        if let windowScene = UIApplication.shared.connectedScenes
            .filter({ $0.activationState == .foregroundActive })
            .first as? UIWindowScene {

            windowScene.windows.first?.rootViewController?.present(controller, animated: true)
        }
    } catch {
        print("Save failed: \(error.localizedDescription)")
    }
    #endif
}
Enter fullscreen mode Exit fullscreen mode

Running the App

Run the app on macOS or iOS to test the scanner functionality.

swiftui remote document scanner

Source Code

https://github.com/yushulx/ios-swiftui-barcode-mrz-document-scanner/tree/main/examples/RemoteScanner

AWS Q Developer image

Your AI Code Assistant

Implement features, document your code, or refactor your projects.
Built to handle large projects, Amazon Q Developer works alongside you from idea to production code.

Get started free in your IDE

Top comments (0)

Billboard image

Create up to 10 Postgres Databases on Neon's free plan.

If you're starting a new project, Neon has got your databases covered. No credit cards. No trials. No getting in your way.

Try Neon for Free →

👋 Kindness is contagious

If you found this article helpful, a little ❤️ or a friendly comment would be much appreciated!

Got it