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.
Setting Up a New SwiftUI Project
- Create a new Multiplatform app project in Xcode to support both macOS and iOS.
-
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).
-
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.
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)
}
}
...
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)
}
}
Explanation:
- The
Fetch Scanners
button fetches the available scanners and lists them in thePicker
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
]
}
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
}
}
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)
}
}
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)
}
}
}
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
}
Running the App
Run the app on macOS or iOS to test the scanner functionality.
Source Code
https://github.com/yushulx/ios-swiftui-barcode-mrz-document-scanner/tree/main/examples/RemoteScanner
Top comments (0)