From a531eeac8ff10b1599eae4dcecd7949af84dafb4 Mon Sep 17 00:00:00 2001 From: Roman Mazeev Date: Sun, 2 Feb 2025 05:50:33 +0100 Subject: [PATCH] Refactoring * Improved performance * Minor API changes * Test coverage increased to max --- .../workflows/Build and test framework.yml | 2 +- .gitignore | 3 + .../xcshareddata/swiftpm/Package.resolved | 58 +++- Example/MRZScannerExample/Camera.swift | 9 +- Example/MRZScannerExample/ContentView.swift | 31 +-- Example/MRZScannerExample/ViewModel.swift | 64 ++--- Package.resolved | 58 +++- Package.swift | 19 +- README.md | 11 +- Sources/MRZScanner/AsyncStream+map.swift | 23 -- Sources/MRZScanner/MRZFrequencyTracker.swift | 32 --- Sources/MRZScanner/MRZScanner.swift | 96 ------- Sources/MRZScanner/MRZValidator.swift | 40 --- .../Private/BoundingRectConverter.swift | 54 ++++ Sources/MRZScanner/Private/Parser.swift | 40 +++ .../MRZScanner/Private/TextRecognizer.swift | 67 +++++ Sources/MRZScanner/Private/Tracker.swift | 46 ++++ Sources/MRZScanner/Private/Validator.swift | 64 +++++ Sources/MRZScanner/Public/Scanner.swift | 101 +++++++ .../MRZScanner/Public/ScanningResult.swift | 74 +++++ Sources/MRZScanner/ScanningResult.swift | 34 --- Sources/MRZScanner/VisionTextRecognizer.swift | 40 --- .../MRZFrequencyTrackerTests.swift | 57 ---- Tests/MRZScannerTests/MRZValidatorTests.swift | 168 ------------ Tests/MRZScannerTests/Mocks.swift | 70 +++++ .../Private/BoundingRectConverterTests.swift | 51 ++++ .../MRZScannerTests/Private/ParserTests.swift | 57 ++++ .../Private/TextRecognizerTests/ImageTest.png | Bin 0 -> 41850 bytes .../TextRecognizerTests.swift | 63 +++++ .../Private/TrackerTests.swift | 25 ++ .../Private/ValidatorTests.swift | 83 ++++++ .../MRZScannerTests/Public/ScannerTests.swift | 254 ++++++++++++++++++ Tests/MRZScannerTests/StubModels.swift | 52 ---- 33 files changed, 1234 insertions(+), 612 deletions(-) delete mode 100644 Sources/MRZScanner/AsyncStream+map.swift delete mode 100644 Sources/MRZScanner/MRZFrequencyTracker.swift delete mode 100644 Sources/MRZScanner/MRZScanner.swift delete mode 100644 Sources/MRZScanner/MRZValidator.swift create mode 100644 Sources/MRZScanner/Private/BoundingRectConverter.swift create mode 100644 Sources/MRZScanner/Private/Parser.swift create mode 100644 Sources/MRZScanner/Private/TextRecognizer.swift create mode 100644 Sources/MRZScanner/Private/Tracker.swift create mode 100644 Sources/MRZScanner/Private/Validator.swift create mode 100644 Sources/MRZScanner/Public/Scanner.swift create mode 100644 Sources/MRZScanner/Public/ScanningResult.swift delete mode 100644 Sources/MRZScanner/ScanningResult.swift delete mode 100644 Sources/MRZScanner/VisionTextRecognizer.swift delete mode 100644 Tests/MRZScannerTests/MRZFrequencyTrackerTests.swift delete mode 100644 Tests/MRZScannerTests/MRZValidatorTests.swift create mode 100644 Tests/MRZScannerTests/Mocks.swift create mode 100644 Tests/MRZScannerTests/Private/BoundingRectConverterTests.swift create mode 100644 Tests/MRZScannerTests/Private/ParserTests.swift create mode 100644 Tests/MRZScannerTests/Private/TextRecognizerTests/ImageTest.png create mode 100644 Tests/MRZScannerTests/Private/TextRecognizerTests/TextRecognizerTests.swift create mode 100644 Tests/MRZScannerTests/Private/TrackerTests.swift create mode 100644 Tests/MRZScannerTests/Private/ValidatorTests.swift create mode 100644 Tests/MRZScannerTests/Public/ScannerTests.swift delete mode 100644 Tests/MRZScannerTests/StubModels.swift diff --git a/.github/workflows/Build and test framework.yml b/.github/workflows/Build and test framework.yml index fd98aaf..dd32d7d 100644 --- a/.github/workflows/Build and test framework.yml +++ b/.github/workflows/Build and test framework.yml @@ -9,7 +9,7 @@ on: jobs: build: - runs-on: macos-latest + runs-on: macos-13 steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 330d167..5f8a6f8 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,6 @@ fastlane/test_output # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ + +# OS X Finder +.DS_Store diff --git a/Example/MRZScannerExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/MRZScannerExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1d94759..cf468f3 100644 --- a/Example/MRZScannerExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/MRZScannerExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,66 @@ { "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", + "version" : "1.0.0" + } + }, { "identity" : "mrzparser", "kind" : "remoteSourceControl", "location" : "https://github.com/romanmazeev/MRZParser.git", "state" : { - "revision" : "2c1c4809379f081c01297f0ac92df2e94c6db54a", - "version" : "1.1.3" + "branch" : "master", + "revision" : "a39f93e35e4d8de2dceeb6103ca4089856e3c566" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "d1fd837326aa719bee979bdde1f53cd5797443eb", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", + "state" : { + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "9783b58167f7618cb86011156e741cbc6f4cc864", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", + "version" : "509.0.2" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay.git", + "state" : { + "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", + "version" : "1.0.2" } } ], diff --git a/Example/MRZScannerExample/Camera.swift b/Example/MRZScannerExample/Camera.swift index 0f27c7e..0d62489 100644 --- a/Example/MRZScannerExample/Camera.swift +++ b/Example/MRZScannerExample/Camera.swift @@ -11,17 +11,20 @@ import CoreImage final class Camera: NSObject { let captureSession = AVCaptureSession() - private(set) lazy var imageStream: AsyncStream = { + var imageStream: AsyncStream { AsyncStream { continuation in imageStreamCallback = { ciImage in continuation.yield(ciImage) } } - }() - private var imageStreamCallback: ((CIImage) -> Void)? + } + private var imageStreamCallback: ((CIImage) -> Void)? private let captureDevice = AVCaptureDevice.default(for: .video) + + // TODO: Refactor to use Swift Concurrency private let sessionQueue = DispatchQueue(label: "Session queue") + private var isCaptureSessionConfigured = false private var deviceInput: AVCaptureDeviceInput? private var videoOutput: AVCaptureVideoDataOutput? diff --git a/Example/MRZScannerExample/ContentView.swift b/Example/MRZScannerExample/ContentView.swift index 54b8079..48cfa19 100644 --- a/Example/MRZScannerExample/ContentView.swift +++ b/Example/MRZScannerExample/ContentView.swift @@ -5,8 +5,8 @@ // Created by Roman Mazeev on 01/01/2023. // +import MRZScanner import SwiftUI -import MRZParser struct ContentView: View { private let dateFormatter: DateFormatter = { @@ -17,11 +17,13 @@ struct ContentView: View { }() @StateObject private var viewModel = ViewModel() + @State private var cameraRect: CGRect? + @State private var mrzRect: CGRect? var body: some View { GeometryReader { proxy in Group { - if let cameraRect = viewModel.cameraRect { + if let cameraRect { CameraView(captureSession: viewModel.captureSession) .frame(width: cameraRect.width, height: cameraRect.height) } @@ -29,23 +31,20 @@ struct ContentView: View { ZStack { Color.black.opacity(0.5) - if let mrzRect = viewModel.mrzRect { + if let mrzRect { Rectangle() .blendMode(.destinationOut) .frame(width: mrzRect.size.width, height: mrzRect.size.height) .position(mrzRect.origin) .task { - do { - try await viewModel.startMRZScanning() - } catch { - print(error.localizedDescription) - } + guard let cameraRect else { return } + + await viewModel.startMRZScanning(cameraRect: cameraRect, mrzRect: mrzRect) } } } .compositingGroup() - if let boundingRects = viewModel.boundingRects { ForEach(boundingRects.valid, id: \.self) { boundingRect in createBoundingRect(boundingRect, color: .green) @@ -57,9 +56,9 @@ struct ContentView: View { } } .onAppear { - viewModel.cameraRect = proxy.frame(in: .global) - viewModel.mrzRect = .init(origin: .init(x: proxy.size.width / 2, y: proxy.size.height / 2), - size: .init(width: proxy.size.width - 40, height: proxy.size.width / 5)) + cameraRect = proxy.frame(in: .global) + mrzRect = .init(origin: .init(x: proxy.size.width / 2, y: proxy.size.height / 2), + size: .init(width: proxy.size.width - 40, height: proxy.size.width / 5)) } } .alert(isPresented: .init(get: { viewModel.mrzResult != nil }, set: { _ in viewModel.mrzResult = nil })) { @@ -68,13 +67,15 @@ struct ContentView: View { message: Text(createAlertMessage(mrzResult: viewModel.mrzResult!)), dismissButton: .default(Text("Got it!")) { Task { - try await viewModel.startMRZScanning() + guard let cameraRect, let mrzRect else { return } + + await viewModel.startMRZScanning(cameraRect: cameraRect, mrzRect: mrzRect) } } ) } .task { - viewModel.startCamera() + await viewModel.startCamera() } .statusBarHidden() .ignoresSafeArea() @@ -87,7 +88,7 @@ struct ContentView: View { .position(rect.origin) } - private func createAlertMessage(mrzResult: MRZResult) -> String { + private func createAlertMessage(mrzResult: ParserResult) -> String { var birthdateString: String? var expiryDateString: String? diff --git a/Example/MRZScannerExample/ViewModel.swift b/Example/MRZScannerExample/ViewModel.swift index 0870cc6..575adda 100644 --- a/Example/MRZScannerExample/ViewModel.swift +++ b/Example/MRZScannerExample/ViewModel.swift @@ -8,54 +8,48 @@ import AVFoundation import SwiftUI import MRZScanner -import MRZParser import Vision +@MainActor final class ViewModel: ObservableObject { // MARK: Camera private let camera = Camera() - @Published var cameraRect: CGRect? var captureSession: AVCaptureSession { camera.captureSession } - func startCamera() { - Task { - await camera.start() - } + func startCamera() async { + await camera.start() } // MARK: Scanning - @Published var boundingRects: ScanedBoundingRects? - @Published var mrzRect: CGRect? - @Published var mrzResult: MRZResult? - - private var scanningTask: Task<(), Error>? - - func startMRZScanning() async throws { - guard let cameraRect, let mrzRect else { return } - - let correctedMRZRect = correctCoordinates(to: .leftTop, rect: mrzRect) - let roi = MRZScanner.convertRect(to: .normalizedRect, rect: correctedMRZRect, imageWidth: Int(cameraRect.width), imageHeight: Int(cameraRect.height)) - let scanningStream = MRZScanner.scanLive( - imageStream: camera.imageStream, - configuration: .init(orientation: .up, regionOfInterest: roi, minimumTextHeight: 0.1, recognitionLevel: .fast) - ) - - scanningTask = Task { - for try await liveScanningResult in scanningStream { - Task { @MainActor in - switch liveScanningResult { - case .found(let scanningResult): - boundingRects = correctBoundingRects(to: .center, rects: scanningResult.boundingRects) - mrzResult = scanningResult.result - scanningTask?.cancel() - case .notFound(let boundingRects): - self.boundingRects = correctBoundingRects(to: .center, rects: boundingRects) - } + @Published var boundingRects: ScannedBoundingRects? + @Published var mrzResult: ParserResult? + + func startMRZScanning(cameraRect: CGRect, mrzRect: CGRect) async { + do { + for try await scanningResult in camera.imageStream.scanForMRZCode( + configuration: .init( + orientation: .up, + regionOfInterest: VNNormalizedRectForImageRect( + correctCoordinates(to: .leftTop, rect: mrzRect), + Int(cameraRect.width), + Int(cameraRect.height) + ), + minimumTextHeight: 0.1, + recognitionLevel: .fast + ) + ) { + boundingRects = correctBoundingRects(to: .center, rects: scanningResult.boundingRects, mrzRect: mrzRect) + if let bestResult = scanningResult.best(repetitions: 2) { + mrzResult = bestResult + boundingRects = nil + return } } + } catch { + print(error.localizedDescription) } } @@ -66,9 +60,7 @@ final class ViewModel: ObservableObject { case leftTop } - private func correctBoundingRects(to type: CorrectionType, rects: ScanedBoundingRects) -> ScanedBoundingRects { - guard let mrzRect else { fatalError("Camera rect must be set") } - + private func correctBoundingRects(to type: CorrectionType, rects: ScannedBoundingRects, mrzRect: CGRect) -> ScannedBoundingRects { let convertedCoordinates = rects.convertedToImageRects(imageWidth: Int(mrzRect.width), imageHeight: Int(mrzRect.height)) let correctedMRZRect = correctCoordinates(to: .leftTop, rect: mrzRect) diff --git a/Package.resolved b/Package.resolved index 1d94759..cf468f3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,66 @@ { "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", + "version" : "1.0.0" + } + }, { "identity" : "mrzparser", "kind" : "remoteSourceControl", "location" : "https://github.com/romanmazeev/MRZParser.git", "state" : { - "revision" : "2c1c4809379f081c01297f0ac92df2e94c6db54a", - "version" : "1.1.3" + "branch" : "master", + "revision" : "a39f93e35e4d8de2dceeb6103ca4089856e3c566" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "d1fd837326aa719bee979bdde1f53cd5797443eb", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", + "state" : { + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "9783b58167f7618cb86011156e741cbc6f4cc864", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", + "version" : "509.0.2" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay.git", + "state" : { + "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", + "version" : "1.0.2" } } ], diff --git a/Package.swift b/Package.swift index 35c54c4..de33b9c 100644 --- a/Package.swift +++ b/Package.swift @@ -13,15 +13,28 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/romanmazeev/MRZParser.git", .upToNextMajor(from: "1.1.3")) + .package(url: "https://github.com/romanmazeev/MRZParser.git", branch: "master"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay.git", .upToNextMajor(from: "1.0.2")), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.1.2") ], targets: [ .target( name: "MRZScanner", - dependencies: ["MRZParser"] + dependencies: [ + "MRZParser", + .product( + name: "XCTestDynamicOverlay", + package: "xctest-dynamic-overlay" + ), + .product( + name: "Dependencies", + package: "swift-dependencies" + ) + ] ), .testTarget( name: "MRZScannerTests", - dependencies: ["MRZScanner"]), + dependencies: ["MRZScanner"], + resources: [.process("Private/TextRecognizerTests/ImageTest.png")]), ] ) diff --git a/README.md b/README.md index 6f497d6..68dfc49 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Library for scanning documents via [MRZ](https://en.wikipedia.org/wiki/Machine-r ### Swift Package Manager ```swift dependencies: [ - .package(url: "https://github.com/romanmazeev/MRZScanner.git", .upToNextMajor(from: "1.0.0")) + .package(url: "https://github.com/romanmazeev/MRZScanner.git", .upToNextMajor(from: "1.1.0")) ] ``` *The library has an SPM [dependency](https://github.com/romanmazeev/MRZParser) for MRZ code parsing.* @@ -28,17 +28,12 @@ ScanningConfiguration(orientation: .up, regionOfInterest: roi, minimumTextHeight 2. After you need to start scanning ```swift /// Live scanning -for try await scanningResult in MRZScanner.scanLive(imageStream: imageStream, configuration: configuration) { +for try await scanningResult in imageStream.scanForMRZCode(configuration: configuration) { // Handle `scanningResult` here } /// Single scanning -let scanningResult = try await MRZScanner.scanSingle(image: image, configuration: configuration) -``` - -*Also, for the convenience of transforming coordinates into a normalized form and back, there is a static method `convertRect`* -```swift -MRZScanner.convertRect(to: .normalizedRect, rect: rect, imageWidth: imageWidth, imageHeight: imageHeight) +let scanningResult = try await image.scanForMRZCode(configuration: configuration) ``` ## Example diff --git a/Sources/MRZScanner/AsyncStream+map.swift b/Sources/MRZScanner/AsyncStream+map.swift deleted file mode 100644 index d7d2cd5..0000000 --- a/Sources/MRZScanner/AsyncStream+map.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// AsyncStream+map.swift -// -// -// Created by Roman Mazeev on 29/12/2022. -// - -extension AsyncStream { - public func map(_ transform: @escaping (Self.Element) async throws -> Transformed) -> AsyncThrowingStream { - return AsyncThrowingStream { continuation in - Task { - for await element in self { - do { - continuation.yield(try await transform(element)) - } catch { - continuation.finish(throwing: error) - } - } - continuation.finish() - } - } - } -} diff --git a/Sources/MRZScanner/MRZFrequencyTracker.swift b/Sources/MRZScanner/MRZFrequencyTracker.swift deleted file mode 100644 index 498f8d7..0000000 --- a/Sources/MRZScanner/MRZFrequencyTracker.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// MRZFrequencyTracker.swift -// -// -// Created by Roman Mazeev on 13.07.2021. -// - -import MRZParser - -final class MRZFrequencyTracker { - private let frequency: Int - private var seenResults: [MRZResult: Int] = [:] - - init(frequency: Int) { - self.frequency = frequency - } - - func isResultStable(_ result: MRZResult) -> Bool { - guard let seenResultFrequency = seenResults[result] else { - seenResults[result] = 1 - return false - } - - guard seenResultFrequency + 1 < frequency else { - seenResults = [:] - return true - } - - seenResults[result]? += 1 - return false - } -} diff --git a/Sources/MRZScanner/MRZScanner.swift b/Sources/MRZScanner/MRZScanner.swift deleted file mode 100644 index b5be7f1..0000000 --- a/Sources/MRZScanner/MRZScanner.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// MRZScanner.swift -// -// -// Created by Roman Mazeev on 12.07.2021. -// - -import CoreImage -import Vision -import MRZParser - -public struct ScanningConfiguration { - let orientation: CGImagePropertyOrientation - let regionOfInterest: CGRect - let minimumTextHeight: Float - let recognitionLevel: VNRequestTextRecognitionLevel - - public init(orientation: CGImagePropertyOrientation, regionOfInterest: CGRect, minimumTextHeight: Float, recognitionLevel: VNRequestTextRecognitionLevel) { - self.orientation = orientation - self.regionOfInterest = regionOfInterest - self.minimumTextHeight = minimumTextHeight - self.recognitionLevel = recognitionLevel - } -} - -public struct MRZScanner { - public static func scanLive(imageStream: AsyncStream, configuration: ScanningConfiguration) -> AsyncThrowingStream, Error> { - let frequencyTracker = MRZFrequencyTracker(frequency: 2) - - return imageStream.map { image in - let recognizerResults = try await VisionTextRecognizer.recognize(scanningImage: image, configuration: configuration) - let validatedResults = MRZValidator.getValidatedResults(from: recognizerResults.map(\.results)) - - let boundingRects = getScannedBoundingRects(from: recognizerResults, validLines: validatedResults) - guard let parsedResult = MRZParser.init(isOCRCorrectionEnabled: true).parse(mrzLines: validatedResults.map(\.result)) else { - return .notFound(boundingRects) - } - - return frequencyTracker.isResultStable(parsedResult) - ? LiveScanningResult.found(.init(result: parsedResult, boundingRects: boundingRects)) - : .notFound(boundingRects) - } - } - - public static func scanSingle(image: CIImage, configuration: ScanningConfiguration) async throws -> ScanningResult { - let recognizerResults = try await VisionTextRecognizer.recognize(scanningImage: image, configuration: configuration) - let validatedResults = MRZValidator.getValidatedResults(from: recognizerResults.map(\.results)) - guard let parsedResult = MRZParser.init(isOCRCorrectionEnabled: true).parse(mrzLines: validatedResults.map(\.result)) else { - throw MRZScannerError.codeNotFound - } - - return .init(result: parsedResult, boundingRects: getScannedBoundingRects(from: recognizerResults, validLines: validatedResults)) - } - - private static func getScannedBoundingRects( - from results: [VisionTextRecognizer.Result], - validLines: [MRZValidator.Result] - ) -> ScanedBoundingRects { - let allBoundingRects = results.map(\.boundingRect) - let validRectIndexes = Set(validLines.map(\.index)) - - var validScannedBoundingRects: [CGRect] = [] - var invalidScannedBoundingRects: [CGRect] = [] - allBoundingRects.enumerated().forEach { - if validRectIndexes.contains($0.offset) { - validScannedBoundingRects.append($0.element) - } else { - invalidScannedBoundingRects.append($0.element) - } - } - - return .init(valid: validScannedBoundingRects, invalid: invalidScannedBoundingRects) - } - - private init() {} -} - -public extension MRZScanner { - enum RectConvertationType { - case imageRect - case normalizedRect - } - - static func convertRect(to type: RectConvertationType, rect: CGRect, imageWidth: Int, imageHeight: Int) -> CGRect { - switch type { - case .imageRect: - return VNImageRectForNormalizedRect(rect, imageWidth, imageHeight) - case .normalizedRect: - return VNNormalizedRectForImageRect(rect, imageWidth, imageHeight) - } - } -} - -enum MRZScannerError: Error { - case codeNotFound -} diff --git a/Sources/MRZScanner/MRZValidator.swift b/Sources/MRZScanner/MRZValidator.swift deleted file mode 100644 index 5a9307a..0000000 --- a/Sources/MRZScanner/MRZValidator.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// MRZValidator.swift -// -// -// Created by Roman Mazeev on 13.07.2021. -// - -import MRZParser - -struct MRZValidator { - struct Result { - /// MRZLine - let result: String - /// MRZLine boundingRect index - let index: Int - } - - static func getValidatedResults(from possibleLines: [[String]]) -> [Result] { - var validLines: [Result] = [] - - for validMRZCode in MRZFormat.allCases { - guard validLines.count < validMRZCode.linesCount else { break } - for (index, lines) in possibleLines.enumerated() { - guard validLines.count < validMRZCode.linesCount else { break } - let spaceFreeLines = lines.lazy.map { $0.filter { !$0.isWhitespace } } - guard let mostLikelyLine = spaceFreeLines.first(where: { - $0.count == validMRZCode.lineLength - }) else { continue } - validLines.append(.init(result: mostLikelyLine, index: index)) - } - - if validLines.count != validMRZCode.linesCount { - validLines = [] - } - } - return validLines - } - - private init() {} -} diff --git a/Sources/MRZScanner/Private/BoundingRectConverter.swift b/Sources/MRZScanner/Private/BoundingRectConverter.swift new file mode 100644 index 0000000..1eee9b4 --- /dev/null +++ b/Sources/MRZScanner/Private/BoundingRectConverter.swift @@ -0,0 +1,54 @@ +// +// BoundingRectConverter.swift +// +// +// Created by Roman Mazeev on 01/12/2023. +// +// + +import CoreImage +import Dependencies + +struct BoundingRectConverter: Sendable { + let convert: @Sendable (_ results: [TextRecognizer.Result], _ validLines: [Validator.Result]) -> ScannedBoundingRects +} + +extension BoundingRectConverter: DependencyKey { + static var liveValue: Self { + .init( + convert: { results, validLines in + let allBoundingRects = results.map(\.boundingRect) + let validRectIndexes = Set(validLines.map(\.index)) + + var validScannedBoundingRects: [CGRect] = [] + var invalidScannedBoundingRects: [CGRect] = [] + allBoundingRects.enumerated().forEach { + if validRectIndexes.contains($0.offset) { + validScannedBoundingRects.append($0.element) + } else { + invalidScannedBoundingRects.append($0.element) + } + } + + return .init(valid: validScannedBoundingRects, invalid: invalidScannedBoundingRects) + } + ) + } +} + +extension DependencyValues { + var boundingRectConverter: BoundingRectConverter { + get { self[BoundingRectConverter.self] } + set { self[BoundingRectConverter.self] = newValue } + } +} + +#if DEBUG +extension BoundingRectConverter: TestDependencyKey { + static var testValue: Self { + Self( + convert: unimplemented("BoundingRectConverter.convert") + ) + } +} +#endif diff --git a/Sources/MRZScanner/Private/Parser.swift b/Sources/MRZScanner/Private/Parser.swift new file mode 100644 index 0000000..67f3f39 --- /dev/null +++ b/Sources/MRZScanner/Private/Parser.swift @@ -0,0 +1,40 @@ +// +// Parser.swift +// +// +// Created by Roman Mazeev on 02/12/2023. +// + +import Dependencies +import MRZParser + +struct Parser: Sendable { + let parse: @Sendable (_ mrzLines: [String]) -> ParserResult? +} + +extension Parser: DependencyKey { + static var liveValue: Self { + .init( + parse: { mrzLines in + MRZParser(isOCRCorrectionEnabled: true).parse(mrzLines: mrzLines) + } + ) + } +} + +extension DependencyValues { + var parser: Parser { + get { self[Parser.self] } + set { self[Parser.self] = newValue } + } +} + +#if DEBUG +extension Parser: TestDependencyKey { + static var testValue: Self { + Self( + parse: unimplemented("Parser.parse") + ) + } +} +#endif diff --git a/Sources/MRZScanner/Private/TextRecognizer.swift b/Sources/MRZScanner/Private/TextRecognizer.swift new file mode 100644 index 0000000..02d4fc2 --- /dev/null +++ b/Sources/MRZScanner/Private/TextRecognizer.swift @@ -0,0 +1,67 @@ +// +// TextRecognizer.swift +// +// +// Created by Roman Mazeev on 13.07.2021. +// + +import CoreImage +import Dependencies +import Vision + +struct TextRecognizer: Sendable { + struct Result { + let results: [String] + let boundingRect: CGRect + } + + let recognize: @Sendable (_ configuration: ScanningConfiguration, _ scanningImage: CIImage) async throws -> [Result] +} + +extension TextRecognizer: DependencyKey { + static var liveValue: Self { + .init( + recognize: { request, scanningImage in + try await withCheckedThrowingContinuation { continuation in + let visionRequest = VNRecognizeTextRequest { request, _ in + guard let visionResults = request.results as? [VNRecognizedTextObservation] else { + return + } + + continuation.resume(returning: visionResults.map { + Result(results: $0.topCandidates(10).map(\.string), boundingRect: $0.boundingBox) + }) + } + visionRequest.regionOfInterest = request.regionOfInterest + visionRequest.minimumTextHeight = request.minimumTextHeight + visionRequest.recognitionLevel = request.recognitionLevel + visionRequest.usesLanguageCorrection = false + + do { + try VNImageRequestHandler(ciImage: scanningImage, orientation: request.orientation) + .perform([visionRequest]) + } catch { + continuation.resume(throwing: error) + } + } + } + ) + } +} + +extension DependencyValues { + var textRecognizer: TextRecognizer { + get { self[TextRecognizer.self] } + set { self[TextRecognizer.self] = newValue } + } +} + +#if DEBUG +extension TextRecognizer: TestDependencyKey { + static var testValue: Self { + Self( + recognize: unimplemented("TextRecognizer.recognize") + ) + } +} +#endif diff --git a/Sources/MRZScanner/Private/Tracker.swift b/Sources/MRZScanner/Private/Tracker.swift new file mode 100644 index 0000000..477fcb0 --- /dev/null +++ b/Sources/MRZScanner/Private/Tracker.swift @@ -0,0 +1,46 @@ +// +// Tracker.swift +// +// +// Created by Roman Mazeev on 01/12/2023. +// + +import Dependencies + +struct Tracker: Sendable { + let updateResults: @Sendable (_ results: TrackerResult, _ result: ParserResult) -> TrackerResult +} + +extension Tracker: DependencyKey { + static var liveValue: Self { + .init( + updateResults: { results, result in + var seenResults = results + guard let seenResultFrequency = seenResults[result] else { + seenResults[result] = 1 + return seenResults + } + + seenResults[result] = seenResultFrequency + 1 + return seenResults + } + ) + } +} + +extension DependencyValues { + var tracker: Tracker { + get { self[Tracker.self] } + set { self[Tracker.self] = newValue } + } +} + +#if DEBUG +extension Tracker: TestDependencyKey { + static var testValue: Self { + Self( + updateResults: unimplemented("Tracker.updateResults") + ) + } +} +#endif diff --git a/Sources/MRZScanner/Private/Validator.swift b/Sources/MRZScanner/Private/Validator.swift new file mode 100644 index 0000000..caa4a4e --- /dev/null +++ b/Sources/MRZScanner/Private/Validator.swift @@ -0,0 +1,64 @@ +// +// Validator.swift +// +// +// Created by Roman Mazeev on 13.07.2021. +// + +import Dependencies +import MRZParser + +struct Validator: Sendable { + struct Result { + /// MRZLine + let result: String + /// MRZLine boundingRect index + let index: Int + } + + let getValidatedResults: @Sendable (_ possibleLines: [[String]]) -> [Result] +} + +extension Validator: DependencyKey { + static var liveValue: Self { + .init( + getValidatedResults: { possibleLines in + var validLines: [Result] = [] + + for validMRZCode in MRZFormat.allCases { + guard validLines.count < validMRZCode.linesCount else { break } + for (index, lines) in possibleLines.enumerated() { + guard validLines.count < validMRZCode.linesCount else { break } + let spaceFreeLines = lines.lazy.map { $0.filter { !$0.isWhitespace } } + guard let mostLikelyLine = spaceFreeLines.first(where: { + $0.count == validMRZCode.lineLength + }) else { continue } + validLines.append(.init(result: mostLikelyLine, index: index)) + } + + if validLines.count != validMRZCode.linesCount { + validLines = [] + } + } + return validLines + } + ) + } +} + +extension DependencyValues { + var validator: Validator { + get { self[Validator.self] } + set { self[Validator.self] = newValue } + } +} + +#if DEBUG +extension Validator: TestDependencyKey { + static var testValue: Self { + Self( + getValidatedResults: unimplemented("Validator.getValidatedResults") + ) + } +} +#endif diff --git a/Sources/MRZScanner/Public/Scanner.swift b/Sources/MRZScanner/Public/Scanner.swift new file mode 100644 index 0000000..dc8db57 --- /dev/null +++ b/Sources/MRZScanner/Public/Scanner.swift @@ -0,0 +1,101 @@ +// +// Scanner.swift +// +// +// Created by Roman Mazeev on 12.07.2021. +// + +import CoreImage +import Dependencies +import Vision + +public struct ScanningConfiguration { + let orientation: CGImagePropertyOrientation + let regionOfInterest: CGRect + let minimumTextHeight: Float + let recognitionLevel: VNRequestTextRecognitionLevel + + public init(orientation: CGImagePropertyOrientation, regionOfInterest: CGRect, minimumTextHeight: Float, recognitionLevel: VNRequestTextRecognitionLevel) { + self.orientation = orientation + self.regionOfInterest = regionOfInterest + self.minimumTextHeight = minimumTextHeight + self.recognitionLevel = recognitionLevel + } +} + +// MARK: Image stream scanning + +public extension AsyncStream { + func scanForMRZCode( + configuration: ScanningConfiguration, + scanningPriority: TaskPriority? = nil + ) -> AsyncThrowingStream, Error> { + .init { continuation in + let scanningTask = Task { + let seenResults: LockIsolated = .init([:]) + + for await image in self { + await withTaskGroup(of: Void.self) { group in + _ = group.addTaskUnlessCancelled(priority: scanningPriority) { + do { + @Dependency(\.textRecognizer) var textRecognizer + let recognizerResult = try await textRecognizer.recognize(configuration, image) + + @Dependency(\.validator) var validator + let validatedResults = validator.getValidatedResults(recognizerResult.map(\.results)) + + @Dependency(\.boundingRectConverter) var boundingRectConverter + let boundingRects = boundingRectConverter.convert(recognizerResult, validatedResults) + + @Dependency(\.parser) var parser + guard let parsedResult = parser.parse(validatedResults.map(\.result)) else { + continuation.yield(.init(results: seenResults.value, boundingRects: boundingRects)) + return + } + + @Dependency(\.tracker) var tracker + seenResults.withValue { + $0 = tracker.updateResults(seenResults.value, parsedResult) + } + + continuation.yield(.init(results: seenResults.value, boundingRects: boundingRects)) + } catch { + continuation.finish(throwing: error) + } + } + } + } + } + + continuation.onTermination = { _ in + scanningTask.cancel() + } + } + } +} + +// MARK: Single image scanning + +public extension CIImage { + enum ScanningError: Error { + case codeNotFound + } + + func scanForMRZCode(configuration: ScanningConfiguration) async throws -> ScanningResult { + @Dependency(\.textRecognizer) var textRecognizer + let recognizerResult = try await textRecognizer.recognize(configuration, self) + + @Dependency(\.validator) var validator + let validatedResults = validator.getValidatedResults(recognizerResult.map(\.results)) + + @Dependency(\.boundingRectConverter) var boundingRectConverter + let boundingRects = boundingRectConverter.convert(recognizerResult, validatedResults) + + @Dependency(\.parser) var parser + guard let parsedResult = parser.parse(validatedResults.map(\.result)) else { + throw ScanningError.codeNotFound + } + + return .init(results: parsedResult, boundingRects: boundingRects) + } +} diff --git a/Sources/MRZScanner/Public/ScanningResult.swift b/Sources/MRZScanner/Public/ScanningResult.swift new file mode 100644 index 0000000..7d47ed8 --- /dev/null +++ b/Sources/MRZScanner/Public/ScanningResult.swift @@ -0,0 +1,74 @@ +// +// ScanningResult.swift +// +// +// Created by Roman Mazeev on 29/12/2022. +// + +import CoreGraphics +import Vision +import MRZParser + +public typealias TrackerResult = [ParserResult: Int] +public typealias ParserResult = MRZResult + +extension ParserResult: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(format) + hasher.combine(documentType) + hasher.combine(documentTypeAdditional) + hasher.combine(countryCode) + hasher.combine(surnames) + hasher.combine(givenNames) + hasher.combine(documentNumber) + hasher.combine(nationalityCountryCode) + hasher.combine(birthdate) + hasher.combine(sex) + hasher.combine(expiryDate) + hasher.combine(optionalData) + hasher.combine(optionalData2) + } + + public static func == (lhs: MRZResult, rhs: MRZResult) -> Bool { + lhs.format == rhs.format && + lhs.documentType == rhs.documentType && + lhs.documentTypeAdditional == rhs.documentTypeAdditional && + lhs.countryCode == rhs.countryCode && + lhs.surnames == rhs.surnames && + lhs.givenNames == rhs.givenNames && + lhs.documentNumber == rhs.documentNumber && + lhs.nationalityCountryCode == rhs.nationalityCountryCode && + lhs.birthdate == rhs.birthdate && + lhs.sex == rhs.sex && + lhs.expiryDate == rhs.expiryDate && + lhs.optionalData == rhs.optionalData && + lhs.optionalData2 == rhs.optionalData2 + } +} + +public struct ScanningResult { + public let results: T + public let boundingRects: ScannedBoundingRects +} + +public extension ScanningResult where T == [ParserResult: Int] { + func best(repetitions: Int) -> ParserResult? { + results.max(by: { $0.value > $1.value })?.key + } +} + +public struct ScannedBoundingRects { + public let valid: [CGRect], invalid: [CGRect] + + public func convertedToImageRects(imageWidth: Int, imageHeight: Int) -> Self { + .init( + valid: valid.map { VNImageRectForNormalizedRect($0, imageWidth, imageHeight) }, + invalid: invalid.map { VNImageRectForNormalizedRect($0, imageWidth, imageHeight) } + ) + } + + public init(valid: [CGRect], invalid: [CGRect]) { + self.valid = valid + self.invalid = invalid + } +} diff --git a/Sources/MRZScanner/ScanningResult.swift b/Sources/MRZScanner/ScanningResult.swift deleted file mode 100644 index 42a242a..0000000 --- a/Sources/MRZScanner/ScanningResult.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// ScanningResult.swift -// -// -// Created by Roman Mazeev on 29/12/2022. -// - -import CoreGraphics - -public struct ScanedBoundingRects { - public let valid: [CGRect], invalid: [CGRect] - - public func convertedToImageRects(imageWidth: Int, imageHeight: Int) -> Self { - .init( - valid: valid.map { MRZScanner.convertRect(to: .imageRect, rect: $0, imageWidth: imageWidth, imageHeight: imageHeight) }, - invalid: invalid.map { MRZScanner.convertRect(to: .imageRect, rect: $0, imageWidth: imageWidth, imageHeight: imageHeight) } - ) - } - - public init(valid: [CGRect], invalid: [CGRect]) { - self.valid = valid - self.invalid = invalid - } -} - -public struct ScanningResult { - public let result: T - public let boundingRects: ScanedBoundingRects -} - -public enum LiveScanningResult { - case notFound(ScanedBoundingRects) - case found(ScanningResult) -} diff --git a/Sources/MRZScanner/VisionTextRecognizer.swift b/Sources/MRZScanner/VisionTextRecognizer.swift deleted file mode 100644 index ae206a7..0000000 --- a/Sources/MRZScanner/VisionTextRecognizer.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// TextRecognizer.swift -// -// -// Created by Roman Mazeev on 13.07.2021. -// - -import Vision -import CoreImage - -struct VisionTextRecognizer { - struct Result { - let results: [String] - let boundingRect: CGRect - } - - static func recognize(scanningImage: CIImage, configuration: ScanningConfiguration) async throws -> [Result] { - try await withCheckedThrowingContinuation { continuation in - let request = VNRecognizeTextRequest { response, _ in - guard let visionResults = response.results as? [VNRecognizedTextObservation] else { return } - continuation.resume(returning: visionResults.map { - Result(results: $0.topCandidates(10).map(\.string), boundingRect: $0.boundingBox) - }) - } - - request.regionOfInterest = configuration.regionOfInterest - request.minimumTextHeight = configuration.minimumTextHeight - request.recognitionLevel = configuration.recognitionLevel - request.usesLanguageCorrection = false - - do { - try VNImageRequestHandler(ciImage: scanningImage, orientation: configuration.orientation).perform([request]) - } catch { - continuation.resume(throwing: error) - } - } - } - - private init() {} -} diff --git a/Tests/MRZScannerTests/MRZFrequencyTrackerTests.swift b/Tests/MRZScannerTests/MRZFrequencyTrackerTests.swift deleted file mode 100644 index 17e54aa..0000000 --- a/Tests/MRZScannerTests/MRZFrequencyTrackerTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// MRZFrequencyTrackerTests.swift -// -// -// Created by Roman Mazeev on 13.07.2021. -// - -import XCTest -@testable import MRZScanner - -final class MRZFrequencyTrackerTests: XCTestCase { - private var tracker: MRZFrequencyTracker! - private let frequency = 8 - - override func setUp() { - super.setUp() - - tracker = MRZFrequencyTracker(frequency: frequency) - } - - func testOneResultFrequencyTimes() { - for _ in 0 ..< frequency - 1 { - _ = tracker.isResultStable(StubModels.firstParsedResult) - } - - XCTAssertTrue(tracker.isResultStable(StubModels.firstParsedResult)) - } - - func testOneResultOneTime() { - XCTAssertFalse(tracker.isResultStable(StubModels.firstParsedResult)) - } - - func testTwoResultsFrequencyTimes() { - _ = tracker.isResultStable(StubModels.firstParsedResult) - XCTAssertFalse(tracker.isResultStable(StubModels.firstParsedResult)) - } - - func testTwoResultFrequencyTimes() { - for _ in 0 ..< 2 { - _ = tracker.isResultStable(StubModels.firstParsedResult) - } - - for _ in 0 ..< 2 { - _ = tracker.isResultStable(StubModels.secondParsedResult) - } - - for _ in 0 ..< 3 { - _ = tracker.isResultStable(StubModels.firstParsedResult) - } - - for _ in 0 ..< 1 { - _ = tracker.isResultStable(StubModels.secondParsedResult) - } - - XCTAssertFalse(tracker.isResultStable(StubModels.firstParsedResult)) - } -} diff --git a/Tests/MRZScannerTests/MRZValidatorTests.swift b/Tests/MRZScannerTests/MRZValidatorTests.swift deleted file mode 100644 index e58c41b..0000000 --- a/Tests/MRZScannerTests/MRZValidatorTests.swift +++ /dev/null @@ -1,168 +0,0 @@ -// -// MRZValidatorTests.swift -// -// -// Created by Roman Mazeev on 12.07.2021. -// - -import XCTest -@testable import MRZScanner - -final class MRZValidatorTests: XCTestCase { - func testTD1CleanValidation() { - let valueToValidate = [ - ["I Bool { - lhs.result == rhs.result && lhs.index == rhs.index - } -} diff --git a/Tests/MRZScannerTests/Mocks.swift b/Tests/MRZScannerTests/Mocks.swift new file mode 100644 index 0000000..c8674db --- /dev/null +++ b/Tests/MRZScannerTests/Mocks.swift @@ -0,0 +1,70 @@ +// +// Mocks.swift +// +// +// Created by Roman Mazeev on 01/12/2023. +// + +import Foundation +@testable import MRZScanner +import Vision + +extension ParserResult { + static var mock: Self { + .init( + format: .td3, + documentType: .passport, + documentTypeAdditional: "A", + countryCode: "test", + surnames: "test", + givenNames: "test", + documentNumber: "test", + nationalityCountryCode: "test", + birthdate: .mock, + sex: .male, + expiryDate: .mock, + optionalData: "", + optionalData2: "" + ) + } +} + +extension Date { + static var mock: Self { + .init(timeIntervalSince1970: 0) + } +} + +extension ScanningConfiguration { + static func mock(roi: CGRect = .init(x: 0, y: 0, width: 1, height: 1)) -> Self { + .init( + orientation: .up, + regionOfInterest: roi, + minimumTextHeight: 0, + recognitionLevel: .fast + ) + } +} + +extension ScannedBoundingRects: Equatable { + public static func == (lhs: ScannedBoundingRects, rhs: ScannedBoundingRects) -> Bool { + lhs.valid == rhs.valid && + lhs.invalid == rhs.invalid + } +} + +extension TextRecognizer.Result: Equatable { + public static func == (lhs: TextRecognizer.Result, rhs: TextRecognizer.Result) -> Bool { + lhs.results == rhs.results && + lhs.boundingRect == rhs.boundingRect + } +} + +extension ScanningConfiguration: Equatable { + public static func == (lhs: ScanningConfiguration, rhs: ScanningConfiguration) -> Bool { + lhs.orientation == rhs.orientation && + lhs.regionOfInterest == rhs.regionOfInterest && + lhs.minimumTextHeight == rhs.minimumTextHeight && + lhs.recognitionLevel == rhs.recognitionLevel + } +} diff --git a/Tests/MRZScannerTests/Private/BoundingRectConverterTests.swift b/Tests/MRZScannerTests/Private/BoundingRectConverterTests.swift new file mode 100644 index 0000000..67c93f4 --- /dev/null +++ b/Tests/MRZScannerTests/Private/BoundingRectConverterTests.swift @@ -0,0 +1,51 @@ +// +// BoundingRectConverterTests.swift +// +// +// Created by Roman Mazeev on 01/12/2023. +// + +@testable import MRZScanner +import XCTest + +final class BoundingRectConverterTests: XCTestCase { + func testConverterEmpty() { + let result = BoundingRectConverter.liveValue.convert([], []) + XCTAssert(result.valid.isEmpty) + XCTAssert(result.invalid.isEmpty) + } + + func testConverter() { + let firstResult = TextRecognizer.Result(results: ["test"], boundingRect: .init(x: 0, y: 0, width: 20, height: 20)) + let secondResult = TextRecognizer.Result(results: ["test"], boundingRect: .zero) + let thirdResult = TextRecognizer.Result(results: ["test"], boundingRect: .init(x: 1, y: 1, width: 40, height: 60)) + + let result = BoundingRectConverter.liveValue.convert( + [ + firstResult, + secondResult, + thirdResult + ], + [ + Validator.Result(result: "test", index: 0), + Validator.Result(result: "test", index: 1), + Validator.Result(result: "test", index: 1) + ] + ) + XCTAssertEqual(result.valid, [firstResult.boundingRect, secondResult.boundingRect]) + XCTAssertEqual(result.invalid, [thirdResult.boundingRect]) + + XCTAssertEqual( + result.convertedToImageRects(imageWidth: 10, imageHeight: 10), + .init( + valid: [ + .init(x: 0, y: 0, width: 200, height: 200), + .zero + ], + invalid: [ + .init(x: 10, y: 10, width: 400, height: 600) + ] + ) + ) + } +} diff --git a/Tests/MRZScannerTests/Private/ParserTests.swift b/Tests/MRZScannerTests/Private/ParserTests.swift new file mode 100644 index 0000000..4e3aa65 --- /dev/null +++ b/Tests/MRZScannerTests/Private/ParserTests.swift @@ -0,0 +1,57 @@ +// +// ParserTests.swift +// +// +// Created by Roman Mazeev on 02/12/2023. +// + +@testable import MRZScanner +import XCTest + +/// More tests are located in `MRZParser` library +final class ParserTests: XCTestCase { + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(abbreviation: "GMT+0:00") + return formatter + }() + + func testEmpty() { + let parser = Parser.liveValue + + let result = parser.parse([]) + XCTAssertNil(result) + } + + func testValid() throws { + let parser = Parser.liveValue + + let mrzStrings = ["IRUTOERIKSSON<8FswakkIf=k2$; zs!sK-d+Xl&+^547e28l=mggTx}Pbw9ft5@;}cg)M0t&J3OAu~;o7m38qUm;D1` z2Fn5>>Z_2d{C~cHIJ!D2yTqbS%fZnb^E$K=ee-=!cOHWY*N>^zo8v38jZ0GQ8UO00 zfzsa9YR)Tv7&y|cZrIWE} zmDzYbYJg2=L5ov0k1Gx)BS`{@!hBYe0|(Ev#-=P=aLRkN8^ig9-ul}~t!Jdx4_8$( zn&<=BcY29VUcN=DmviRbc%6t;&=AXl&F1}mrAdd=DfquaE&6Q`=#U^~2oq&M2GJ=4d8G?Xt zr+|Q9Ib^gc@dCeqGM14L1>S+LSCMuA@CNH3spSj;@{9DpH>gLUfE%z8+C@rE4Eh)p zAD)NB9+9{YI3tLZsF141+C`>&27&40_clwj?KNMzz9lT|Uq!IMEg^prFfeJX83y)& zV5$=Z#UB4WNhMKkNu^T)VJXRFy=~p*1p1ZChm|3PL zcvZ5dGww5-S;y<>AcTGZufR?yy*9!BK0ydUFeD(5|ND3d6ubaLmlxQH zQti_Fqt1tCVW|wjAy-qO@1z4QCsR?`z&bJ&TtRp9`c&l`DQ>hc4ij>jY;N8^5SIUj zo^D~R6VolvU0ZXrUmS=^!IDZXOPuZPSrv#9Hf!`_3m?klN7Rd;%=}HkpPM4DS#aZc zY)eh1h?$`u>LYb}SEF}7Qm{ef-lQRRwk8a|;kv(lVknE3!Sf&YVWngOHgr??38HzB zxl6N5r))$%PA+MNY}1 zSb>Yl=SpL^fyAuwBa1u0K@&&lT@+%^sz<5c#CO0;Gf)9z+zbHZWiJG%jPkO_FSM3= z#PQ9IlJ0gcVw>I%ro&buul}0m<%utSmKVDZ1w#jI;h0~ArKcB}SC@fTRqZj)?wwcY zpGb8W#JG8^7BM@*{yu2PX~alNzf5*_iELBR9;Er|A$sBXE=tl~b~PEWC`vul0|1V% zGy15#BkUa+q4P(Jn;20ERr>oZ*rCWl7NLnmLuXzsDe0#h6`dszPb222JBt2Ih^3Oy zJ-q}4Yg$KYI#*1$5BK$5FxJ)_bh5&sc)eSoh-L$jI5t3*b@O#6y;$-`?yyLQ@_EOw zUP%Cl)sBPN1ahpm3}1u)J?}CE!j7{LNB%Q+EJ<5^HD^y{6_}qWx@TANmEo#QN_n^^ z1RPpbfhi1R!w4F+go*F&hU~tb=XUA8nV^Sud$oj=x2CyP;JJ7mCfw_FjaP0+|I2Zy zVGMRH3|6vN^pRm(?`n?HtPcg6J1A8T%XE*PyO6AYIBDvzM{5g3b=GyujQrl%9X-_Pqhs8FVmL7Xc47Kg(TjlKcFbKR)# zBhBC*e_Bcc-mCW=>Fi5W;+}SL4hwiBIV9{oi%3g;;a zOnjJ!puuNF{ONXeuJym)fgYQIFicoJ*Gkrh)eUoh)Qjv3A`}AqeRxPC7YWmY5)d{T z&x_-}wf&pop08`z7*j=Z_t&^Tgw~ky)!whHW6$AMUkZwt8yGA{l)?n*V|Y?1_=p5> zl@UHRj@-xl?j(gv0jts%AxN|OPhElh&78+Y^}3kxq=J5bMu<>&kLFI4%CA=IPv4dY zWzH{;V^?9~L_%?FrUk`*ypG+rDfA)-qGv}ojb{`92z7x2x`ZR}kt=z>*HgOZ*n3K+ zgPedNa-cdl@RrPrs;wb(qx#s`a_VYlXYQwXv8)#g&?A^+CZKaZs;UW3g_V^Ndnl#T z49c>$y&s{wUE4?2M!VXWr0?H>fZ831NAISz*qX*q%54mQVRBZXkq(u2g!kU)Hd`!j zD;gPISg>q$ROc3XZ8I6S`)tb(6JLnlTODL3hj6TKF%{;bl!pJXCgAxr8 zUf?=y!N2sDuw;)>$IMY#3h{|qiaemuv4-)&glIS5_rY1VuNGXtv&j((k)`~ zW5y6MI%CZG8MF^Ko1DLD6~>UXFB#L8g^53iU0mj*%$ei40!31WJBX{jbYbs9DO<;>+zn$jB?`0iSPnu6cM(Hk;r@hxPsfR`dif&TacV-+>~Va1|e+^H#< zmMHorbb3`iaA~ByZQ0nYZS~Jh6L&N|{xYVtKj(GP>^PiCe}tDC4G?QkxotOdCl6m) z-H7@NIRG{b0eSSDJie7GeqpuLVoPuPxOY0hh3?$J4xmKwCA{cc!6iPfL*LM~C|y9} zZ>s4r-$^E?K;mP>Z3$htD@DL%`b6@G;!hg@p#cF~36EkOVH7OO8bgDU!b%>tAfhal zo*c}uOqnJKv7;YIXfDVYIRI(MiBR2*gv<|f-Bli#9Y3hBoZEmdFF;V$foe8Q_`+48 z2k*WGJIz=8w3^=iDF-`s4IrB0_fj?Z#c@v5Gm9^W#Pw-)KTy^ES6LiK!!glBQ3@2Y zW61h<>>y7^Kf?>8{e4rVVMY(cuU*utU0gUShYfQ`4BjCV-d?l{Gd9}>uBvolIA{kD zEN7-;$1T4BO@*eqM8GXW)*kgzZ8TDb40KWKknYi)Yu6N4h6wj5?Deq;|B>;prp5!$ zo0}iVOkq+Lru5BDvrj0QyoUn#)Xdl5=Wnpy zEIq4(#&l>b0p$es_4e;svdESZb5vjQOgQi?Gf#$Ug^He`ZxUygf9k?+))>Xx>#;-> z7NxS{Ww(~^M&6YPw`5~~k$^lyjg2KZIFkqnbV)*BFy56LluDD>#SU(!6sr_KkAX^M9-G^qZ14B_OLZRkw*7R5;?hfI!x;@3z- zm%mS2wgwNucl0#sp3@kQQ|)8g5vTsP+kskN%VD@h<#1*px{a-Lt(e}C`G@@aPvIp{ z!z0MxPR5L>eEZna@W%dYl76EPPMxelj_0zYpT~PZ0=VZN8QCnUqp;WW0}YoNXlMkP zixo$xzl^A5^ww8~*nKCfwT761J|4>D%LKH3e!zx3Gk?t|=ascR9b2NhEAb}4xVwri z)V4jzvQ7LvPQ6LLD{*QulV@z}(Gx>`xa3ohKFO5nwK?8h8;rh&4qC~kRM}RJfvxuO ztWvZ|6n~vVM^-z_mbisZ^7NaQ{G0aG;_b?3S-VJVA3jR;D=%)!G?Qm)oId{VqS5TF zFLZCAkhX!P=||v_fcywZB{@u3=pe3*=zP!T2QJwG1t}- zBxM%JJN~fgx)W4rC$79rPTtICm)#Q-g3Z|KCqqx?RO<8_y9$|8%d{ry>}xZE7O`Z; zbG1&xuMZp$6YfFT1uVj0=F-pE-t(heS!H9GIM<7*Z#*8NDg$HBqkHeQi;*9rTRPjSvxQ+DTY z<25C%-NE5R>Oab31~Gg)W71(*`L;wGXvPxWi~jNIiSGQgmosY4u?*8X`*WB5-2>0i zGUCo4n|Zb+E&}fT+zyTf*lcK^qTLy1pnRdY4%D%i)BSLrUnsaEM-p=wCq(^g$50c? zIb0saZnulMRL_6aW?68n;yd;if5m*2wu9O5b~zrXVb}LQXHp$ok1V|X3NNF60|pBE z`)s}wF?xDSVO!9^K&;A{V^ee|`_)-^X}&ga)>6?OKp=hy$>8>(__8U?jAcr(-fc#8LWKlsOJ~&MSqY>z?W5D`V92<^D%Xk(A zdi-l~#sW~BS%)`XAl?Aeqa;jMv{7SfT4S63SzUq9WT(~(3?X2*-!NI*$iRQm1#&rO zB&Sn+95P5tB0;*k-rQ$XQcP|6uIqKQDd}aVq-$fNa-SH2J;VpI*gfG{qc((?hfXQWM1SEDXSY-v>sU0;2Lp}7L1F;wm0n}2@hM0ZK5 z-vqI}kVtr4SoNdAU$cKlEIn9!yqIHX+cm1#H2FCs)IuZhk7WK+|k;^EnxulN37;{9qtEsoC5&Mix6XdHFDKJGD) zAV4Ac++vU6jlhY4_EL`Fn2g5fSssX@}?gK^7Yrc8W`HYk0%)HDrv* zl6hL?<3{vco3`lqOmOnX!l(-_LL&OY0yuJ!-!=io0Ui`G9jsQzU{)3lor~gXf7Enu zQR!pmp6u{?WBglAO%;klCeWBAKApGLWpZ+jjA9$?Q42W|_a<+U$}Y3O)|gU?yjm@1 zA7s%WlQ9wO4TB(E0o9}pBnQ5{nkT*l^iuuIbi-qeVEYnoud1#iF4tJvtdtrwDz$-g{ls<`FG=eZLxm#7`(ts zI347~5J%7*F~!_Q4)^5+<0jRi3;6m@P1`~$@jU`vW7SfdrR=`V)Da_6ekci0Jdx8L zff6r%$ToV%>8lg-ypxCS)XrZEpM)Jsp$Vk;xa`D#bqZrWV(#JMN3f~C z@#Ft;j7Q8G%`_E6B^~Q9>c~puZnc)h@1*_22{A=j_Y5iTW96GYz#jpFFoPQ5=80$! znZLpGsO`h-#Sp^%S`<7U&V0J8LY{xoB_-d+|D+xOVk2SBPpemI!fR^_-h7nSCPukw z*3X9wwB^4QrnqGekKVtY)eOb$)hqpD`2FYQ>+QYzp$@Oy&;SA-x#37tC%1F0A5M!$ zjmtC~H(O6pexq(p^{JDeYf~}VKYN9)0l%DIF}n9)qJJW?&A82t0#gwJSNuVxrgeG~ z9BZxfm8-Cf5&IXpY?u>ZW?avee%_i@>`@j2!^-6O7po<_d}Ltj?wkBl36BK^AXCOybQnK!=)?_>TW^8JvrPGLe_ogZ*L!5YIBXQd3NtxR z$zGnC-7x?;{@R!f!Q;R15|w&H?ggK|V_j%5lBxD_rZ+bxr7{{sK0wLhCzS99xia=W zY>00D=7VGo(p??M4?l1eCOE-2bGCVE)B`hT=LR@>qcD@gIM0cFq^lFsueQ`NRo_tt zf^#Ycb5CKVS$SC#Tz4l*Xy=<>cK52vT)`$U-~fjx_NwA6DBJ^|^0^%HNdZxUd~Xos$=6@RVN6UMN>fD_S0y5pf_K-u?lgrhQ_tum zP56kJvYDDJzi` zKmoXKBi8RtAg;B{a{2xK{@nG4-w;b$F4vuCMKUNHPvX*`EG=`w4AE!E@TzhWSoa;X z8mmjC+x={hXSfqXI-K#f2VxR5>Rj6HzYh9ZPIb)~`)j$SSth;1eUL_f5HHmXDWMyT z70Uk3JMRLfLMmK~Zd!j|F!BiR@JyXr1DJ)H)EOqYv1d9LcT> z8-c9XiHSk3?;d?kPN{<}MEU!7EeRRDKxwfe?fwHTG(9PpXS(AEhzztMw6^%q@T%0| z$CV75=H&^Vo(%~-VJE{Lc8pLWZq_D(9nqS@7z1#se9CeiL7;)DEd}rI*vWh))>n#G zs!us;paY2h^Q^!2(EzIRvWK+iOYn{U?3pKp7Jn0S&r?(y-Z)T`*N-M|?Sd_8eOdXC zWrK_(F$`F?P7{C2b6Mwakd2s9GX}+nVTN{S9<%jX*7e&o-PjnJ)<|TU;=_vO*ItB_ z54>|aZjzB9juR{>orc~YUT3hj{QLGNg|+mWxw(mjIRVCnluz&Tsn65T>$d}mZO?4g zWdjdf59{0rF=cMak#V>bX_Wk1*GO}HCY6%+#Tl4Y?jVzfA*R;vxYU`hVW#NcakkFi zi7JE~adThe+6q*$&fKRbC)f|4nh~gtmoc?ePi_bgZNc^(F%$F5)GZx;4dCQ`049I){j%+kY@th1k|<9B=0F9#zBn)%;kK2E5) z#S5xk(iMUf^7jD~+IZ3vg4A#j%T8cx+GcL}(dUb#%5@kB8!tNNS9Q1*Nx2=+Jadi@ zX#+|)6YtuAqYpL@RzR2`{kD5brXWYi63}|Icyn~Mo2%|lmAmXiEsTlXPj+LHj3ex2Pj9_Zf)@*m8%b7Y#*_1i6~j)I2bVRhZ?7Kt-kaBRCw-$_{NWH@}kJ z4M~?=J95Ej>BV*HT`PZ0oj=%v5CMV?%ITC1_Ri%AjK=OBD>Ziwjl+7gzY&VFa0PBm z-1n0hwNE&FKY$2m)A-joKo2s9Ke+fF7aP2+gh}X!c#Kfhknem!--5v_Xg?75S z1X1ItKiZ#G_s`2%9en4QOPwEq5Re6734v&!oYJp*SZcqk@M3tuuDjM_ij6s5@aa7F z{&a^jJ49PVpqi56y>HqPxK@qk#!I=$CeVR7+`MCoe+E#Kk>mxJUm6icxOEU6C|z>w zff2&~HZL#3CIVdO3N+WETy>x!aZ}ZzQ`?d60o}#C0#Sz_&xeE8?&(v{u<Tn})t-LVFt7Ul6 zl{WwfJbI6S&ayyB6y;oN+gvMwD#VFu*m0xgf+vFRZnQW~I{o(75D6wz_UoM;&b~$4 z`$B}K9-UqIKS?==ar{iUnQ-lB&nLGBA(hCLrxX(b&pTd(Wja(okztjS-nAu;%s7mC zNrM#mBa~Dp(reHGNM2=0`CxlrmBkZR5_JFV&ndoHCyxE$a$aAzj$5b-9S-kfW+VUm zx+gb}bNc5?4jaFvB_&UsXLEptwJy))M+(tBGsAFW_@c_R!YlRlP4xXv$dEARRV|m_ zC>hVu4+PW$>MYzQkZ9I9sv&+pWi+{ubU*~WyFK6$WsG3WHONQt&f3;)Uq1Oz1ifnVCQ&=DR8ZS6z_u4zwN zEmK(=9qf%h?9ktFZ*lBK8PV8RlP%3XiwrQ+<7Qdp9Cp*^v)ktfi_g!?K`gH&i@dI` z16|iBFR+5bXq$G=T?7kwPp|hoGwXw3GnkPIP-Jik;#7nH15dBJNo=~Vqx>&vI>%k}ZTt*DYOYNmolrv;}u8jWV7u08GK%aVlKG#}pHEqd`Rn<}UM?A$F5+f9p&Pt!&#-K5#^RMGO z&KMJt*~|R1#?&)n#$MB@deBEesrkMJhw}3@_gYfZ?3WggTs3>`<@-9-yE=~9{7Ki* zE}K!4Zf(%eHydhcM9H0Xl#=$AozZxrjs>7{Q)$OaKPrKX+KvX9_1LfEB#A1WVQnJG z-ZuAQiFqx#`B{tdbHs)B^hq2vf5>pXalOPdC)mQoK%>c2Gq5SBWOiVOPdS-{I$n=Fscwz{*3 zUIrIjDKtP2c=f+4M4lU2ake z0m|s!4C8`%j4R26D=gk4*nF=_XZ?}mhcwl$bd?K2--_OZ8M}l-V18kBBy-h1_tic> z(mo>v<6qXflKf~n8(@6}h&H%|c3s~Pq*?4~S4E}!d4j@oh6dno9-M1UW9ats9ywBl zC`(h1lBvkh1Dis;M=01YfA}mr(ND78vvoBMzpF{kz#T41^3f$04X0-W5F5|jdBazJ z+p6_~ai?Tbn9bm`VCZmfT^ioErr#<&?vFkgLdo^v^D2;>NMSEZ!}Zgg!CwwMMcqE3`4ieDe0N4llMZqUsD+D&YP%-LyiqUqJcyxxRJ zahc>M$F_@pafNJHyXea_AffQ8sy(57Ei4kD@!EaMG3E#nO06)SNgUVm!;k0&HoGRu zw*O)0Piyo!usFcy+f`cN1gUX@Y0q^;8~I~5zpC4#VWtmw#x*Y7k@_B_?NOxL{5p2x zKQbA1vdeV2(_c2=JuBJw@{Yl^dSTyAg4t0L9?C=L2+}uRWWo|jf`Y=98>;%YM=)wi z59CL@tXUcym$Nd8ZK8@Q7@Z>0&SFLWdkV_Uf962cru(!%d86zg21;=4ZU}if3_)u% zTU!tiO-{hGcJ4{oo?)IZHHb_bx;RAj>I=K&63YxORB$g@Py$ocKDF8gJtpRLoO$JZ z@8rB2BH)5dcgk?xjf|xSLFB#DwZ(Nj(2PEW)|a0Wz?EWfH!f66?D%I^ecA=Za~_ALwdX~Od?)lbuG zbmr+1e21AB%4u*DstC!vLjpc6!nKV~pGd+A+XDb0F2c&(U$tyyG5;_pRiedzb*a`z7c-qvH#%eok3Z z#@WFBOjWI$(-MSD6`2PiJVN3V{$<=db^8WySaJ~*QPtyx#^7-I2(@mH$E5FqAvW}4XCmsCJ_*)y;#_o+pVQ*4SL<=ERecz<2hZa7!aOQ7P*@BRHw^I!J2 z4?h_3OIe8E%eR1P)n4FbHU?G3jj!)^XBYL?E-2xS0}@d~d#N875bbP@Xs60C9LTgs z5&Ov6cBW1q%529{;GH(-e|8oS*pq;5HKJsW1(ucXIqemx9eF)C<48S2LtX^FVn9GW zv7%7t%!-x%=umf|8uWpQ$jnl;)rLj};Y2R25Xmi5wY2#?sLY}9+rvjp)H74R3P}k& zpTrGOD&5tib_8t+g*ErIfW&{Zre;(h z=q_MSuSa?v&P%b)mnx{VH*J2U)hhdOFB*p|3xyngjCZXYKSas&st})gGYngRp8@vu z^86Nk$S;|dKGCv1%=B4$dXUBRhrXEROs#9I-WdoJ8 zS}Wh_eK6hGeMabJxw{OVnV}2jy0e(X{`_1m&T?Eisa+7{?oL(oE>@yXijq1a8vv4& z_~62Ze7j--TS&}fEbBYM<)3YM`Dp@@5 z@8dLKIAHCr*onse7PI;WZd^+qLydK|LFC3|S^ig13OLt4G|s=kIDo0vh2(eKsG9De zR>mM+M{jvYTYh%BJRbhx~jz_#?^3 znfNh&Kg!R9H(9D3+H#wG>&l z@G6%(Ez5?agFkY0*0uYcwL?r_8CjnEn3n4|L3R*jDPXwZ6xY-IIM&yJdy;`|{>ztV zcZ|`jQ);?GZmZ*^t}p$l@TN|oHPM~wVFpE2cEU!)^GQ*lIE0w{qpt{Z1z+0+hMp#Wm!hpx0SY^7#6)ba3rNw z>R>FGD#OWHRYyCA$Ik{0ZANK|8pR|r>FJ9Ab2A#D<#}O?w6ly%Iv?L|?ro*WFe!NY z1tkeYwm=vo!<|7H?o)IXSwhdxGMo#uUF+h_TX-E2(x?U_c&geOiy|Ny!fh@rD2Pbl z^*}|EnCk#x{7X(Y%_1+F+p3g2F#I3Fj~qWRIBK%=3((2O2-Ly=Bn@KxX#xUb;eR>% zcdE(~Hr%+hX4HRo8T~CrasL=jbEg_#G68F@d5KCBHDSQqJ?0)Q1${*fL`%s!M6(XV zlbSotz`1RdaShQNs}AZ<*_9?T2TGZ*hS=(N;;KC9tcq(v|D*VcGj0~nUX;CR)*4l@(fuzr zp3yT}3hK}MgbDscK{~nzG6I7t1#YPmj{jp!l93UPyjZf(y@fKR#{a<)Y#|l;`G3L< z>`M{K^OIn_oIGSzrw50pqZOx{3k+&CX&sIaA5K3?0~MTSb%{Z)76=k_`XyBw1flFS z+-hTKXIwBmX^2cv#hTBhX*|g)Qm>LA9#** zC`kR~)xtiVpLcu4oL4P>`F{ULPYOzEQ>)OslTnSMgdb;in|v-fwa_LJ*F}_@pY4=_ zh;x=2&l2)I6~2>3CZ}f8;MT@Ytk41neBDodvq$PBpZ_r+Hsertuf~HBbZf$`Kb{V0 zbzV%_CIWpKqrF+u@fl_x_jlYk%=QrliPqHj!RUJ5%*2|w3=Rva3Rs5s^gI)P+r!$8 zWJ#dHKX-BKqi^n!AX;S*xZ>n|vq1490SST##%NQ^F7gzt*CFgi9A{?Avvm>GKDF4A z)muF;FrEIr=X&wsyF^mr`@Cr+(%lY&^%epvinaW54)IcwcG1QV% zLb~ysMk8|Tt?nR1nQvjbIpR&yi%o@)A_NY?7~J;dsvv<6P(lE{aBi-p!HNu!XLgvp zTW%0!o}CCS2UMDi)8m-{OP4y^M-dzC+n+@!iSj6cSIPNR9a;+fr9&56zs!VAICPr( zn!6zFyXk$1f!g`hf&hUqCIEcU4eWt zAhj*0q}3ZCQW2ialiBjmI6dmQy<`qg|*61XlK)X zaRW?fB|!4W2-OhQNZ-C(I17w+ig=(!3&wajmpGzzn!L=XRoc0Fyg{2Fw+8kjQ2Dlr z49LVuXkPjm6V{iXWVAG#!8q)LeV!A$HqU2Ru>}a{<~NsFd*EZ+?c90g%mdeC&8Y~C)C_SuqR>#nEvnSisVL4T<0jB2MYL=NMd zfsKC4i*X9e@MfQR8qJ&gCb!2W+e*u<=g0MlB8ei99V6+){PFcnL&i%;Kv3gC4MZ8< ziqU{3K&#OgD%W3^^xlrH{mUVitR-*L`I3$@lM=wmXhX`oBqH665N! z^McFd67h^}r}7sWFe=4;LeRaN?)`U%Pf3FFftTxx0Fr%pdPkAG{?*P1svxE>=fJ!8js01*3EeKXD|vV zyh{vLH3Lqy0AdWsj6$rTalD>Rri{S7+9T#cz-HHQTvby8|M#}s!NXR!sg`Izy=cGf z*x32)?tSqtd;z7VlGz1|V_qFTRDjQXqGg&iJj;fs4H~R8&eY{TN*J)FLsc+PQ&dVr zV`=H7F<2kSc_*m`UeixJZKalYHt_d1ZZjR*c1@v}9>Tn;E8_h8qTB^R@Gc*2(2#he z!My5mQg-zdEl?Z8+0%|39j~=dvVN;M4*ac~&MvAwBdu{IPCtm!kvyGX0fB+WD_#45 zYc#c=TWunk56wH(2ODkujX9C)aR>Vw;|fc|8@m>!67k%kL|5AS(~5hYCR8jjw6|`? z1)0#ES;~_9_rb)^Vh?^T$oGBtmgUO^*#P=-!rVq*)9w3OStl7JlaC4pvW05fsPxsk zyXzFQfhLB%S?%tu%5uWKhPQD0YK}x|Xnk?%x2mhJF}Z5R_Di;P>)qQTM$@TfsY&b* z(==9ffb`-KmSw4q80$u$sg^}rG{Zl!KT`;B6!>CWEs!3c)LxJGySJPg=(TieL$;io z-Rn(2n(ev{4n2G{pL$}c^M1U-g10zN?(t*a-k|i!Q^=IOX1Bha&U%N|AZMU3jYCm; zmXlqTQ{i+ra=n?M_$nsl}V^*3QmnK`F5 zCY?3}Wmd_?Y(Qa|bWm^iLtI(!ik=4#o2z+gyNbNhG0lIQKrt)Hqf}f*gbDOPvji0u zNv5;9?crr|-;EeyNv1pS2g=;{2x;d_bzl1eGpe6VNf_X)tgJmG3ra#VpXRFezOlFU zT>}T;Q^V&9jhhB4kHt1R_t;TtM5u@y*Cn5YL+q8s^pVj?a;&NyGOKIDBNN-%z!9Rm z1EjNjPX@(lSG}Qb%8jRDc-E;>=3WG1D$&EoF$N%AX6x&!HXd%nD5aOy)ENy_#Mb7U zPvojL-d)vE)6An2*#XPT&x31AHCwydnjW88k=c*rk(w#=v_wTVkKLhxxNUEC!%C=U zC3%%bh-^CgB<>r#vgz*RIIZ)~YsdNdB4GrSv|930H`wZJmRA1mTS}k>oiV+23k`K1 zU^RJQTi*56ikFx*!}#$nAd!O$86)2x5|BL*0X*Z^kNjO_G@6aliz56tg5<2`Qyb$3 zEwQ0qP>$6cj`22X7#jcq-j5pPefi6z#NOiUJ~V|AyrVJNU2B7L1EX_0Su`LZ^)kq? z;4RkfQ>6nv%?AynhjDBp;NURNgadbl1Tj$t)_q|`XOlHEazm>M^k-U8z9k{+=Ft)p z8|$Ww%qF*br!&i?-1$OVJ4{069qF8w%wv)UHpl)0KiEvD7?sXDx{f-(WI=tCb$45Q zbCX25A+8W^Ie$)}9flm6Ig9O8;Y<95fp050biJ({yLf{wmQbk?ip{3DgbbI~>w61w zTweJ4|G#RJOtxz$GGOe!hCqDXEu0iO11?lEa{j{q_48OFi zL1sjDz|#*M{!hFl9Ij>JFKBgePg9D^hvT`etG=>N9tljQ zob-NaMNcM_%w9`;Zu#vQnVXJ(c*D-4qLDn#sM)@n8%v)yraIdbbb4%36af?Ru+}g1#Z7*q1u06e+yY!?hV^%(Wp%} zcO$Z~U7bS(NM|f9_g4exm6Y=#TBY+-z9!b|=pus04s9+^i!L5(-s7D|@!U@gkwO(E z2+sMxK7qU}Cs7aYQ`syZDWc^f%b}IJyKEcHCohsrSG~&z^S!twwWD8>uWNwY*ARCU z#OBqi)FhUmg-9tqXHa)rQcTO-RyWg)&PF!7f=)-h>hs(<>tzjZ!4A=l%*(|F4qL(V zdrobSj?n}3>FeXy^2YL}4*AuyklG0gu>7vG`lq7l*N=Q2Sl7re8puza06Lkl?&GX_ z4kyhdIIv6>^J|Z0h=7|I0F}Fogv58CNpomr>0cn-oLuH<4$FnJ!j7uBBsVYbh2t!a ze9ZrON#`@h2CVTrgedsDS_6z;Y0C@Qu7$Yk<$M&Lp@#ub+65Mf5~B$sK@B_%wi<$p z(%1&Z!Ld&(ifjLHHR>3Uqc=2vX$jsYs7Os`vQr7IX}%H$qlA-wEg;DhWlN1N?7xSm zr_tNjx*l%VMvnXpKuZ{JwVDS=$}^a*ZB##6P7_H|wp-X~_zrCOm;)Vt25dcMzE=iO z;@cQCo0sTJ^`bX<9m!)SMBl>X?uF8bS4V+K@Yt4p$u}1~tfwgzG zbAx%_h6TD}@6CGIbT7+pa4qqv``jYw!SQu#Y%BTqGPRqp_c2Io_p!91qwU9GKW6RI zr?<|Jy0_=QTVQQfduW7zByrj(6bLW#O$CC-9k8$36<|pyc?nklfM>TR+kk6tiOF_6 zx@lhU`J&!w0HJ_IexsTeLbLt#_vHOaen1Q>Dv$|t1!r|$22G`N?~u_*H=zkLA6cU1 zk)}sh3UmTNviv(>ElpLE}XBht>N-hqF0cTcdj%&TC`*aha=mHwG>&p zG}sgs`Vs)ga6XBtJW`&$t+Y7R!-7H+Dn%Pm8Y!vMQtSc3+R(p*&h1^_qpos;=9_48P$2+T(|MI5g?9pQz4gPW+<)Nx61c;P=sG zz>+ok^hl!~sKQ=j_w2B&yI`e;6*{%qB-9tgk6b7SQ=Hwc~zV0J65fGE+ zVrrkMufnnEJ;$qf-~6n|N$YoIbj!%TR8NyPUw2C@(}>gtU9k<6DsZbVT7{NPDU!B! zT2ONMdIe7x5x@<)r>!p8QKhW4PNPX7qm|LpG+`?AY%6YCEu4ORF~Oj9=mE{w1ljsi z?i}i2znc_V?lwze8tZI4iF?=#o*p`i-)^Y4G8K-Qfc+du=798 zl9`A~kI#Qv_d#n(>_!7BnTXssJ#X4k@3{gcpE)M^ynM04+^mO>NJ`Im-3=7;C|>iw zi@a7~M5uRo!k^z|42$;k3D-(~|JLPhOnf%BbylWz;mEr^(Xo0EWguxaMck&cSJLX zTgFd;Z$~kTQIw`Yo$7X1;tIYV7Atz=p(@{QIiIlpyc3W)JXS02+9*wGvTUDJNLL1r z*>gMGUmzv@GrdR}{{HQp=2Moc;ruDB$X{~H2kU9w9$iOC4hWG7nwge*`q*(S-A|W~ z7*JBk`IlEIpK7!PNNH>D^ds*!ce^&>cfFOR-S7PNsi=+-aAaXnxO|UfQ7ucC6g`A# zJ0>*!#2?WW1OALPRZ1&0I`{q7>>lzj6Ugy1RbzAk8I-;%C zaF?G^fc`FChc~+{YWlJ!`mvz!i6w70n%@(WG*=zB>|(QQ-jvf*X>AJq7 z^+?5d7D;E>TYSrxtQZ67L3ro3h}q|hgF4euJR4%wmSyVqC3JUT?U_$VsKbfHsy_-m z;^lC!B!KwOVnc;Vw9>xGpSP_?-#7ojfjisUq*+PlAxt&qUOV_!v zM5}01yDR-~RP~^;816C8%qO?PqRhaBhtr$p?nhfvQt+V|4UXF&tiC$aC$Q_r54zkm^2)n!gRR! zeLQ3R1)mIg9%D>CV3e$zBH~*v-t1i8ApW)(G|SnOC-a%e3aw@LZ*C}mbfU2SFjN_k%YdP~QK&(&UOaTyni zThYdxa?6u8H7qHZB6ewtc#4Aro2zMc?OSHL=Iav!(DK+Jz(b{^*F3^0h*)widDhF@ zbz9t=MDrcyn40VsGS}u6ZWH2fJX9{kqD*I3^)1_NowSK@Jm2S$1)RaWOYGWChD;H%Jl(YGE_nA1P{3QfQ&hBTp3?FCgGa(vX!1S>sYq++ zC0*z)qs?j^R<}o|{>xqRE;2yz)L^5eMDa+O=V?_Uti}W1tHPP$%Jn>f#2qzTUaH7d zuQg*+clrIYSa;(;2;*hOg`tgd=WG5m=TtMP_FQl~(Pf3Tf5FBlHqrh&vT3NdE0aD> z`TiDprV>Abb-m57SNNGCK8c`PSWJ4z!&_<D5H(oKq{^3+aAu{q6Yv>TM;~Dprw& z-nG1R{p(k5)1AJ(2RVe!nR<=y>E;CgTT!5NGS>+bSm~g&3lhjYtL|XcGG6yo!Ek!u zaC~Ht-biq;C61n}!@FI&9w#(FIa*GhKWb@c8rF3oE=~zd(E3xGTf6BYt-Uy2r_Qfq zu79=b3N~+dh(N*!J9EL_K)81xNN=`<<}<=n+!jpV#VPz~{}!nurL4&7_C*<0bNU)p z;`geisKj*2dm#h0Nm%+0qnz5S$tE|OURhI8mxeK!tK@q}sJQ>lyyb>1~NG z4Q<4>JYz{*6$TXD|UgD(ujs&Z!r;^PCt zt0+B}Q@25&5x`=&%G`LDK!4DXH>-U`1mi!>1Jlov4v(n1J*^3jl% z=GJPW6CzRR#`EmnQ6n`akZ;X)@6G?Ic>l1}ikF;Dl@dAh;!Cn-U7{mJxB1D%W>5$y zHL!KA(Yr*S+>~>VV>8Q+Ih{wV-f)~?8smHI0WXUz)1!$;=Jyjp4hn)FKH@1{hqH3O zs;qQ4&I}W~Nh>NNG+vgM8K=VV8y&p?vcJ!D+$mk%%A|1^IuAPZs37XipA|j^(Trkv z^TJsuh0}c?$w*Ml;SG7wY)P)&!@vm1&B%-vjE~1$(8Nx;R8ksu)(d{iH*GGllB(MJ zT2`mOboY<#%AC=(FFn_u$U}qwIJL_kts=&fbWFY;VYSI!~O} z&6gf;I2P&=F!(g@gJ-^$D{XnsAdPi3{ve)BtC zk0)Usl0-jSTRL97Teu_~!7jOy@0m>~{+hvISy&!y?!M&Tt}GHS{Ot%|c5*#vVIML! zHsC7{R@0uD+x1^9pl^hn-Wu$6KgMY$lpC1sp4yaeSib|42SR=Tlv`5mQCs2wi4YJ& z{9&`XRQzNwe@o0mlHn|Uw68F>RY))oqtyNBbNE&H*`NV3I^|Xue)K5CMS4LIT zo;W{JaDn)!J*3B#!z=DmuX(a6Lt1fRMF4-2W0h_aQcbA~Ea8H|WmHukMZ#v%p%jLD zQqqw(enW2y7aPz{!^ z`mgF|VA8r>fy+(%qx56z%FZ6?sB6YJx1GB7<4c{bP0DER@lMb!A?cD&cf>)BKf#^tXE5cG1R!%h+;3jA&`^46j( z(!fv%7r#p*h4F#G+!^A=d4M6txl_t4z4$i29dhn6L7_|POH@o&m-49`?ed$_j87rh zq_+Xo(WHe7b+ekMoV6B%XtD1f!aYJfP{b3`XUb(S_4O%6H4JvnQi4?xT5G;3H7fXa zHoE;_%T7QN_{=a7qQmrHXuuLmrf-_|H~&|@_(^@qH+w{{tsyQO?tWwby+05V`NAeO zEh1&Z&bZ=C6F;_l(CJd6&`sI<_ld2;2_sU(6<*9zM6!LxPEW%`=Ma8z3F3q_F2));E`jM?LLrWo*{hqZul-tKcAkB)>7+jSw4)0t2A*k!iVE#y~M~Td;&(K+D6Y~lu z8&TC~4-<_=t~XhRlK9jv?BdgSY%1#0Ylpm4)b`j$CJVwKW3(@V}B(vaofTYKNLwhx5%i!GmIZ&UoAU7WHW<*YTtW61Qjo` zxmL`SYx}eO_2zzV>MXe4oSv-fHeh${85q{;HrJkSkK;>KAT^&0g1&QaeE=^wQ{ z6uwBS)taOBo&AKY>}cc zXBpXb)m0gaIdW}1)}oy^f1Li>s%wdR+m1-beIreK^@BGVS;uiC8&^=uqmfd^G+V4W z!50G+a%;;SVU#TU*1+|fbfOgQ3~L}#|> z0wUfRdOM>zMib>OS1A{wh{TOnxN-VKRx1xQuO}o^QIn)n49`6NPV}_@QxDHRQeU8uTm1T^!h$N;=9%F2fl7BqvgOn9z#Ll9kr}v37_bi z-da0zj_4exd&flBVT&@G8)9>LsIQ{DtA%!oeM8B!$|jytQdsUjh<3L}=ya>D|CC=O z_mFAm;SfL8cu14|;|A8vYEJuXo3^pE(j*pH%0hej@e~nV^Qh!NTsE+oWyMnv&zCe! z4!xTOIx|N=o~C{i;Z{gPU9rD-5+x626$f=`9DhR+Tqs!ORMFo>kYsl?63cwe>dqi1 z5*Z#3-$%hcC?1U5AB}Mdi!HnoOjMFwn+}45++DQI7Y#Es4Na_i;&W5_+-)+JvdjBD zFkJ54gzp<>eM>K{uXaD6xDg)sc$E2(%o`IfS4{dx+&kmWLwVafsAxa)139H8r(D5t zVNVCR`9D9Wge8mA@|(Pii(T5`H!sOe7q|K?Xw~((RBMKual!?M$vGXMc?Ob`*K?BB z5)GGTIX0kKucy4yJif2!gNJ4$&2t%*ch;2kkjk0*MGp=qK^R4@9_}P!1DfVnQ!9co zhH)l5l-own3C!GU7df$EGGZ=6611G*$3+5WTL#Q}#*J3Q=lrdhFK-d*VcfjLDuQMtGd&cfzxAy=E5Sm9O>G;qzxVY@?doJ6f$FD3d42l-ulw` zsJ~GPqbc*RZd4jYJM;iK-dYMiji z&gY2-kQ=JicZG(1G>?1Oi&>`*hUI^oN-+Se!oLnxTU>n(SOtTj*XcQ&uk;m+1Vk{;J?AX1_l3!kv3mNv=Ov zDf?h2#{|?{Ai~MC<8wGPoBlMQitQbXPZ6}}&j?AgN0|48`Y1BD^Le=YQ1qWnVmKDq z8!w{K|5k_&UH8|G99YpN-R)Q6}o`r&MabHcv8Dn5$VoC!u#w>ju6d!L7@3i&R*;Jv6*^ z+;IOK@;A`|-NKVGeS?Ft?TFpo6sQAIQ$@x3()n{WmWqE`PBt0EV|R)dW%dNfvVv%f zlrk1fOl5y{9Lnn+H#wV(XgLN}SDVTgU^JfMh6{?5AC@ANQN_OKFSF0pNvBGxCS&bV zi(cEC6aWvVTLZEiKY!-X#_b5lh_m;T``yB9N~Qyj<_0wwXR-;tcck2bqY+v%Ul`9I zk*87q`%-TljchQU*I*nSH8tCt!X~0LlE`?+$yr=XQE?xuDAxu417sU=@&lbT2dosU z-wgK-4ju0hS^d`}lXF@Wnshvn0rKp*E5MgtM_Y{0ui*5sd-HnpoFo*3rRM8LsP(c3 zOh05ra&i}gFQEd{)?GRx5WhGF0K4YZkufL#y&_Qs=kUFPrJn?b`CaC={@Oc04d3pc&@__j9|N3qJf8gc)&v*HsU-$p~ z!GC@Z|HB^%poRaJU*7)?)-c4WX#(;&)9VBG7VpQ9H?>G#zyAx!3gbHKMTgR;xbh(p zbUijnLgCa5ijr@CN8sa)9;pCRIL7uk^K@gSIGDKVK^yVOAEHI4V9NR=%tn;GvyP}& z&SGB=v?8@fOaZF7-70FTx8(Og*6S2H`aJ1#eV9>gzh-WP<-{40^3m8h;!lY?ZaAmz zKf)MGWsrPIuk(8tggSUU?kFLGL_HA8`HrwfB%y;8+()X(?(75oY8^fe-8N;}O zFu+{7d@sLp&604;>jQ9k6a`8>47eNk2peAkX$h?|K`$yhQ}E3G8-2F7T6xU2CG`6W zeINSJPmxmZ%tKA{R<1N*NmJXl=Fy{**es52v-Gu;Ka>@sPBXw4X~a!OIv#0Zc$;=I z#U=osV~@aG1>}kc9xU8?g6>*Y^j8kmKde9YZhK?x+M(|!NIQ_@tU$DvYgU#&)-LGl z(RbcSJEmb?s)d1@YsriGWS+nE4#yhSCoa#^vs03@v5&JA=@rPh&hJm^cmsd__yo+G zCcod`Q+jM;m1HED?0@=U^~sz1I;~>oS#x|vjYPeI1wlhSb2bRo>UO!zt??=lxcjs87jFLUAxcoO*sr`V zcb#WW&lB~T-4E%7S2X8u69|z|-|Jv-?7m%qX_JCpafU+R#&G4{*97#xy5#zx!a5&O zwOM+)ysRii=U9K|1one3v(Es)+${9O;z1t4}m$8oV!@} zgJFQ(%zW4j7CW_l8W5}`1Wx4kH>5p{e;SjkITL4P)h)e2vvc@qP7Sam6=%MkHw03% z+MOTrO5?BS{$1pVbcy{#1`bQtI}Gu%tiHZfTi{FZ!&{+t42r6Qv8Ft0{Yk7c4ir7~cx# zX`rl47q?Z22}29~fNOs`=)Uy93nImpVJPjClZ6gTzWy!1roLg_7$0K|uI%e!i#BGS zu9{#c8DaV9gQfy*2nlxo>}P`{8uasLJ>lSaO)!mf7czv2-%Bv_4#=Dep35zs0IiV*{Zp6%R_b< zucR9n_>A+4Gr^jh7ZfdP5r$HW)|W3X#B^5x0|ygSGM$Sh{%0At88>PwXL$q+F#mu6 zfjt=!o z``%YE3`ATRVSQLx){sutq|C&iI_5V~GH~ZkQFJ;ubHsvS{HkSi<3deOYD7~%MVGOX zOcf_qsv@oOU5EE?yj2=I3M11AYSmIc&c zM|_Ng!HPSOUBiZ+l-0MF_3RF9@rc;qzKh!;LKs^BM~7lZh9h=d_P{WX?Z>Sh2RB#6 z!U94{#RBs;GP3e)4c-X9pKld=qXUfQDn$AE9HwbH(0o8 zN*78N+QJmxhH#f`n-l={m+{|zhh*$J{1OPYZVLAf$uIGeh7FkzW?7Mob)g~@=JB}^ z9Y4`P9`kk6<0&MqJVf7ZWRqEA)BR*uPGXz%$>$xbR&PbEb8^~+?Bb2A@U~D`mJc%S zuC;r2sZ##{_hEB8HfNvrkS^}(rb|k6Y_?^h;O?;t{S%-jmO8+KQy@Feyg)2=}9D(L)4y#L>B@*(O%KItN7O(3+d8 z#}Dk-xHO;q&aTJ&^Gif|<>TxRN-F$mGgLU&2Yr2!2)j~CWs3VN(^7KBE9YJE z^&LGnwHmQu^`|FWVuyz16^BQUS2yA&@E!68ukB@S!nK3Yy}a}qC44 zZu?q^o1t>S%++BjWxLbOya{%=;2#tu&rBZ$731{w``~z@x9@+!+hgIPi~9d*Ys<*U z$XpEigor!q9Jsv5KHmI1$$5;m^_F6J7^KSHlbo5lH*YpA-JG@q$ml!Hw%a@0VI~e$ zwSWtJJR7!nSqZjTD|l`P%Rd1U?Dg?3^{I>V5<0|iVq)N)OS`lMBuk53haE-~I`%OmZ~s420^$J9diCMp&sJ?E%ci%>P_BvId$yvuP7} z*CNw#vr4z?I6lASP0|{kFR@RG^e2N{0(_D%c@Bdwejkx95e^{hkwQ?zO-yDGjW|Y? z6s9fTEO)G5&#u2b#?Bql@E96SyF8lfdo`SOv>Y=tw`YcTU|W){Pc)A@Z4hC}j}IlY zPzKNPqvva3a$HEH88Jesjw=56HCVy6#65FAmQ5lU<{Cn3CsH7U&)T zs>EW&+p$qvyL~3UvUTX1x=po8nTAA&mH|9Uaj3RTFT~ksUE&n8YK?xemaBZvH??gH zV@#62<9Ol4FSWWBfIHj$o2w1209DG_Im=9u-m;)0GOR-Ha&FGP7N(iz?R%hDU`Ym0KDLcQHF z-}T)&#kQnJDKBdgRoP(NJe)%#H{6HVJ@;3)aZ~~b!8g}aAPHF~a4(_tGwt4*a7BeT zVzE9|+$^Zkf_;CfOZ;f`1*Uct>{HedVFCyLfJAfg(g2!+I1*m*3Llo z`b1w->Vc1MjTUNGc0c(DV=u>!+q&#d#rSjEFBjTT`C~6uWWd0wqT=pTVX^#7w*FvD z`ma6w-Fp{IOkmnGIa&PPt!YTg`B)T06h`_2>_L>8`;-<0Nb+$O{m8pr(}5f3Z8hdV zKp6RHHbb>tapFd4l9be`ghtBRQY~LEef*Hwf;&?bXzL+3%`Sli=ANg24#$OuidC|~ zpy?LN!LBV@vcN^qN-!jgm#c)B6_H#Z?KJ}Npk6ElU@0+*-Y)i1PF7<`N)ZYsd#9?M; zTfhr21=vP-!+Hh)j&|N%K*8{<^xlrXf7b=0_ea~aB4%4V=5qPTc(SJI9h5G5J zD{U4IjQ5MQz46g1)9{Q)nIzaC5!kX$E3!q`hgtkfN;z@$`(4%{>$aj=p5^7Tc3P*W zj~m1$WBARERSeOo$YzcCeVf-pyJPM&s`}=$RTOjIsva%X_4kWFAo$G8c)=K{EB?K2 zzMV?>{^R|c1%P4N+3)@qk(ZHROFI)*6O}{a2fJO}n%1xlvX)NAGsZe(4Q^Mba~-Wu z_#50Uy62puwy28rIjfjd^g7^UGSgL6LrURAFwR0E7QwL(+a2RNJy#piXP*?@CXGUZ zt2oJA+<>JYtSBoY%R!PxDn}sn@)dSySCe5(c@`^%$If4Q(nKfM*H%Wul|%+wchU(l znt5+P)$tc`M{L#ttW1W4T$2mI?6fhhE!uc zCDDN9ser{oXyRCFn$+AzC$4|baVd7!f$nE;;V}x~y`oTTF?+OBZ#ARrb~`%{L))C? z6TeuVuruNuvb>8oI($FEK|!ZuQ+f*DstMDSEqdIlKT{-4!2YRBv&FR@dDh{r)U3Rg zpnY{Ew&Ntp_C*cKPIot*@x+)WRO4fY;p*gV9=e~J;S3KTBALdjpm(1 zBa5)c=_d9t+1Z)oty8c3ja2~rKXQXL<3~8MwYM~D7-9I{AKCZT)K|7opO)!=V#c6a z!JD%FCiFXQVyR4gJ;U3jb>ReAS+zDuunU;iLm?TWP&kJ`JR_jh8Dn9aL#sKX8of|& z;V?OcnS_p33PPa{+aBsT6wTFp*x$oV+hk{6)F53_He8I;PrQ3K|5<`N@abGIB1Pf% z$vM)#Sys!&J8-A_S(WuTEH+fIB$l0`gH(#4Wi#x|q|9yNV4npz)k@KwJ6-hB5=Er& zQ=$FMc3_T8e&ID#y-%=vz4_7ywLvh=wEcV85KljluZ;~uxq}(n_LbhG4 zv@BvNs21*QT<6p)Lr&LS+v;YrTXMWHQl=OqLf&6|jms{PG}u`y;Wfx5MHagk6M_wXX;Z#Loi?H5H-e-pE?@ zTQ)3?l?mOAb-a@6pTpLmOV(fok>&)j)6VD1VB2n;M(OuDz5OZcqW~n~qJLF?Ud#YE z2wambFFzq(_fHz>TmblDtePZS;%`F>;rYE}4X1QHG74Q|fjvo3*7_Tq&g(VMX?)yn?MEM3#@votTSe3v z^amugb(II?7v}OVuIAT2e;jXi`_+)dQfCy2?6GVo$ij2xiR>-$gQie{+|=;YIV?My z!hWbgzR{x4SY*q5PKy>|wzO6fe=_>SXl*m>aBTRokCy@$JT%`IzPl2cX<-Gryq*uH6_}08X8&`&jZ0 zhOrFKSfQLE10uPc`oqTx*j>i!w$jaWc1e8b8hM}x8&LjbS{~h+*USY+%Eo}!Kc6L7 zQzn!(t8n_M7)5}cJ&gDO_(H?-O{oTn9G8&9<_yb=uNiWU5V9FtnF2gx%Z=%e_M|KZ z4bwnuo>%!{xI*spxqdu3ocbx7J@5wn?V+1eW6jlqR#)5?mf*5iJckk)!?BA%PBk{Z zv9K1GMJtYrYZ;W0i4m%Jh6tjdR6pHh5GOwBSZ{F~d#r2p)v;_SHggC}xxCa~sPfYE z&mtm@+a22Hc%`F+Yq{fR-GJVG`SuaDivY|oZH@<}buxMo(U6fnP3yyDXLKC6S`Ki>fm=j^uj8-F^ zlR^CgY@Nv3+$l#`ozknoqh=`=VgfYyzWSUXM2w%WVDXcW~+d*EXy2AH2a7qzU zcrngk|D?FtcpFCZE zCdLg{X>sQQp;7><@<#V+C|PQGXRYejDUv>~oka1&p7Y1nC3#`uWK)e)pEF)GNxs`0 zOj{BbGkRIAQ(?)9e1yqAQNSeEP!r3q0`11GR)An+U$aSr*Z_*1cCtb)>TpZ%_8Jy8 z=6Z5O$3(eP7X23$zAc0x#C}#K-lH`@E$O##l8CrNLeqJ{bUJPR5+&*2B?IRQw=_$chqeJ#7?R#BSogA8Ru0fl#$q`R^V z$6~OgIJ^cYVuJC-nb3!uBGYFNlh9Df$dP~UqAOxP9Wt|Gk4tp43#ihYB+&~VAD)w} zU)S&6PH_o?P$pH8kE~cI+Pb0=bZ#mdkEJG4!TX_uX%ly~)4 zW>gF(uK5*$>QNVNuJVJ=n3yLDKRc~fEYjTMl} zNgB?k$#UTT%%V%W_@d<0lrNU{>XCI_=FZ<~4kBEF>?+^b_zqN@IQu~|hTT~C`LT=+ z-UTVgm>m`SPi6^KWS$%FqTOO9%1g8f<&7xsvyy*j@?VQ6m#m#*z1L%&r9{HQmJf>c zcwKBU;g^kuh20A)!Re!SZjMM|KeH7f$`)VZ5D#v}%RM$2Rj~KhEpJ)85`&V}EPZc3 z>Xw*O!5xNI>a$T;dSWIxIknU|%DqSq!D1md+hy^x8bj_~*!OQ$iB)SyRU53f_CuEM zo#pUr)Z~p$Zuk0xuTWtFNQaXWF?)NBEOxZ>kWdkkwWhe8R%K!Kc*|k>m^C`oa|#0U zNu&w(d#@RXyeJq*@Y98RD?T3#{JX~DgsHG%uw5XodVfYozten(vhjX-T>XYOgR8%- z(u{>hg|XJmHlV3GnL|!#blNr2P*Rl3%uG??aIC@{Ua`xL8!nQ&d0jxZ+wl{1YrHwk zxL_?ABuf<*rwe24Yig4fXQy5HS~X8jBg2N?e9>{e6AHRS|4NtBomOGlmvya;$JzKI zkPDf2YK<<*5k@;>JO-G_NjnV3=XH0Im(Tf#wuD-{upiq^G+ zf%A=tHA&x&7Ic~4PWeK-a<;yWB=QH^sZT4}K70S?C=W_y_U}M0F2$Fo@UV zJEGj*n_RR;D^XHbj>6Zntepcd#d1@fl32Z$sYwiES?={HlYC24RLTBsL&=WJP1T0+ z!bn3q=r-}}l7Ns%NRRpkm$~LG((oN4lxRbzrr||k;rt)7B|QWF;I3g2L)QE8uCtF) zU1)eF-t2PKI7xzc8@ZsiMR-3%7NrS;X7=Z0I)FC8tHkZg>`<@NTu>qMZQZ$!)L$$t z#zK62?{LY8ms`+((lEnlSfDo#5APQJ6WmoMTQ?Glli+@vHw02R%h}B_N4JjSeqGHs z;P_833XpLZf1h3kB}`O@FJd>P5Oh@e(#L@{5cU1hNW={v;dGeNo?Vp^Ow-{TiXZVmjXP<5Q!zQ#BX~G4IlA zn8W~9L!@nk{f4=wQeKg>y?tXz;UPuhrN?XI;nLur?U`Ru$%RX8+uMyneb>FQXgZS)QWjf<+QZw1lr?dxlJIo0zS#h)tAT$;-Jd0(u|UNwCUe$D6drX z{f+oJKz>?TyTxyTsAdrX1%oh@hh`PY(=iigOX zq@8LcXS5t7f?TJS2*}tg)GFh-QJkp&{>44B(yxTGY^ER@<%eXxY?|qNJXbT^^1vtQ zm38XXFYexIdsUu89&aofY@^MgjX_ZzB&)&E%HtkWv0B=-1XvfryDKxT7Ppp`wdS?h zcf%}>uma+;8;j)kw!(n}f@C2GfN)1foP9VN2xh^I$*HXj#=Gz<7}dLY!~-^QjU|_8 zmeQidn-^vkP-btA(mSUIl0+yr%hI4H1+c>}_R5GeaIa~Jmpkv=M)29(nsia&siK3f zM}ZK7|56#H5pn`E2iw21AZc81Z#FkV93iu()H}=60Sx~eu3_I*Hw6|cNL(nXsSDn# zsU}xKhzZX>pJ+-w4h>hMTY?LJ#qFuw1v~j${+fac7_pt zj*quMqdIJIvSTsJL@t}izQ$&UcYzNOMo0uT3UWN|vkXq*7i8g=PHJ#PUhM#j)OvrL z7kY;Sy{{aN!SZY-corWccqHhz^D!l|-`UnaEq;G7u21%F#m>cqm` zq{rRS{>yg{!?TW6f*ChJOo$+!Y1d;sW#JM!<2PONQ)-gQWKbi!WkU(z4c5&Pg@*|l zL28Ry^$}x&Njx|IfsotfFc-vK2`b=rfwpjJRi7jfBUR=FtRneJua+tY-2eW<4cpbD z-cB#AeMAx)jON#5p|TkVZe60Oz6Ijf@S7a!tyo+?+6XUWALC+8!TVjw37Ogs<@ezy zgQEJx0OW>@VDUDyQWla)PCkD^!{S7}R8c;C>{crNxH6nV6Akvdd1;w=-u;$?^-e36 zf|yI4*l?u^hWqaObzrjRf?--0vLPvH;f-QR2rUF~zWp8-o*GGovur*7Q#sBQ%bLnA z-(vvQ6N@@44RELg6MaF&EZ=THbq{V=Bcn|Av(7M3nqpFt6Y*0<2%j$i%VdK@=+BUz z;+uq@B17`??r1eY~%$SS(4VG`x%j8kc$~L7r?B zYycelupP@Dzo%&M{TdNF@SZlB6J+-1FqymS$D6BpNON)nDKsCUIxvd}7e1e*_5)-5 zWk(cE&B-O-RO6r4)~i~6%l%XN2el@#wp^=mXinRxPS5tlWevqC7SdhIaSivEC_HiR z5IlGP*div3Gi|@vqr_Gkkh7F6Hi-_(+(X&g01X+o(HrphkwxZ4Zt4!hdV-Jwb+n{} z^;>J$d}YaU=B%CB_lNXIM3s}~AoK|-CWJNtmlS8kIz*tRY;NirJ^bqri_LFfB7^>< zGbY`Y>GfB*SE~75AB$quUI=b8quu~hK3P+Hqh*)1CK`kWmaGK=fe>56fj@P-O+zNk96^-9p)UTS^Vors~$v>F=V>9r__SM1mZoT01vefnm7ID-Jml zSM9%9xs>tc$$2T9%F{3QnTzm8RhEEl*P6(<&HWh=j=nWH)%%$ybf&mlHsU>=+qZC= zpuprt_9&-pCzy`H@VK{h*QzbIE~gCS4;j9shfRj3;hnt$_Z=&iV%53$T$r_57V@3?I5ipOvEh{IXU2yinzinyidwOR*YAhc)g-X z$|ubXUaw`s$lZhjMgDZyo`b3fLsahZFHqVmb zGfsHfGhH7t$Qo~pveM%p4hjSd>jtJDX2p^Cmj;T(vNM~++KdRw@j`pPBb zN+~{R`iVrjp@s`TR`p&9J;Y3Q?&=m%B{+k~ZD|B=vq`EX2N{G$m(%)fTf%yisj92Z zebTha%I!A-&pcjp|7r-rZuYd>&yv>`=Ui(-JHRalqO4=DN}+YXaifxuZO3fBT~)7i zygBMx=pR)?<8Hj%qv6@T>~~{(2od`EiYrDn`(*C&Q;J##$z%6)X=u2q9lK<_SvGM1 zh!vPA?a`e3PE8;D4(q!LyO0RK6?w-63L~&B^_btCos)%62827QSU%7S-n%sA}2WYu`riK>h zT27P;YMb~BA04O&7pC2KPpv_;X_|!*qEYq72`1WkemD@F`}HJTpI{&c*$z^XtXKs;7=A8SMjqo#`r`xr z()A~xd0<%kF#K+ho1~GiCx^|hXXbn}md@>%`FgQMU7p+;_NI*LdeT1DeS3^|y=pRr z&<+KkLlx4{Y`Nj^sma2c&D~Y!n3eyXqaxD0<7)ddUi0G;9Wu)BGTvKH==uTd-MOjHEA3xgJiI9OVhPs4c$2uNSvURbM zDpV8p23>+p3)89o6fv&rn!#vclK3TUE<==PouK=79&1Dr8^V9K7`Z{0WCaz2tNE8O{;9%6m3TrI#hH?%3%c7P`;n` zApLdY97%oV;y zam>DeaIp1x7(g)ezB?z{hsRGO=0<{vEbyp~DP7~Y*Vu#@k!oeQyv@=RBl38P{4B{8 znH8f(kb!p5L;W?yJX&jnseb^k-oDmOz1~1p=0iNT#B6Lb10gR=(u54L#>M3$o?S%L zUjk$AACKF@!Xwk1AFjS+flLPk9C|?vRiQ%ugh^;<0fk`g+4-;72nrg7lNmPK7=GKb zOH3}@>eH~WmX7_iPWgrezIhK1)*TGg=H<#%>&*uRB;oh1nt=nt(PozBIpV<{t3;%o zP0kdOdTHnFF;z^~tA%mI(V`EgZ|o{>_V!#{HB!BP04L0ZP|O~}k8#xq_#3qiViH_4 z%_mSA`p#YJRfjkg><{yy4o3#26sT3zNII&V< z1L>ez^7f7V1~s$26M^yZi1SxBcDZ9B8){^8N3w-aaOn|q!A76Pa~t6haW5KP{4Ui5 zRCw;rWkvQa4mUH{clb)C^IRmD;QiKLFlfp%jj^~rD7ZZiPFy71goz%5gjRTWn>fbo z*>YBX6A|-23v99>+ONX%yEO1Id~7<+g8RQ!++0gR+^KG=^bZ@^!yp60hfzLc$USQn zdRzhVCWtIb0vY^)&zrB+p8vfV`Ua#_sQ!yO=@3jJXueN3XdT*nd!AB4&yHUq^7A8N zh};POqkI};Unz%wS=Zjqb-~NeZ(d8s_gB}2QTpz_(Hm-aU3RAl&O5kgqSG|7fuYY$ zi7un&NF}hckKrmq=`hWWgTDbiST8Mti(Qfi%NL+qyb-|xWUc|Vpdh_ko?24W8-N!F zK7%>$buuTWo%Kg(!eHj2b(lYG9RxppL@4O#9H>SCB=#a@HBo7A@9r#801I$lLyg6B zkd^h|_Yz(@JwUz2*so?*Dk_G)HDnt6c(r<`jsSjuxXgrLqWYH7>ebVW{n+IC!IjVm z3-q=KS4Q`7(Z(;)?raQRhu*iCJywO<_m*@TF_}SyMxDmet=GV09%kv#1D=!9+8eNk z%*%tA47nuV^99eRs>!vDVC@sNF(&6ZAEb6!&hGfTiHPl&t94K!VZaa_%^KKEWx#WD zsE7^7=#I9v#}zt3&cU?_BXWu$>=^hT?p$#F4c%XC}Lbs1gZb9k^-l}RO78q z1ZCI03Mwl`krFt82_#D{v`u##XUCg~hJZp7iYG(>t?Nmr{nDYCmm$E;i!lj6kxFvc z{p|gh(c@8@d~{uHc`VSDu(}Sch$jM#KRP$z_0nQOcVhd-kh&MDMN%+fl#J|gDk||= z6rWvmGm|JWEEkPFzuXCe3aSJ~!3Q-AYcXwD=dF78F%>1y2g|lyHF2=80vge9Ps zr{Z1kg7y#AQVwDW(64Z^>kdwQKj&62wL3tY1hDw=#vA|hzQQ)HhX#<&d;j^o+F43^ zxaE$IF6rklt~u``ZE6W0q`ZCOhX`9wsBt&3A)q~aZbwpb0VMP>j0v9$Y zBEbCxZP9{hqPC;~m~XQhaI(1~t8j!tz&uULb@B^SPf7?M%9c)9?_BUDAh7vX_Z@Zs za(B2UyAnpB%nyvhSa9|iuV17F=VO4fp$I< z*Mv{%hbpRIBYwNK<6aeAKGV!DEFBE(cT{KZIi28|z#8Nv<3Ceul!!LoKo)t7q=Q4x zB;FZ+NJvNSNva=GN2gdMRqd9Vz=qQDeB5R7Y=Wg` zv2!f*8{1rz$O|ki$hEZRVm(_)06x_e1XTGw5$(tbBDt4lQsfD3os;&$Cg^ z%_a{vv?wX~#qfoXz(hmezPf>CJa0kq*TaY#V%3laQ-KK^M`TJts6lPPOJ z=y=`i$#40D(aRb{Zosi<9uEbBpuAzsAtuJYZ}+r!?Pq8L~oG({A3hG3PzLT{Xt zh-CsisnAGFOi{u)0R*sQU%!91AOf=hrMCE8*>BqZ2@sX}9@6RMH5lfnwc}4ZjG#1y zZ-5YE_(K9&spE*Mk>Bi4mR^G_2PBHvyhEz z_jUzRq!ZCf!c>u_`FOmbfL=WaXo-c-#HpMVMOub;{(v*CML0UG?%ruDaaqR5om<@4eFW;d*9T zvX8I4dmOgZTH&l-jn|Ng)rzB$|G}M;?_((7_dv$F{h9SvErCwE)P2?1s05XRp$|jh z_Eg{-Xq006kfk<|t=6MoV%{`z2#k~SK|-_|pjb3)2Xs3s;@CI!CpUG8x(?*ZN&VnB zPDtROsQ4_^)^apxtnOhEC-*NI3rn}q(3Lm|$H-RRKNe>^ejxxi?Wiz$qkwJDSikAJ zC@5Ex1d3IST(UzSTn@?a1l~Z#qppdcz7}JehsapFK*HaC`(zq$J)bsjF`+Uo@|b)K z3YiD)(!%kYqrd)@zFk8sMMvPOdcAfP6`i(BXJXE0?~;h@zP>IvQqw1XI`aqIS~qId zyt(b$YC7EJlpcja_MQ#mQ!xxmL-{Krf(D(_gyGuMv)7z9lq~Moz-MB3Cdny z93AObR!V&4KpveiJkRI=+fwcgTcC_G44$i2E7d0{orcVJ?$?xeop|Zf0&)N#YyH}T zeZUIn`w~2EYYP3+5@5@>rbeF|@iPjeg>Ce>>hcX9D{LDID?WaCjSq3y+uFz;pKwyX zfL!M!o(nqV-mrqKwL7z~(mvqN}C*jD5erE>X)LI9axfF33pPOIg&C3S77x z_1{gCbmd@)+_^45{Jr}00kmU2x9pwFN^*NqmPqwh#9#2d2x97ood)u<*|nWa`ryXW zm?kY=+`X$isRQjq9Y{m)Ojd*}z}Q*oLtL_9iJtl9gQ)TVXjU;_5+F@wNC+e7dP@c| zR^Jjmqo|UqiJ`HP6I-r>27m-v2Rjr+>C9zUFw#a%rAXqF2~NFyO~&Vz(x82}p)sh( zgNePN^sz~eIyA?F8R;;DxM>(#_UL4zeN)E4w(XFd} z3M^MSRR-i*eZBzT61p%2GR-66FpL;DsVz{hJQr8lfH#PD%O=1_I?+SD^c#<7*7ms! z))3q^pDe}Bh-g~aYwcuSqHPG0Q;IECIBA6$mzVKFM_FW^3r)Z~tn;o-Uq6Nax0&p> zC$^@x!%4H3a&*MGVif2OQmW#fhm~2=xs_G`7$JhdLUiiir>?oB5%3>kx6SD`N|P5< zwqgIU0b6BItGjKbiU;_aq2|*XlnL&ZR#A0(x{bFZLfb=y_PeJO$Fp6Y@F7G$9}RRg zQ6=CSEjh%*kdx_eY#JPF5WB0wt)OfuoTwPM(qrUu?;vy|bkF9X)M`CF#qcsN$!#8k! z_<}2rV5henI<6;mS=e=`)Po|Cq)7OfVm2#B9XiU4tcGQ0HrUY-y^l}F3_7RqTt6)% z6IXq!A{5GE0bKn9yb{AF+MY}{v8nJG0b{p%<`cGqeD)V%_bWM^%1)vR<_(n>i=oMd zg1?s7*pId9HVkS`_@B`bhOO_ODa1qd$&pZk^V9u6Lue;rEze2ov$ z!VgphVpRQ2^vK8l@3t3cKs7v4oIU^j$Qf8Rc0uTlg}dgZ)E5Cm4^a0(gms$X`ysAy2?HHi=6R@9Xf6NATE)%cX3Sv z438bQPu6jk1A2PCJaZi0`e1qJ6BMejDwx$Oz>UtUm|kmTU0@FG`sdOX zXIqy*rZGSnF6fUkBh`n{5LvHDNmrDW-d%<)fIi+qeRGx3%c~RKMti!oj*fg%SzpUj z3iykgH9MxQ*fk_>a~6J&_cUb22pUq{XVjDfRy2i`27k^n>wJ;gHNg+O-I z;O2(sCcNMOYn4ghH<0UGAfsI4|4Cj9DG~^_HkmpUi+$7OdNawfH=FzDd9L8sLOXc1>|z{q%;! zOk?V)gK;tqRb45{!IrJ=wxl_#e)(=zkQKC-P|&tciaGt0fx=q$U2kCgnCk+ z+Mr24K^_A@!=^SiE)KF0J2z0nKD(@;BcpmGTMAZIHqUzRJ@$W~jZGc3g45cW(<^st ztR%X9P=GH+7=a03%Il53bU>yrVi(bab)luV}zwI;NsBLOT~_)l*63y%eg zDC9w9APtSu9=>sw^@4qXZ?kEm}rBnKsygRo6fa4*YPC&c?5OUkTW4@~ZnbD`O@m2@AR%v@mJuGQh zjHY&X$d-$usDlwDcXcn>wU=RkxW`ri4V?7F+Hq@EAk%KyHs6*#sHh;Pfzy;32EQ<6 z^S||FR>L+qPQO7WNpd2m_GsPeAuch?^uvX%{Lf~)%ml_!ni&Je0&xNT7Cib=i@K!c zKwzg570@Dh&KVb^M?VoERSa3AjyD~1c;q)t?s|Nm)K|-z@SA1O(z4{OTlla!jCnxR zPNX^H?P==NaL#-5tV@J|$nSXfLQ4@6V_{E$L_JI1{u1Di2=qZ_yVSdXhpW#MB7t6g z>rgYsqc|@&b*UND|X)*L}M)2AcT1gcA>XCyHL4l6;_8pFb_^h*tFP9gsodWDO?yJuk zW@877+D+SY%fkKJb*W=RR&SZW7UHB~rwS?2f-mNi*N9H_YP(+&3+y{+9N{&ISfF+` zaeU$;_(-gZzhaH^?w?#y3wDma0Om{-}tu}+1C#UEar~E z(<=J{ON`0=Lcyz9r3PA`Zm5qzbw8C-qD#ITUKdF+6as5+@iz}`X%Nzi*@RBjzF{fs z7x$*IbS4>*yK42PGsAn|BBC6FOJt}r0{t4_kqAw7*!{X4@r0ddL3rq2*USE@2Pjh1 zY57sfgmr^ACkn(!zW)5RzA2w9a8lX$GZKEiSTgiD8%)~m_nAQ^hhk=>=DUm90qPygp(&7J3a@m9u~|AAYs9IUxUZW4v3*op3JR}T;t3} zTkMw5JTub*_g%LJNsbkYnQ178)`oCq4-LnUl*&qGM2;H8LlEcoEonhMBpQ;8nJ=Se zS`{bABK^w=hfAJxpguv(QW=T&S-yhTuinE76sa-bdE?d)sOR=~LM2__3TmBagwd4W z$Z*`6p8I9k_^^c#_i%jGY^ z6QY@Gp7+v%w|%tu?+EN5%tN2pLzP1Q=emaWLF-^_%cRf z;mbloQv9-0jTKcd%8Tw_s;JJ3uW(sA)_Fd6-Uy;oR`{zW4q9UEeAci!96YA}COzA@ zdU>|jDafHu8;3^@<_oF)%B*3_BYoqM86$j^#nsN_ylnzBg$=**ZDG9(uX&R_5R;G< ze0Fx@X*`|#*!hHi#Bpm@&pXN=&rZZ5Fv@K&PA}#Sh)wfk1DK6dS=CcR7*|Obi>r>^WP<F*hJ;?+kC)gF=y#QI;$qWw z&0`n(CtX5`k5AkNckPb`3OO+d17w+|f*Xc=YkOJ2uck@)sph(A(Z>Ap&k0I^un^E= z+@rtwsOgGh6W!A@&>lokPtU?RlAY*8V*+uMad!5yg??Rma^iR>!4f3#Xl~pqbuM$Y zX!PNR^pW2~O+pZW9cq~!SVwP-sxFo0E?qfS51%q7yH>4fZCJ8Sv)ovGIsXG_U+SYc=68t=$O$Vs#`1Xi>sRqHo`M(4$0IO(af6CcN@ zrx8n5u-Q+e5FRg+XmX`*_Xv)-*nj!HVI7X)uJyeENKQe}S0vl$jI(pqJs>Ue!Td}0 zIGL8zcw;kqWMJH$8cOq$W2<$BTt*Q{MfQpdYG7>r0v$FTjcx4$hYvwggn1!C{a z;&w<>!HM@>aRr6&$u5bI9e57LEX2xKT-3hfz-jmvD~i58+p<@PV!U!jpYt$)0kWr{ zI%UL6`In2$vRevCa3H!d0P=;t55|lLPuYaYxRk|*TSSQjJX7 z;*{3I2X`2(ogpDRBl5hKD$QAMX_L0owwJMcj}E8y<@ZNWC7G+<1#9+mTIkzmhu`K> zoYXSD>y`Y_`kO^EpFUA%0YfXB&3y`PXdA_S|5I{A6VW{lYPfV&^&4(R`QObsC%P2p zEgx>wW8($dj$SmBl=Vg&pRXQ@4eV!Ayt_>mi$6&x(U$6yY^#M4T?cyF9}m=O}(;S2`~jZ)!F z97*)CgVHK0Cfni1o;w()- zI`Nefq&Nr6FdL~pyjSHZ-oqkWxTe;vgE&Mj;{=O$QsA2_;c16}zU!)-S!oOn8sYQq z6&w-Iz0vpkYhix{fUsJ3DBgdl-eV0^8vh9(dmI1_dmVc9{n}(_paiKlGzM&I`8J|2 zUd)Z!)@Dtw0`MO=-oMPPcL)FGZ%)o(IdKR_WZyX>4NiVt4;EsyTDA zK+Qr=Gv)Jo`L_?QHfclz?|g|1(;T(?H&FNA?pn}aw#>KTP5awe%_wcKWy)x!u`_{h z^(79}_lqBf{(+y&%z145{mfogJgqU;wynu$F?F)2?c9I4yz^k9V7?p{5|*GpWEbW9 zRM`nOg4bN2W(JyK0Hbz*MjN3xp3PNGG&`O_F}fKwH8?iYjFIF~q&0Zxh;Kc$8pf-h z!|FeV{rvcAfw(5E9$ooBO9{+chG?nvPRlF(0trab2P7wl>ewU(BX;_wtMT=iH;pfk z01OLvcpu3`z!9o!mE9G>z~L^gtpitA$U;M8qijc|-82E2Sr zI1vqs`_>E&ROHi*-1Zhaep%O?IIw>xTiZpu!{=QlH4o-x)SupWO)d zxcyR2z;9i#HX1d!qFiKLnPbaBvLdy6<49?1d>LtsBGE0ucTjw7R%T!ei$@5z)g#-p z?mhIRwt9W<*>IB80&@v8^VF$gX&{-ph=a>QncVypOyp~{&j+e6u00nm-Fgd5-O;oI!b?xH70m7~;Sq#$$GyM+$sO_JMiDaWI z7N6(7L*tpg-uiX(O_=8I_xM*76F2IkrLTzaR2iz0e|;+9o(T`)xy_b#=aS{{q1AeHxQ!?9lVv= z@xds!;K}OdTxfa40ikzYUOl~ePC;pppX|z)H32|{mqOB}znv(a+j(29lcnt4*%Sa< zxuW^zSxj_WR$Xov7XTv=I8;e)Rj?BqECLqRSc3eQq8y1qg z>#pQa8Sn*NM+PTzOdO_{Q@R+{El-8GBI&G}iwQg7)+Tn*g+K6R574ssft5l{%19KK z>6-0(#94)FS?n0iaet(1hb!K^hB7EY?`WYjYfwZvtSPVZW6a9Hko$`yx|@3$Oh*@o zrk1~-D>op09v5ix@`QGa{be`&eSjDwYV4hI zhX4k6UY|Zu%57CT?MBt_iL*wB!6o{wH|fy}2`{MX67+y(O6tt$d1-~oisBi7oRpz| z#>#3JcmqhNWOH8y1tw?S7jbAO1za=G$7~qZqvsVTRqmhkiq&bmS{aiyD9pcGnE>MZ zlf2J}($uaMNl^T)wsBoaxIJ;e?A^hi2RC`2yxV(e=6)bOF|T7@O?jF4G|ojTE91%i zK2336bF-6c37!sF&d&N<2~HuB)25h8?(Uf-BTm&zAx(VLHRdADvh}X&6u2-}q${pt z_^B7GUzL;3{ZjCp?}Lu7JEueFuWMuY=_mL>S<3m_D+)P&QUug-1Ry6pg;h8R zW$YBp<5CGqEPgX@_QMcVOz+aQ^`(=1KjvXg1Y}VQI(1La0LaENJ4PmZM}j!2ZQQoL zWNU>b9;-9#NewvQLYf4fa~bdSnRo@U!2_8DcX)`XEsxRSCn5BN+Kks~7${K11>8tO z#8B%l)>{mOdSUz}LsU60^MF5sybkOro0is|WiV6ew+}XUL5w^8x(f@Wg1m`>>byO$ zeCP`9N&QOR_$>Zya&n*WeWHZ)NWq)aQhyai*l0^d!S8a*CFj8UKZ=;n9O7-biA0By z7HKBguWW+5r_u`!V#?T#KP7h!{QI63LtTq>E#QN6wj20j*;tv_L91y8@&_X)nxF23 zF{O4WUGCpTsFuc2Fed@@PYRH@Uj2H*j;0~fj2KecVMr(W7^gyzhgbOY)+{hV{maP% zReRkFr=~=EKR0*k&?pV0n7CzWrtH=UOsPL_a&{0bj5wPrjb&vzoi$4HxWg8p)gL;npfEGpdvhEZ zUD8eXuEX&34Cofnd0ChquqB%V>h3Npwisw34kl8uafsURlms9Y6biM8rhu`jV&Kxz z4)d*UrQSf1a+z0&S(atPo>%X^VwsY617X168GtBKtoU_MB`PJAO%X#@8$`cVmr7MP znS&o(KK?AQIP$dGrAB!Igr5xh@w+sQoLUV*p(dxwzErFZ2CE^q@v31W!a^iT2?+^( zb=viEdb+y0EF}Ltb+1_b^ZYmc_&06yKOO4ddd~m)!2gdu|MLp|Ul(yl^<2|xyrj=6 z{F4?52?+;G&mN`&@$>Wahmq(6dAh+oJ^0{0u*ZA`My3{a(e!M9(cOQHJb+g!pnqAA z$Vkb^ic3k0%P84MORLB$tH?`>N=d0mNqKo*VE?ZLeh?2Iuh9Q}0h^M3FtC90pD*}; zKq??eKxmMU_hT4|MX)>A2gIif@pJR>Z+a!|2$60@C1|mv()jC Skq+>N#7NIfw_5vA%zpq}VE;Y< literal 0 HcmV?d00001 diff --git a/Tests/MRZScannerTests/Private/TextRecognizerTests/TextRecognizerTests.swift b/Tests/MRZScannerTests/Private/TextRecognizerTests/TextRecognizerTests.swift new file mode 100644 index 0000000..569ef59 --- /dev/null +++ b/Tests/MRZScannerTests/Private/TextRecognizerTests/TextRecognizerTests.swift @@ -0,0 +1,63 @@ +// +// TextRecognizerTests.swift +// +// +// Created by Roman Mazeev on 02/12/2023. +// + +@testable import MRZScanner +import CoreImage +import XCTest + +final class TextRecognizerTests: XCTestCase { + func testScanImageSuccess() throws { + let expectation = expectation(description: "testScanImageSuccess") + Task { + let fileURL = try XCTUnwrap(Bundle.module.url(forResource: "ImageTest", withExtension: "png")) + let imageData = try Data(contentsOf: fileURL) + let image = try XCTUnwrap(CIImage(data: imageData)) + do { + let result = try await TextRecognizer.liveValue.recognize(.mock(), image) + XCTAssertEqual(result, [ + .init(results: ["Red Green Purple"], boundingRect: .init(x: 0.1296875, y: 0.7222222222222222, width: 0.75, height: 0.13636363636363635)), + .init(results: ["Brown Blue Red"], boundingRect: .init(x: 0.1625, y: 0.6035353535353536, width: 0.6843750000000001, height: 0.10353535353535348)), + .init(results: ["Purple Red Brown"], boundingRect: .init(x: 0.1203125, y: 0.2752525252525253, width: 0.7687499999999999, height: 0.13636363636363635)), + .init(results: ["Red Green Blue"], boundingRect: .init(x: 0.171875, y: 0.15656565656565657, width: 0.665625, height: 0.10606060606060608)) + ]) + expectation.fulfill() + } catch { + XCTFail("Should not fail here. Error: \(error.localizedDescription)") + } + + } + wait(for: [expectation], timeout: 10) + } + + func testScanImageFailedZeroDimensionedImage() throws { + let expectation = expectation(description: "testScanImageSuccess") + Task { + do { + _ = try await TextRecognizer.liveValue.recognize(.mock(), CIImage()) + XCTFail("Should fail here") + } catch { + XCTAssertEqual(error.localizedDescription, "CRImage Reader Detector was given zero-dimensioned image (0 x 0)") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 10) + } + + func testScanImageFailedWrongROI() throws { + let expectation = expectation(description: "testScanImageSuccess") + Task { + do { + _ = try await TextRecognizer.liveValue.recognize(.mock(roi: .init(x: 0, y: 0, width: 200, height: 200)), CIImage()) + XCTFail("Should fail here") + } catch { + XCTAssertEqual(error.localizedDescription, "The region of interest [0, 0, 200, 200] is not within the normalized bounds of [0 0 1 1]") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 10) + } +} diff --git a/Tests/MRZScannerTests/Private/TrackerTests.swift b/Tests/MRZScannerTests/Private/TrackerTests.swift new file mode 100644 index 0000000..0bcadcb --- /dev/null +++ b/Tests/MRZScannerTests/Private/TrackerTests.swift @@ -0,0 +1,25 @@ +// +// TrackerTests.swift +// +// +// Created by Roman Mazeev on 01/12/2023. +// + +@testable import MRZScanner +import XCTest + +final class TrackerTests: XCTestCase { + func testExisting() { + let result = Tracker.liveValue.updateResults([.mock: 1], .mock) + + XCTAssertEqual(try XCTUnwrap(result.first?.key), .mock) + XCTAssertEqual(try XCTUnwrap(result.first?.value), 2) + } + + func testNew() { + let result = Tracker.liveValue.updateResults([:], .mock) + + XCTAssertEqual(try XCTUnwrap(result.first?.key), .mock) + XCTAssertEqual(try XCTUnwrap(result.first?.value), 1) + } +} diff --git a/Tests/MRZScannerTests/Private/ValidatorTests.swift b/Tests/MRZScannerTests/Private/ValidatorTests.swift new file mode 100644 index 0000000..b91ad99 --- /dev/null +++ b/Tests/MRZScannerTests/Private/ValidatorTests.swift @@ -0,0 +1,83 @@ +// +// ValidatorTests.swift +// +// +// Created by Roman Mazeev on 01/12/2023. +// + +@testable import MRZScanner +import XCTest + +final class ValidatorTests: XCTestCase { + func testEmpty() { + XCTAssertTrue(Validator.liveValue.getValidatedResults([]).isEmpty) + } + + func testWhitespaces() { + let results = [ + [ + "", + "P Bool { + lhs.result == rhs.result && lhs.index == rhs.index + } +} diff --git a/Tests/MRZScannerTests/Public/ScannerTests.swift b/Tests/MRZScannerTests/Public/ScannerTests.swift new file mode 100644 index 0000000..359bd02 --- /dev/null +++ b/Tests/MRZScannerTests/Public/ScannerTests.swift @@ -0,0 +1,254 @@ +// +// ScannerTests.swift +// +// +// Created by Roman Mazeev on 02/12/2023. +// + +import Dependencies +@testable import MRZScanner +import XCTest + +final class MRZScannerTests: XCTestCase { + private let image = CIImage(color: .blue) + private let scanningConfiguration: ScanningConfiguration = .mock() + private let textRecognizerResults: [TextRecognizer.Result] = [.init(results: ["test"], boundingRect: .zero)] + private let validatorResults: [Validator.Result] = [.init(result: "test", index: 0)] + private let boundingRectConverterResults: ScannedBoundingRects = .init(valid: [.init(), .init()], invalid: [.init()]) + private let parserResult: ParserResult = .mock + private let trackerResult: TrackerResult = [.mock: 1] + + var validatorMock: Validator { + Validator { possibleLines in + XCTAssertEqual(self.textRecognizerResults.map(\.results), possibleLines) + return self.validatorResults + } + } + + var boundingRectConverterMock: BoundingRectConverter { + BoundingRectConverter { results, validLines in + XCTAssertEqual(results, self.textRecognizerResults) + XCTAssertEqual(validLines, self.validatorResults) + return self.boundingRectConverterResults + } + } + + func testSingleImageSuccess() throws { + let textRecognizerMock = TextRecognizer { configuration, scanningImage in + XCTAssertEqual(self.image, scanningImage) + XCTAssertEqual(self.scanningConfiguration, configuration) + + return self.textRecognizerResults + } + + let parser = Parser { mrzLines in + XCTAssertEqual(mrzLines, self.validatorResults.map(\.result)) + return self.parserResult + } + + let scanningExpectation = expectation(description: "scanning") + Task { + await withDependencies { + $0.textRecognizer = textRecognizerMock + $0.validator = validatorMock + $0.boundingRectConverter = boundingRectConverterMock + $0.parser = parser + } operation: { + do { + let currentResult = try await image.scanForMRZCode(configuration: scanningConfiguration) + XCTAssertEqual(currentResult.results, parserResult) + XCTAssertEqual(currentResult.boundingRects, boundingRectConverterResults) + } catch { + XCTFail("Should not fail here. Error: \(error.localizedDescription)") + } + + scanningExpectation.fulfill() + } + } + + wait(for: [scanningExpectation], timeout: 10) + } + + func testSingleImageParserFailure() throws { + let textRecognizerMock = TextRecognizer { configuration, scanningImage in + XCTAssertEqual(self.image, scanningImage) + XCTAssertEqual(self.scanningConfiguration, configuration) + + return self.textRecognizerResults + } + + let parser = Parser { mrzLines in + XCTAssertEqual(mrzLines, self.validatorResults.map(\.result)) + return nil + } + + let scanningExpectation = expectation(description: "scanning") + Task { + try await withDependencies { + $0.textRecognizer = textRecognizerMock + $0.validator = validatorMock + $0.boundingRectConverter = boundingRectConverterMock + $0.parser = parser + } operation: { + do { + _ = try await image.scanForMRZCode(configuration: scanningConfiguration) + XCTFail("Should fail here") + } catch { + XCTAssert(try XCTUnwrap(error as? CIImage.ScanningError) == .codeNotFound) + } + scanningExpectation.fulfill() + } + } + + wait(for: [scanningExpectation], timeout: 10) + } + + func testSingleImageTextRecognizerFailure() throws { + let textRecognizerMock = TextRecognizer { configuration, scanningImage in + XCTAssertEqual(self.image, scanningImage) + XCTAssertEqual(self.scanningConfiguration, configuration) + + throw CIImage.ScanningError.codeNotFound + } + + let scanningExpectation = expectation(description: "scanning") + Task { + try await withDependencies { + $0.textRecognizer = textRecognizerMock + } operation: { + do { + _ = try await image.scanForMRZCode(configuration: scanningConfiguration) + XCTFail("Should fail here") + } catch { + XCTAssert(try XCTUnwrap(error as? CIImage.ScanningError) == .codeNotFound) + } + scanningExpectation.fulfill() + } + } + + wait(for: [scanningExpectation], timeout: 10) + } + + func testImageStreamSuccess() { + let textRecognizerMock = TextRecognizer { configuration, scanningImage in + XCTAssertEqual(self.image, scanningImage) + XCTAssertEqual(self.scanningConfiguration, configuration) + + return self.textRecognizerResults + } + + let parser = Parser { mrzLines in + XCTAssertEqual(mrzLines, self.validatorResults.map(\.result)) + return self.parserResult + } + + let tracker = Tracker { _, result in + XCTAssertEqual(result, self.parserResult) + return self.trackerResult + } + + let scanningExpectation = expectation(description: "scanning") + Task { + await withDependencies { + $0.textRecognizer = textRecognizerMock + $0.validator = validatorMock + $0.boundingRectConverter = boundingRectConverterMock + $0.parser = parser + $0.tracker = tracker + } operation: { + let resultsStream = AsyncStream { continuation in + continuation.yield(image) + continuation.finish() + } + .scanForMRZCode(configuration: scanningConfiguration) + + do { + for try await liveScanningResult in resultsStream { + XCTAssertEqual(liveScanningResult.results, trackerResult) + XCTAssertEqual(liveScanningResult.boundingRects, boundingRectConverterResults) + scanningExpectation.fulfill() + } + } catch { + XCTFail("Should not fail here. Error: \(error)") + } + } + } + + wait(for: [scanningExpectation], timeout: 10) + } + + + func testImageStreamParsingFailure() throws { + let textRecognizerMock = TextRecognizer { configuration, scanningImage in + XCTAssertEqual(self.image, scanningImage) + XCTAssertEqual(self.scanningConfiguration, configuration) + + return self.textRecognizerResults + } + + let parser = Parser { mrzLines in + XCTAssertEqual(mrzLines, self.validatorResults.map(\.result)) + return nil + } + + let scanningExpectation = expectation(description: "scanning") + Task { + await withDependencies { + $0.textRecognizer = textRecognizerMock + $0.validator = validatorMock + $0.boundingRectConverter = boundingRectConverterMock + $0.parser = parser + } operation: { + let resultsStream = AsyncStream { continuation in + continuation.yield(image) + continuation.finish() + } + .scanForMRZCode(configuration: scanningConfiguration) + + do { + for try await liveScanningResult in resultsStream { + XCTAssertEqual(liveScanningResult.results, [:]) + XCTAssertEqual(liveScanningResult.boundingRects, boundingRectConverterResults) + scanningExpectation.fulfill() + } + } catch { + XCTFail("Should not fail here. Error: \(error)") + } + } + } + + wait(for: [scanningExpectation], timeout: 10) + } + + func testImageStreamTextRecognizerFailure() throws { + let textRecognizerMock = TextRecognizer { configuration, scanningImage in + XCTAssertEqual(self.image, scanningImage) + XCTAssertEqual(self.scanningConfiguration, configuration) + + throw CIImage.ScanningError.codeNotFound + } + + let scanningExpectation = expectation(description: "scanning") + Task { + try await withDependencies { + $0.textRecognizer = textRecognizerMock + } operation: { + let resultsStream = AsyncStream { continuation in + continuation.yield(image) + continuation.finish() + } + .scanForMRZCode(configuration: scanningConfiguration) + + do { + for try await _ in resultsStream {} + } catch { + let error = try XCTUnwrap(error as? CIImage.ScanningError) + XCTAssertEqual(error, .codeNotFound) + scanningExpectation.fulfill() + } + } + } + + wait(for: [scanningExpectation], timeout: 10) + } +} diff --git a/Tests/MRZScannerTests/StubModels.swift b/Tests/MRZScannerTests/StubModels.swift deleted file mode 100644 index 25abdd6..0000000 --- a/Tests/MRZScannerTests/StubModels.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// StubModels.swift -// -// -// Created by Roman Mazeev on 14.07.2021. -// - -@testable import MRZScanner -import MRZParser -import CoreImage - -struct StubModels { - private static let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyyMMdd" - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(abbreviation: "GMT+0:00") - return formatter - }() - - static let firstParsedResult = MRZResult( - format: .td3, - documentType: .passport, - documentTypeAdditional: nil, - countryCode: "UTO", - surnames: "ERIKSSON", - givenNames: "ANNA MARIA", - documentNumber: "L898902C3", - nationalityCountryCode: "UTO", - birthdate: dateFormatter.date(from: "740812")!, - sex: .female, - expiryDate: dateFormatter.date(from: "120415")!, - optionalData: "ZE184226B", - optionalData2: nil - ) - - static let secondParsedResult = MRZResult( - format: .td2, - documentType: .id, - documentTypeAdditional: "A", - countryCode: "", - surnames: "", - givenNames: "", - documentNumber: nil, - nationalityCountryCode: "", - birthdate: nil, - sex: .male, - expiryDate: nil, - optionalData: nil, - optionalData2: nil - ) -}