diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8b50077 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# +# This source file is part of the Stanford Spezi open-source project +# +# SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +# +# SPDX-License-Identifier: MIT +# + +Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_mac_os.pdf filter=lfs diff=lfs merge=lfs -text +Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_vision_os.pdf filter=lfs diff=lfs merge=lfs -text +Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_ios.pdf filter=lfs diff=lfs merge=lfs -text +Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_mac_os.pdf filter=lfs diff=lfs merge=lfs -text +Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_vision_os.pdf filter=lfs diff=lfs merge=lfs -text +Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_ios.pdf filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index fac4990..56a12d1 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -30,6 +30,7 @@ jobs: resultBundle: SpeziOnboarding-iOS-Release.xcresult with: runsonlabels: '["macOS", "self-hosted"]' + checkout_lfs: true scheme: SpeziOnboarding buildConfig: ${{ matrix.buildConfig }} resultBundle: ${{ matrix.resultBundle }} @@ -39,6 +40,7 @@ jobs: uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: runsonlabels: '["macOS", "self-hosted"]' + checkout_lfs: true scheme: SpeziOnboarding xcodeversion: latest swiftVersion: 6 @@ -58,6 +60,7 @@ jobs: resultBundle: SpeziOnboarding-visionOS-Release.xcresult with: runsonlabels: '["macOS", "self-hosted"]' + checkout_lfs: true scheme: SpeziOnboarding destination: 'platform=visionOS Simulator,name=Apple Vision Pro' buildConfig: ${{ matrix.buildConfig }} @@ -77,6 +80,7 @@ jobs: resultBundle: SpeziOnboarding-macOS-Release.xcresult with: runsonlabels: '["macOS", "self-hosted"]' + checkout_lfs: true scheme: SpeziOnboarding destination: 'platform=macOS,arch=arm64' buildConfig: ${{ matrix.buildConfig }} @@ -85,17 +89,9 @@ jobs: buildandtestuitests_ios: name: Build and Test UI Tests iOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - strategy: - matrix: - include: - - buildConfig: Debug - resultBundle: TestApp-iOS.xcresult - artifactname: TestApp-iOS.xcresult - - buildConfig: Release - resultBundle: TestApp-iOS-Release.xcresult - artifactname: TestApp-iOS-Release.xcresult with: runsonlabels: '["macOS", "self-hosted"]' + checkout_lfs: true path: 'Tests/UITests' scheme: TestApp buildConfig: ${{ matrix.buildConfig }} @@ -106,6 +102,7 @@ jobs: uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: runsonlabels: '["macOS", "self-hosted"]' + checkout_lfs: true path: Tests/UITests scheme: TestApp xcodeversion: latest @@ -117,6 +114,7 @@ jobs: uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: runsonlabels: '["macOS", "self-hosted"]' + checkout_lfs: true path: 'Tests/UITests' scheme: TestApp destination: 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' @@ -125,23 +123,14 @@ jobs: buildandtestuitests_visionos: name: Build and Test UI Tests visionOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - strategy: - matrix: - include: - - buildConfig: Debug - resultBundle: TestApp-visionOS.xcresult - artifactname: TestApp-visionOS.xcresult - - buildConfig: Release - resultBundle: TestApp-visionOS-Release.xcresult - artifactname: TestApp-visionOS-Release.xcresult with: runsonlabels: '["macOS", "self-hosted"]' + checkout_lfs: true path: 'Tests/UITests' scheme: TestApp destination: 'platform=visionOS Simulator,name=Apple Vision Pro' - buildConfig: ${{ matrix.buildConfig }} - resultBundle: ${{ matrix.resultBundle }} - artifactname: ${{ matrix.artifactname }} + resultBundle: TestApp-visionOS.xcresult + artifactname: TestApp-visionOS.xcresult uploadcoveragereport: name: Upload Coverage Report needs: [buildandtest_ios, buildandtest_visionos, buildandtest_macos, buildandtestuitests_ios, buildandtestuitests_ipad, buildandtestuitests_visionos] diff --git a/.reuse/dep5 b/.reuse/dep5 new file mode 100644 index 0000000..8702f3a --- /dev/null +++ b/.reuse/dep5 @@ -0,0 +1,9 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + +Files: Tests/SpeziOnboardingTests/Resources/*.pdf +Copyright: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +License: MIT + +Files: Tests/SpeziOnboardingTests/Resources/*.md +Copyright: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +License: MIT \ No newline at end of file diff --git a/Package.swift b/Package.swift index 1887d04..6209712 100644 --- a/Package.swift +++ b/Package.swift @@ -2,9 +2,9 @@ // // This source file is part of the Stanford Spezi open-source project -// +// // SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// +// // SPDX-License-Identifier: MIT // @@ -33,7 +33,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.2.1"), .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.3.1"), - .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0") + .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0"), + .package(url: "https://github.com/techprimate/TPPDF", from: "2.6.0") ] + swiftLintPackage(), targets: [ .target( @@ -42,7 +43,8 @@ let package = Package( .product(name: "Spezi", package: "Spezi"), .product(name: "SpeziViews", package: "SpeziViews"), .product(name: "SpeziPersonalInfo", package: "SpeziViews"), - .product(name: "OrderedCollections", package: "swift-collections") + .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "TPPDF", package: "TPPDF") ], swiftSettings: [ swiftConcurrency @@ -54,6 +56,9 @@ let package = Package( dependencies: [ .target(name: "SpeziOnboarding") ], + resources: [ + .process("Resources/") + ], swiftSettings: [ swiftConcurrency ], diff --git a/README.md b/README.md index 5c68b8f..73742da 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,14 @@ For more information, please refer to the [API documentation](https://swiftpacka The [Spezi Template Application](https://github.com/StanfordSpezi/SpeziTemplateApplication) provides a great starting point and example using the `SpeziOnboarding` module. +## Running Tests Locally +If you would like to clone this repo and run the unit and UI tests locally, please be aware that you need to have git lfs (large file storage) configured. The unit tests load some binary data (e.g., PDF files) at runtime, which are stored in the [test resources](Tests/SpeziOnboardingTests/Resources/) as git lfs tags. To install git lfs, please refer to the [official documentation](https://git-lfs.com/). Afterward set up git lfs for your user by executing the following command: +```sh +git lfs install +``` +Now, you can clone the repo and the binary files should be checked out correctly. + + ## Contributing Contributions to this project are welcome. Please make sure to read the [contribution guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md) and the [contributor covenant code of conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) first. diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index f3c7546..9408dbe 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -9,7 +9,7 @@ import PDFKit import PencilKit import SwiftUI - +import TPPDF /// Extension of `ConsentDocument` enabling the export of the signed consent page. extension ConsentDocument { @@ -18,7 +18,7 @@ extension ConsentDocument { /// force the ink used in the `UIImage` of the `PKDrawing` to always be black by adjusting the signature ink according to the color scheme. @MainActor private var blackInkSignatureImage: UIImage { var updatedDrawing = PKDrawing() - + for stroke in signature.strokes { let blackStroke = PKStroke( ink: PKInk(stroke.ink.inkType, color: colorScheme == .light ? .black : .white), @@ -43,101 +43,20 @@ extension ConsentDocument { } #endif - - /// Creates a representation of the consent form that is ready to be exported via the SwiftUI `ImageRenderer`. - /// - /// - Parameters: - /// - markdown: The markdown consent content as an `AttributedString`. - /// - /// - Returns: A SwiftUI `View` representation of the consent content and signature. - /// - /// - Note: This function avoids the use of asynchronous operations. - /// Asynchronous tasks are incompatible with SwiftUI's `ImageRenderer`, - /// which expects all rendering processes to be synchronous. - @MainActor - private func exportBody(markdown: AttributedString) -> some View { - VStack { - if exportConfiguration.includingTimestamp { - HStack { - Spacer() - - Text("EXPORTED_TAG", bundle: .module) - + Text(verbatim: ": \(DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short))") - } - .font(.caption) - .padding() - } - - OnboardingTitleView(title: exportConfiguration.consentTitle) - - Text(markdown) - .padding() - - Spacer() - - ZStack(alignment: .bottomLeading) { - SignatureViewBackground(name: name, backgroundColor: .clear) - - #if !os(macOS) - Image(uiImage: blackInkSignatureImage) - #else - Text(signature) - .padding(.bottom, 32) - .padding(.leading, 46) - .font(.custom("Snell Roundhand", size: 24)) - #endif - } - #if !os(macOS) - .frame(width: signatureSize.width, height: signatureSize.height) - #else - .padding(.horizontal, 100) - #endif - } - } - - /// Exports the signed consent form as a `PDFDocument` via the SwiftUI `ImageRenderer`. - /// + /// Exports the signed consent form as a `PDFKit.PDFDocument`. + /// The PDF generated by TPPDF and then converted to a TPDFKit.PDFDocument. /// Renders the `PDFDocument` according to the specified ``ConsentDocument/ExportConfiguration``. /// /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` @MainActor - func export() async -> PDFDocument? { - let markdown = await asyncMarkdown() - - let markdownString = (try? AttributedString( - markdown: markdown, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - )) ?? AttributedString(String(localized: "MARKDOWN_LOADING_ERROR", bundle: .module)) - - let renderer = ImageRenderer(content: exportBody(markdown: markdownString)) - let paperSize = CGSize( - width: exportConfiguration.paperSize.dimensions.width, - height: exportConfiguration.paperSize.dimensions.height - ) - renderer.proposedSize = .init(paperSize) - - return await withCheckedContinuation { continuation in - renderer.render { _, context in - var box = CGRect(origin: .zero, size: paperSize) - - /// Create in-memory `CGContext` that stores the PDF - guard let mutableData = CFDataCreateMutable(kCFAllocatorDefault, 0), - let consumer = CGDataConsumer(data: mutableData), - let pdf = CGContext(consumer: consumer, mediaBox: &box, nil) else { - continuation.resume(returning: nil) - return - } - - pdf.beginPDFPage(nil) - pdf.translateBy(x: 0, y: 0) - - context(pdf) - - pdf.endPDFPage() - pdf.closePDF() - - continuation.resume(returning: PDFDocument(data: mutableData as Data)) - } - } + func export() async throws -> PDFKit.PDFDocument { + documentExport.signature = signature + documentExport.name = name + #if !os(macOS) + documentExport.signatureImage = blackInkSignatureImage + return try await documentExport.export() + #else + return try await documentExport.export() + #endif } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift index 02ff8cf..f573393 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift @@ -7,6 +7,7 @@ // import Foundation +import SwiftUI extension ConsentDocument { @@ -43,10 +44,89 @@ extension ConsentDocument { } } - + #if !os(macOS) + /// The ``FontSettings`` store configuration of the fonts used to render the exported + /// consent document, i.e., fonts for the content, title and signature. + public struct FontSettings { + /// The font of the name rendered below the signature line. + public let signatureNameFont: UIFont + /// The font of the prefix of the signature ("X" in most cases). + public let signaturePrefixFont: UIFont + /// The font of the content of the document (i.e., the rendered markdown text) + public let documentContentFont: UIFont + /// The font of the header (i.e., title of the document). + public let headerTitleFont: UIFont + /// The font of the export timestamp (optionally rendered in the top right document corner, + /// if exportConfiguration.includingTimestamp is true). + public let headerExportTimeStampFont: UIFont + + /// Creates an instance`FontSettings` specifying the fonts of various components of the exported document + /// + /// - Parameters: + /// - signatureNameFont: The font used for the signature name. + /// - signaturePrefixFont: The font used for the signature prefix text. + /// - documentContentFont: The font used for the main content of the document. + /// - headerTitleFont: The font used for the header title. + /// - headerExportTimeStampFont: The font used for the header timestamp. + public init( + signatureNameFont: UIFont, + signaturePrefixFont: UIFont, + documentContentFont: UIFont, + headerTitleFont: UIFont, + headerExportTimeStampFont: UIFont + ) { + self.signatureNameFont = signatureNameFont + self.signaturePrefixFont = signaturePrefixFont + self.documentContentFont = documentContentFont + self.headerTitleFont = headerTitleFont + self.headerExportTimeStampFont = headerExportTimeStampFont + } + } + #else + /// The ``FontSettings`` store configuration of the fonts used to render the exported + /// consent document, i.e., fonts for the content, title and signature. + public struct FontSettings { + /// The font of the name rendered below the signature line. + public let signatureNameFont: NSFont + /// The font of the prefix of the signature ("X" in most cases). + public let signaturePrefixFont: NSFont + /// The font of the content of the document (i.e., the rendered markdown text) + public let documentContentFont: NSFont + /// The font of the header (i.e., title of the document). + public let headerTitleFont: NSFont + /// The font of the export timestamp (optionally rendered in the top right document corner, + /// if exportConfiguration.includingTimestamp is true). + public let headerExportTimeStampFont: NSFont + + /// Creates an instance`FontSettings` specifying the fonts of various components of the exported document + /// + /// - Parameters: + /// - signatureNameFont: The font used for the signature name. + /// - signaturePrefixFont: The font used for the signature prefix text. + /// - documentContentFont: The font used for the main content of the document. + /// - headerTitleFont: The font used for the header title. + /// - headerExportTimeStampFont: The font used for the header timestamp. + public init( + signatureNameFont: NSFont, + signaturePrefixFont: NSFont, + documentContentFont: NSFont, + headerTitleFont: NSFont, + headerExportTimeStampFont: NSFont + ) { + self.signatureNameFont = signatureNameFont + self.signaturePrefixFont = signaturePrefixFont + self.documentContentFont = documentContentFont + self.headerTitleFont = headerTitleFont + self.headerExportTimeStampFont = headerExportTimeStampFont + } + } + #endif + + let consentTitle: LocalizedStringResource let paperSize: PaperSize let includingTimestamp: Bool + let fontSettings: FontSettings /// Creates an `ExportConfiguration` specifying the properties of the exported consent form. @@ -57,11 +137,13 @@ extension ConsentDocument { public init( paperSize: PaperSize = .usLetter, consentTitle: LocalizedStringResource = LocalizationDefaults.exportedConsentFormTitle, - includingTimestamp: Bool = true + includingTimestamp: Bool = true, + fontSettings: FontSettings = ExportConfiguration.Defaults.defaultExportFontSettings ) { self.paperSize = paperSize self.consentTitle = consentTitle self.includingTimestamp = includingTimestamp + self.fontSettings = fontSettings } } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift index 68205c4..0e8fc66 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift @@ -41,12 +41,12 @@ public struct ConsentDocument: View { /// The maximum width such that the drawing canvas fits onto the PDF. static let maxWidthDrawing: CGFloat = 550 - let asyncMarkdown: () async -> Data private let givenNameTitle: LocalizedStringResource private let givenNamePlaceholder: LocalizedStringResource private let familyNameTitle: LocalizedStringResource private let familyNamePlaceholder: LocalizedStringResource - let exportConfiguration: ExportConfiguration + + let documentExport: ConsentDocumentExport @Environment(\.colorScheme) var colorScheme @State var name = PersonNameComponents() @@ -85,6 +85,7 @@ public struct ConsentDocument: View { signature.removeAll() #endif } + documentExport.name = name } Divider() @@ -129,12 +130,13 @@ public struct ConsentDocument: View { } else { viewState = .namesEntered } + documentExport.signature = signature } } public var body: some View { VStack { - MarkdownView(asyncMarkdown: asyncMarkdown, state: $viewState.base) + MarkdownView(asyncMarkdown: documentExport.asyncMarkdown, state: $viewState.base) Spacer() Group { nameView @@ -152,11 +154,16 @@ public struct ConsentDocument: View { .onChange(of: viewState) { if case .export = viewState { Task { - guard let exportedConsent = await export() else { - viewState = .base(.error(Error.memoryAllocationError)) - return + do { + /// Stores the finished PDF in the Spezi `Standard`. + let exportedConsent = try await export() + + documentExport.cachedPDF = exportedConsent + viewState = .exported(document: exportedConsent, export: documentExport) + } catch { + // In case of error, go back to previous state. + viewState = .base(.error(AnyLocalizedError(error: error))) } - viewState = .exported(document: exportedConsent) } } else if case .base(let baseViewState) = viewState, case .idle = baseViewState { @@ -194,6 +201,7 @@ public struct ConsentDocument: View { /// - familyNameTitle: The localization to use for the family (last) name field. /// - familyNamePlaceholder: The localization to use for the family name field placeholder. /// - exportConfiguration: Defines the properties of the exported consent form via ``ConsentDocument/ExportConfiguration``. + /// - documentIdentifier: A unique identifier or "name" for the consent form, helpful for distinguishing consent forms when storing in the `Standard`. public init( markdown: @escaping () async -> Data, viewState: Binding, @@ -201,15 +209,24 @@ public struct ConsentDocument: View { givenNamePlaceholder: LocalizedStringResource = LocalizationDefaults.givenNamePlaceholder, familyNameTitle: LocalizedStringResource = LocalizationDefaults.familyNameTitle, familyNamePlaceholder: LocalizedStringResource = LocalizationDefaults.familyNamePlaceholder, - exportConfiguration: ExportConfiguration = .init() + exportConfiguration: ExportConfiguration = .init(), + documentIdentifier: String = ConsentDocumentExport.Defaults.documentIdentifier ) { - self.asyncMarkdown = markdown self._viewState = viewState self.givenNameTitle = givenNameTitle self.givenNamePlaceholder = givenNamePlaceholder self.familyNameTitle = familyNameTitle self.familyNamePlaceholder = familyNamePlaceholder - self.exportConfiguration = exportConfiguration + + self.documentExport = ConsentDocumentExport( + markdown: markdown, + exportConfiguration: exportConfiguration, + documentIdentifier: documentIdentifier + ) + // Set initial values for the name and signature. + // These will be updated once the name and signature change. + self.documentExport.name = name + self.documentExport.signature = signature } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift new file mode 100644 index 0000000..bd069d0 --- /dev/null +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift @@ -0,0 +1,159 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import PDFKit +import PencilKit +import SwiftUI +import TPPDF + +/// Extension of `ConsentDocumentExport` enabling the export of the signed consent page. +extension ConsentDocumentExport { + /// Generates a `PDFAttributedText` containing the timestamp of the time at which the PDF was exported. + /// + /// - Returns: A TPPDF `PDFAttributedText` representation of the export time stamp. + private func exportTimeStamp() -> PDFAttributedText { + let stampText = String(localized: "EXPORTED_TAG", bundle: .module) + ": " + + DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short) + "\n\n\n\n" + + let attributedTitle = NSMutableAttributedString( + string: stampText, + attributes: [ + NSAttributedString.Key.font: exportConfiguration.fontSettings.headerExportTimeStampFont + ] + ) + + return PDFAttributedText(text: attributedTitle) + } + + /// Converts the header text (i.e., document title) to a PDFAttributedText, which can be + /// added to the exported PDFDocument. + /// + /// - Returns: A TPPDF `PDFAttributedText` representation of the document title. + private func exportHeader() -> PDFAttributedText { + let attributedTitle = NSMutableAttributedString( + string: exportConfiguration.consentTitle.localizedString() + "\n\n", + attributes: [ + NSAttributedString.Key.font: exportConfiguration.fontSettings.headerTitleFont + ] + ) + + return PDFAttributedText(text: attributedTitle) + } + + /// Converts the content (i.e., the markdown text) of the consent document to a PDFAttributedText, which can be + /// added to the exported PDFDocument. + /// + /// - Returns: A TPPDF `PDFAttributedText` representation of the document content. + @MainActor + private func exportDocumentContent() async -> PDFAttributedText { + let markdown = await asyncMarkdown() + var markdownString = (try? AttributedString( + markdown: markdown, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(String(localized: "MARKDOWN_LOADING_ERROR", bundle: .module)) + + markdownString.font = exportConfiguration.fontSettings.documentContentFont + + return PDFAttributedText(text: NSAttributedString(markdownString)) + } + + /// Exports the signature to a `PDFGroup` which can be added to the exported PDFDocument. + /// The signature group will contain a prefix ("X"), the name of the signee as well as the signature image. + /// + /// - Returns: A TPPDF `PDFAttributedText` representation of the export time stamp. + @MainActor + private func exportSignature() -> PDFGroup { + let personName = name.formatted(.name(style: .long)) + + #if !os(macOS) + let group = PDFGroup( + allowsBreaks: false, + backgroundImage: PDFImage(image: signatureImage), + padding: EdgeInsets(top: 50, left: 50, bottom: 0, right: 100) + ) + let signaturePrefix = "X" + #else + // On macOS, we do not have a "drawn" signature, hence we do + // not set a backgroundImage for the PDFGroup. + // Instead, we render the person name. + let group = PDFGroup( + allowsBreaks: false, + padding: EdgeInsets(top: 50, left: 50, bottom: 0, right: 100) + ) + let signaturePrefix = "X " + signature + #endif + + group.set(font: exportConfiguration.fontSettings.signaturePrefixFont) + group.add(PDFGroupContainer.left, text: signaturePrefix) + + group.addLineSeparator(style: PDFLineStyle(color: .black)) + + group.set(font: exportConfiguration.fontSettings.signatureNameFont) + group.add(PDFGroupContainer.left, text: personName) + return group + } + + + /// Creates a `PDFKit.PDFDocument` containing the header, content and signature from the exported `ConsentDocument`. + /// An export time stamp can be added optionally. + /// + /// - Parameters: + /// - header: The header of the document exported to a PDFAttributedText, e.g., using `exportHeader()`. + /// - pdfTextContent: The content of the document exported to a PDFAttributedText, e.g., using `exportDocumentContent()`. + /// - signatureFooter: The footer including the signature of the document, exported to a PDFGroup, e.g., using `exportSignature()`. + /// - exportTimeStamp: Optional parameter representing the timestamp of the time at which the document was exported. Can be created using `exportTimeStamp()` + /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` + @MainActor + private func createDocument( + header: PDFAttributedText, + pdfTextContent: PDFAttributedText, + signatureFooter: PDFGroup, + exportTimeStamp: PDFAttributedText? = nil + ) async throws -> PDFKit.PDFDocument { + let document = TPPDF.PDFDocument(format: exportConfiguration.getPDFPageFormat()) + + if let exportStamp = exportTimeStamp { + document.add(.contentRight, attributedTextObject: exportStamp) + } + + document.add(.contentCenter, attributedTextObject: header) + document.add(attributedTextObject: pdfTextContent) + document.add(group: signatureFooter) + + // Convert TPPDF.PDFDocument to PDFKit.PDFDocument + let generator = PDFGenerator(document: document) + + let data = try generator.generateData() + + guard let pdfDocument = PDFKit.PDFDocument(data: data) else { + throw ConsentDocumentExportError.invalidPdfData("PDF data not compatible with PDFDocument") + } + + return pdfDocument + } + + /// Exports the signed consent form as a `PDFKit.PDFDocument`. + /// The PDF generated by TPPDF and then converted to a TPDFKit.PDFDocument. + /// Renders the `PDFDocument` according to the specified ``ConsentDocument/ExportConfiguration``. + /// + /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` + @MainActor + public func export() async throws -> PDFKit.PDFDocument { + let exportTimeStamp = exportConfiguration.includingTimestamp ? exportTimeStamp() : nil + let header = exportHeader() + let pdfTextContent = await exportDocumentContent() + let signature = exportSignature() + + return try await createDocument( + header: header, + pdfTextContent: pdfTextContent, + signatureFooter: signature, + exportTimeStamp: exportTimeStamp + ) + } +} diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift index 18a9545..462c354 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift @@ -6,10 +6,13 @@ // SPDX-License-Identifier: MIT // -import PDFKit +@preconcurrency import PDFKit +import PencilKit +import SwiftUI /// A type representing an exported `ConsentDocument`. It holds the exported `PDFDocument` and the corresponding document identifier String. -public actor ConsentDocumentExport { +@Observable +public final class ConsentDocumentExport: Equatable, Sendable { /// Provides default values for fields related to the `ConsentDocumentExport`. public enum Defaults { /// Default value for a document identifier. @@ -17,31 +20,68 @@ public actor ConsentDocumentExport { public static let documentIdentifier = "ConsentDocument" } - private var cachedPDF: PDFDocument - + let asyncMarkdown: () async -> Data + let exportConfiguration: ConsentDocument.ExportConfiguration + var cachedPDF: PDFDocument? + /// An unique identifier for the exported `ConsentDocument`. /// Corresponds to the identifier which was passed when creating the `ConsentDocument` using an `OnboardingConsentView`. public let documentIdentifier: String + + /// The name of the person which signed the document. + public var name = PersonNameComponents() + #if !os(macOS) + /// The signature of the signee as drawing. + public var signature = PKDrawing() + /// The image generated from the signature drawing. + public var signatureImage = UIImage() + #else + /// The signature of the signee as string. + public var signature = String() + #endif + /// The `PDFDocument` exported from a `ConsentDocument`. /// This property is asynchronous and accesing it potentially triggers the export of the PDF from the underlying `ConsentDocument`, /// if the `ConsentDocument` has not been previously exported or the `PDFDocument` was not cached. /// For now, we always require a PDF to be cached to create a ConsentDocumentExport. In the future, we might change this to lazy-PDF loading. - public var pdf: PDFDocument { + @MainActor public var pdf: PDFDocument { get async { - cachedPDF + if let pdf = cachedPDF { + return pdf + } + + guard let pdf = try? await export() else { + return .init() + } + + cachedPDF = pdf + return pdf } } - + /// Creates a `ConsentDocumentExport`, which holds an exported PDF and the corresponding document identifier string. /// - Parameters: + /// - markdown: The markdown text for the document, which is shown to the user. /// - documentIdentfier: A unique String identifying the exported `ConsentDocument`. + /// - exportConfiguration: The `ExportConfiguration` holding the properties of the document. /// - cachedPDF: A `PDFDocument` exported from a `ConsentDocument`. init( + markdown: @escaping () async -> Data, + exportConfiguration: ConsentDocument.ExportConfiguration, documentIdentifier: String, - cachedPDF: PDFDocument + cachedPDF: PDFDocument? = nil ) { + self.asyncMarkdown = markdown + self.exportConfiguration = exportConfiguration self.documentIdentifier = documentIdentifier self.cachedPDF = cachedPDF } + + public static func == (lhs: ConsentDocumentExport, rhs: ConsentDocumentExport) -> Bool { + lhs.documentIdentifier == rhs.documentIdentifier && + lhs.name == rhs.name && + lhs.signature == rhs.signature && + lhs.cachedPDF == rhs.cachedPDF + } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportError.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportError.swift new file mode 100644 index 0000000..74083ed --- /dev/null +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportError.swift @@ -0,0 +1,27 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + +// Error that can occur if PDF export fails in ``ConsentDocumentExport``. +enum ConsentDocumentExportError: LocalizedError { + case invalidPdfData(String) + + var errorDescription: String? { + switch self { + case .invalidPdfData: + String( + localized: "Unable to generate valid PDF document from PDF data.", + comment: """ + Error thrown if we generated a PDF document using TPPDF, + but were unable to convert the generated data into a PDFDocument. + """ + ) + } + } +} diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift b/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift index d5ea24c..a31108a 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift @@ -34,7 +34,7 @@ public enum ConsentViewState: Equatable { /// ``ConsentDocument`` has been successfully exported. The rendered `PDFDocument` can be found as the associated value of the state. /// /// The export procedure (resulting in the ``ConsentViewState/exported(document:)`` state) can be triggered via setting the ``ConsentViewState/export`` state of the ``ConsentDocument`` . - case exported(document: PDFDocument) + case exported(document: PDFDocument, export: ConsentDocumentExport) /// The `storing` state indicates that the ``ConsentDocument`` is currently being stored to the Standard. case storing } diff --git a/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift b/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift new file mode 100644 index 0000000..bac4249 --- /dev/null +++ b/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift @@ -0,0 +1,63 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SwiftUI + +extension ConsentDocument.ExportConfiguration { + /// Provides default values for fields related to the `ConsentDocumentExportConfiguration`. + public enum Defaults { + #if !os(macOS) + /// Default export font settings with fixed font sizes, ensuring a consistent appearance across platforms. + /// + /// This configuration uses `systemFont` and `boldSystemFont` with absolute font sizes to achieve uniform font sizes + /// on different operating systems such as macOS, iOS, and visionOS. + public static let defaultExportFontSettings = FontSettings( + signatureNameFont: UIFont.systemFont(ofSize: 10), + signaturePrefixFont: UIFont.boldSystemFont(ofSize: 12), + documentContentFont: UIFont.systemFont(ofSize: 12), + headerTitleFont: UIFont.boldSystemFont(ofSize: 28), + headerExportTimeStampFont: UIFont.systemFont(ofSize: 8) + ) + + /// Default font based on system standards. In contrast to defaultExportFontSettings, + /// the font sizes might change according to the system settings, potentially leading to varying exported PDF documents + /// on devices with different system settings (e.g., larger default font size). + public static let defaultSystemDefaultFontSettings = FontSettings( + signatureNameFont: UIFont.preferredFont(forTextStyle: .subheadline), + signaturePrefixFont: UIFont.preferredFont(forTextStyle: .title2), + documentContentFont: UIFont.preferredFont(forTextStyle: .body), + headerTitleFont: UIFont.boldSystemFont(ofSize: UIFont.preferredFont(forTextStyle: .largeTitle).pointSize), + headerExportTimeStampFont: UIFont.preferredFont(forTextStyle: .caption1) + ) + #else + /// Default export font settings with fixed font sizes, ensuring a consistent appearance across platforms. + /// + /// This configuration uses `systemFont` and `boldSystemFont` with absolute font sizes to achieve uniform font sizes + /// on different operating systems such as macOS, iOS, and visionOS. + public static let defaultExportFontSettings = FontSettings( + signatureNameFont: NSFont.systemFont(ofSize: 10), + signaturePrefixFont: NSFont.boldSystemFont(ofSize: 12), + documentContentFont: NSFont.systemFont(ofSize: 12), + headerTitleFont: NSFont.boldSystemFont(ofSize: 28), + headerExportTimeStampFont: NSFont.systemFont(ofSize: 8) + ) + + /// Default font based on system standards. In contrast to defaultExportFontSettings, + /// the font sizes might change according to the system settings, potentially leading to varying exported PDF documents + /// on devices with different system settings (e.g., larger default font size). + public static let defaultSystemDefaultFontSettings = FontSettings( + signatureNameFont: NSFont.preferredFont(forTextStyle: .subheadline), + signaturePrefixFont: NSFont.preferredFont(forTextStyle: .title2), + documentContentFont: NSFont.preferredFont(forTextStyle: .body), + headerTitleFont: NSFont.boldSystemFont(ofSize: NSFont.preferredFont(forTextStyle: .largeTitle).pointSize), + headerExportTimeStampFont: NSFont.preferredFont(forTextStyle: .caption1) + ) + #endif + } +} diff --git a/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+PDFPageFormat.swift b/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+PDFPageFormat.swift new file mode 100644 index 0000000..ece0429 --- /dev/null +++ b/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+PDFPageFormat.swift @@ -0,0 +1,28 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import TPPDF + + +/// The ``ExportConfiguration`` enables developers to define the properties of the exported consent form. +extension ConsentDocument.ExportConfiguration { + /// Returns a `TPPDF.PDFPageFormat` which corresponds to Spezi's `ExportConfiguration.PaperSize`. + /// + /// - Parameters: + /// - paperSize: The paperSize of an ExportConfiguration. + /// - Returns: A TPPDF `PDFPageFormat` according to the `ExportConfiguration.PaperSize`. + func getPDFPageFormat() -> PDFPageFormat { + switch paperSize { + case .dinA4: + return PDFPageFormat.a4 + case .usLetter: + return PDFPageFormat.usLetter + } + } +} diff --git a/Sources/SpeziOnboarding/OnboardingConsentView.swift b/Sources/SpeziOnboarding/OnboardingConsentView.swift index 051c6a8..e94c090 100644 --- a/Sources/SpeziOnboarding/OnboardingConsentView.swift +++ b/Sources/SpeziOnboarding/OnboardingConsentView.swift @@ -87,7 +87,8 @@ public struct OnboardingConsentView: View { ConsentDocument( markdown: markdown, viewState: $viewState, - exportConfiguration: exportConfiguration + exportConfiguration: exportConfiguration, + documentIdentifier: identifier ) .padding(.bottom) }, @@ -111,13 +112,13 @@ public struct OnboardingConsentView: View { .scrollDisabled($viewState.signing.wrappedValue) .navigationBarBackButtonHidden(backButtonHidden) .onChange(of: viewState) { - if case .exported(let exportedConsentDocumented) = viewState { + if case .exported(_, let export) = viewState { if !willShowShareSheet { viewState = .storing Task { do { /// Stores the finished PDF in the Spezi `Standard`. - try await onboardingDataSource.store(exportedConsentDocumented, identifier: identifier) + try await onboardingDataSource.store(export) await action() viewState = .base(.idle) @@ -162,9 +163,9 @@ public struct OnboardingConsentView: View { } } .sheet(isPresented: $showShareSheet) { - if case .exported(let exportedConsentDocumented) = viewState { + if case .exported(let document, _) = viewState { #if !os(macOS) - ShareSheet(sharedItem: exportedConsentDocumented) + ShareSheet(sharedItem: document) .presentationDetents([.medium]) .task { willShowShareSheet = false @@ -178,7 +179,7 @@ public struct OnboardingConsentView: View { #if os(macOS) .onChange(of: showShareSheet) { _, isPresented in if isPresented, - case .exported(let exportedConsentDocumented) = viewState { + case .exported(let exportedConsentDocumented, _) = viewState { let shareSheet = ShareSheet(sharedItem: exportedConsentDocumented) shareSheet.show() diff --git a/Sources/SpeziOnboarding/OnboardingDataSource.swift b/Sources/SpeziOnboarding/OnboardingDataSource.swift index 55fa7ee..e78e330 100644 --- a/Sources/SpeziOnboarding/OnboardingDataSource.swift +++ b/Sources/SpeziOnboarding/OnboardingDataSource.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -import PDFKit +@preconcurrency import PDFKit import Spezi import SwiftUI @@ -45,16 +45,46 @@ public class OnboardingDataSource: Module, EnvironmentAccessible { fatalError("A \(type(of: standard).self) must conform to `ConsentConstraint` to process signed consent documents.") } } + + /// Adds a new exported consent form represented as `PDFDocument` to the ``OnboardingDataSource``. + /// + /// - Parameters + /// - consent: The PDF of the exported consent form. + /// - identifier: The document identifier for the exported consent document. + @available( + *, + deprecated, + message: """ + Storing consent documents using an exported PDF and an identifier is deprecated. + Please store the consent document from the corresponding `ConsentDocumentExport`, + by using `ConsentConstraint.store(_ consent: ConsentDocumentExport)` instead. + """ + ) + public func store(_ consent: PDFDocument, identifier: String = ConsentDocumentExport.Defaults.documentIdentifier) async throws { + // Normally, the ConsentDocumentExport stores all data relevant to generate the PDFDocument, such as the data and ExportConfiguration. + // Since we can not determine the original data and the ExportConfiguration at this point, we simply use some placeholder data + // to generate the ConsentDocumentExport. + let dataPlaceholder = { + Data("".utf8) + } + let documentExport = ConsentDocumentExport( + markdown: dataPlaceholder, + exportConfiguration: ConsentDocument.ExportConfiguration(), + documentIdentifier: identifier, + cachedPDF: consent + ) + try await store(documentExport) + } /// Adds a new exported consent form represented as `PDFDocument` to the ``OnboardingDataSource``. /// /// - Parameter consent: The exported consent form represented as `ConsentDocumentExport` that should be added. - public func store(_ consent: PDFDocument, identifier: String = ConsentDocumentExport.Defaults.documentIdentifier) async throws { + public func store(_ consent: ConsentDocumentExport) async throws { if let consentConstraint = standard as? any ConsentConstraint { - let consentDocumentExport = ConsentDocumentExport(documentIdentifier: identifier, cachedPDF: consent) - try await consentConstraint.store(consent: consentDocumentExport) + try await consentConstraint.store(consent: consent) } else if let onboardingConstraint = standard as? any OnboardingConstraint { - await onboardingConstraint.store(consent: consent) + let pdf = try await consent.pdf + await onboardingConstraint.store(consent: pdf) } else { fatalError("A \(type(of: standard).self) must conform to `ConsentConstraint` to process signed consent documents.") } diff --git a/Sources/SpeziOnboarding/Resources/Localizable.xcstrings b/Sources/SpeziOnboarding/Resources/Localizable.xcstrings index 93dd60e..5c911cf 100644 --- a/Sources/SpeziOnboarding/Resources/Localizable.xcstrings +++ b/Sources/SpeziOnboarding/Resources/Localizable.xcstrings @@ -304,6 +304,9 @@ } } } + }, + "Unable to generate valid PDF document from PDF data." : { + "comment" : "Error thrown if we generated a PDF document using TPPDF,\nbut were unable to convert the generated data into a PDFDocument." } }, "version" : "1.0" diff --git a/Tests/SpeziOnboardingTests/.gitattributes b/Tests/SpeziOnboardingTests/.gitattributes new file mode 100644 index 0000000..3d6f2c3 --- /dev/null +++ b/Tests/SpeziOnboardingTests/.gitattributes @@ -0,0 +1,8 @@ +# +# This source file is part of the Stanford Spezi open-source project +# +# SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +# +# SPDX-License-Identifier: MIT +# +Resources/*.pdf filter=lfs diff=lfs merge=lfs -text diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_ios.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_ios.pdf new file mode 100644 index 0000000..769a4db Binary files /dev/null and b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_ios.pdf differ diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_mac_os.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_mac_os.pdf new file mode 100644 index 0000000..d67492b Binary files /dev/null and b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_mac_os.pdf differ diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_vision_os.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_vision_os.pdf new file mode 100644 index 0000000..3979b41 Binary files /dev/null and b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_vision_os.pdf differ diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_ios.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_ios.pdf new file mode 100644 index 0000000..0d5447e Binary files /dev/null and b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_ios.pdf differ diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_mac_os.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_mac_os.pdf new file mode 100644 index 0000000..1fb5f03 Binary files /dev/null and b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_mac_os.pdf differ diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_vision_os.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_vision_os.pdf new file mode 100644 index 0000000..c690e46 Binary files /dev/null and b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_vision_os.pdf differ diff --git a/Tests/SpeziOnboardingTests/Resources/markdown_data_one_page.md b/Tests/SpeziOnboardingTests/Resources/markdown_data_one_page.md new file mode 100644 index 0000000..d9c4053 --- /dev/null +++ b/Tests/SpeziOnboardingTests/Resources/markdown_data_one_page.md @@ -0,0 +1,3 @@ +This is a one page pdf example. + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. \ No newline at end of file diff --git a/Tests/SpeziOnboardingTests/Resources/markdown_data_two_pages.md b/Tests/SpeziOnboardingTests/Resources/markdown_data_two_pages.md new file mode 100644 index 0000000..deb6be5 --- /dev/null +++ b/Tests/SpeziOnboardingTests/Resources/markdown_data_two_pages.md @@ -0,0 +1,17 @@ +This is a two page pdf example. + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. \ No newline at end of file diff --git a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift index 5613633..3252dd6 100644 --- a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import PDFKit @testable import SpeziOnboarding import SwiftUI import XCTest @@ -38,4 +39,128 @@ final class SpeziOnboardingTests: XCTestCase { let final = hasher.finalize() XCTAssertEqual(identifier.identifierHash, final) } + + @MainActor + func testPDFExport() async throws { + let markdownDataFiles: [String] = ["markdown_data_one_page", "markdown_data_two_pages"] + let knownGoodPDFFiles: [String] = ["known_good_pdf_one_page", "known_good_pdf_two_pages"] + + for (markdownPath, knownGoodPDFPath) in zip(markdownDataFiles, knownGoodPDFFiles) { + let markdownData = { + self.loadMarkdownDataFromFile(path: markdownPath) + } + + let exportConfiguration = ConsentDocument.ExportConfiguration( + paperSize: .dinA4, + consentTitle: "Spezi Onboarding", + includingTimestamp: false + ) + + let documentExport = ConsentDocumentExport( + markdown: markdownData, + exportConfiguration: exportConfiguration, + documentIdentifier: ConsentDocumentExport.Defaults.documentIdentifier + ) + documentExport.name = PersonNameComponents(givenName: "Leland", familyName: "Stanford") + + #if os(macOS) + let pdfPath = knownGoodPDFPath + "_mac_os" + #elseif os(visionOS) + let pdfPath = knownGoodPDFPath + "_vision_os" + #else + let pdfPath = knownGoodPDFPath + "_ios" + #endif + + let knownGoodPdf = loadPDFFromPath(path: pdfPath) + + #if !os(macOS) + documentExport.signature = .init() + #else + documentExport.signature = "Stanford" + #endif + + if let pdf = try? await documentExport.export() { + XCTAssert(comparePDFDocuments(pdf1: pdf, pdf2: knownGoodPdf)) + } else { + XCTFail("Failed to export PDF from ConsentDocumentExport.") + } + } + } + + private func loadMarkdownDataFromFile(path: String) -> Data { + let bundle = Bundle.module // Access the test bundle + guard let fileURL = bundle.url(forResource: path, withExtension: "md") else { + XCTFail("Failed to load \(path).md from resources.") + return Data() + } + + // Load the content of the file into Data + var markdownData = Data() + do { + markdownData = try Data(contentsOf: fileURL) + } catch { + XCTFail("Failed to read \(path).md from resources: \(error.localizedDescription)") + } + return markdownData + } + + private func loadPDFFromPath(path: String) -> PDFDocument { + let bundle = Bundle.module // Access the test bundle + guard let url = bundle.url(forResource: path, withExtension: "pdf") else { + XCTFail("Failed to locate \(path) in resources.") + return .init() + } + + guard let knownGoodPdf = PDFDocument(url: url) else { + XCTFail("Failed to load \(path) from resources.") + return .init() + } + return knownGoodPdf + } + + private func comparePDFDocuments(pdf1: PDFDocument, pdf2: PDFDocument) -> Bool { + // Check if both documents have the same number of pages + guard pdf1.pageCount == pdf2.pageCount else { + return false + } + + // Iterate through each page and compare their contents + for index in 0.. Bool { + // Get the document directory path + let fileManager = FileManager.default + guard let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + print("Could not find the documents directory.") + return false + } + + // Create the full file path + let filePath = documentsDirectory.appendingPathComponent("\(fileName).pdf") + + // Attempt to write the PDF document to the file path + if pdfDocument.write(to: filePath) { + print("PDF saved successfully at: \(filePath)") + return true + } else { + print("Failed to save PDF.") + return false + } +} } diff --git a/Tests/UITests/TestApp/ExampleStandard.swift b/Tests/UITests/TestApp/ExampleStandard.swift index c3fb232..5102dc8 100644 --- a/Tests/UITests/TestApp/ExampleStandard.swift +++ b/Tests/UITests/TestApp/ExampleStandard.swift @@ -24,7 +24,7 @@ extension ExampleStandard: ConsentConstraint { func store(consent: ConsentDocumentExport) async throws { // Extract data outside of the MainActor.run block let documentIdentifier = await consent.documentIdentifier - let pdf = await consent.pdf + let pdf = try await consent.pdf // Perform operations on the main actor try await MainActor.run { diff --git a/Tests/UITests/TestApp/OnboardingTestsView.swift b/Tests/UITests/TestApp/OnboardingTestsView.swift index 45567f2..114b6c8 100644 --- a/Tests/UITests/TestApp/OnboardingTestsView.swift +++ b/Tests/UITests/TestApp/OnboardingTestsView.swift @@ -15,7 +15,6 @@ struct OnboardingTestsView: View { @Binding var onboardingFlowComplete: Bool @State var showConditionalView = false - var body: some View { OnboardingStack(onboardingFlowComplete: $onboardingFlowComplete) { OnboardingStartTestView( @@ -29,6 +28,7 @@ struct OnboardingTestsView: View { consentText: "This is the first *markdown* **example**", documentIdentifier: DocumentIdentifiers.first ) + OnboardingConsentMarkdownRenderingView( consentTitle: "First Consent", documentIdentifier: DocumentIdentifiers.first diff --git a/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift b/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift index 6e0b086..377d2e3 100644 --- a/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift @@ -9,8 +9,7 @@ import XCTest import XCTestExtensions - -final class OnboardingTests: XCTestCase { // swiftlint:disable:this type_body_length +final class OnboardingTests: XCTestCase { override func setUp() { continueAfterFailure = false } @@ -211,134 +210,6 @@ final class OnboardingTests: XCTestCase { // swiftlint:disable:this type_body_le XCTAssert(app.staticTexts["First Consent PDF rendering exists"].waitForExistence(timeout: 2)) } - #if !os(macOS) // Only test export on non macOS platforms - @MainActor - func testOnboardingConsentPDFExport() throws { // swiftlint:disable:this function_body_length - let app = XCUIApplication() - let filesApp = XCUIApplication(bundleIdentifier: "com.apple.DocumentsApp") - - app.launch() - - XCTAssert(app.buttons["Consent View (Markdown)"].waitForExistence(timeout: 2)) - app.buttons["Consent View (Markdown)"].tap() - - XCTAssert(app.staticTexts["First Consent"].waitForExistence(timeout: 2)) - XCTAssert(app.staticTexts["This is the first markdown example"].waitForExistence(timeout: 2)) - - XCTAssert(app.staticTexts["First Name"].exists) - try app.textFields["Enter your first name ..."].enter(value: "Leland") - - XCTAssert(app.staticTexts["Last Name"].exists) - try app.textFields["Enter your last name ..."].enter(value: "Stanford") - - XCTAssert(app.staticTexts["Name: Leland Stanford"].waitForExistence(timeout: 2)) - - XCTAssert(app.scrollViews["Signature Field"].exists) - app.scrollViews["Signature Field"].swipeRight() - - // Export consent form via share sheet button - XCTAssert(app.buttons["Share consent form"].waitForExistence(timeout: 4)) - app.buttons["Share consent form"].tap() - - // Store exported consent form in Files -#if os(visionOS) - // on visionOS the save to files button has no label - if #available(visionOS 2.0, *) { - XCTAssert(app.cells["Save to Files"].waitForExistence(timeout: 10)) - app.cells["Save to Files"].tap() - } else { - XCTAssert(app.cells["XCElementSnapshotPrivilegedValuePlaceholder"].waitForExistence(timeout: 10)) - app.cells["XCElementSnapshotPrivilegedValuePlaceholder"].tap() - } -#else - XCTAssert(app.staticTexts["Save to Files"].waitForExistence(timeout: 10)) - app.staticTexts["Save to Files"].tap() -#endif - - XCTAssert(app.navigationBars.buttons["Save"].waitForExistence(timeout: 5)) - if !app.navigationBars.buttons["Save"].isEnabled { - throw XCTSkip("You currently cannot save anything in the files app in Xcode 16-based simulator.") - } - app.navigationBars.buttons["Save"].tap() - - if app.staticTexts["Replace Existing Items?"].waitForExistence(timeout: 2.0) { - XCTAssert(app.buttons["Replace"].exists) - app.buttons["Replace"].tap() - } - - // Wait until share sheet closed and back on the consent form screen - XCTAssertTrue(app.staticTexts["Name: Leland Stanford"].waitForExistence(timeout: 10)) - - XCUIDevice.shared.press(.home) - - // Launch the Files app - filesApp.launch() - XCTAssertTrue(filesApp.wait(for: .runningForeground, timeout: 2.0)) - - // Handle already open files on iOS - if filesApp.navigationBars.buttons["Done"].waitForExistence(timeout: 2) { - filesApp.navigationBars.buttons["Done"].tap() - } - - // If the file already shows up in the Recents view, we are good. - // Otherwise navigate to "On My iPhone"/"On My iPad"/"On My Apple Vision Pro" view - if !filesApp.staticTexts["Signed Consent Form"].waitForExistence(timeout: 2) { -#if os(visionOS) - XCTAssertTrue(filesApp.staticTexts["On My Apple Vision Pro"].waitForExistence(timeout: 2.0)) - filesApp.staticTexts["On My Apple Vision Pro"].tap() - XCTAssertTrue(filesApp.navigationBars.staticTexts["On My Apple Vision Pro"].waitForExistence(timeout: 2.0)) -#else - if filesApp.navigationBars.buttons["Show Sidebar"].exists && !filesApp.buttons["Browse"].exists { - // we are running on iPad which is not iOS 18! - filesApp.navigationBars.buttons["Show Sidebar"].tap() - XCTAssertTrue(filesApp.staticTexts["On My iPad"].waitForExistence(timeout: 2.0)) - filesApp.staticTexts["On My iPad"].tap() - XCTAssertTrue(filesApp.navigationBars.staticTexts["On My iPad"].waitForExistence(timeout: 2.0)) - } - - if filesApp.tabBars.buttons["Browse"].exists { // iPhone - filesApp.tabBars.buttons["Browse"].tap() - XCTAssertTrue(filesApp.navigationBars.staticTexts["On My iPhone"].waitForExistence(timeout: 2.0)) - } else { // iPad - if !filesApp.navigationBars.staticTexts["On My iPad"].exists { // we aren't already in browse - XCTAssertTrue(filesApp.buttons["Browse"].exists) - filesApp.buttons["Browse"].tap() - XCTAssertTrue(filesApp.navigationBars.staticTexts["On My iPad"].waitForExistence(timeout: 2.0)) - } - } -#endif - - XCTAssert(filesApp.staticTexts["Signed Consent Form"].waitForExistence(timeout: 2.0)) - } - XCTAssert(filesApp.collectionViews["File View"].cells["Signed Consent Form, pdf"].exists) - - XCTAssert(filesApp.collectionViews["File View"].cells["Signed Consent Form, pdf"].images.firstMatch.exists) - filesApp.collectionViews["File View"].cells["Signed Consent Form, pdf"].images.firstMatch.tap() - - #if os(visionOS) - let fileView = XCUIApplication(bundleIdentifier: "com.apple.MRQuickLook") - XCTAssertTrue(fileView.wait(for: .runningForeground, timeout: 5.0)) - #else - let fileView = filesApp - - // Wait until file is opened - XCTAssertTrue(fileView.navigationBars["Signed Consent Form"].waitForExistence(timeout: 5.0)) - #endif - - // Check if PDF contains consent title, name, and markdown message - for searchString in ["Spezi Consent", "This is the first markdown example", "Leland Stanford"] { - let predicate = NSPredicate(format: "label CONTAINS[c] %@", searchString) - XCTAssert(fileView.otherElements.containing(predicate).firstMatch.waitForExistence(timeout: 2)) - } - - #if os(iOS) - // Close File - XCTAssert(fileView.buttons["Done"].waitForExistence(timeout: 2)) - fileView.buttons["Done"].tap() - #endif - } - #endif - @MainActor func testOnboardingCustomViews() throws { let app = XCUIApplication() diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 3eacd68..b894f57 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -472,10 +472,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 637867499T; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -491,6 +491,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.onboarding.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;