From ec18eab4810d4b7349bde186e0462a6d7987a217 Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Sat, 22 Jun 2024 12:36:55 +0200 Subject: [PATCH 01/29] Introduced algorithm to split exported consent document across multiple pages, if footer, header and text do not fit on a single page. Addresses #49. --- .../ConsentView/ConsentDocument+Export.swift | 260 +++++++++++++----- 1 file changed, 186 insertions(+), 74 deletions(-) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index c18d198..1e6c937 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -9,11 +9,10 @@ import PDFKit import PencilKit import SwiftUI - - +import WebKit /// Extension of `ConsentDocument` enabling the export of the signed consent page. extension ConsentDocument { - #if !os(macOS) +#if !os(macOS) /// As the `PKDrawing.image()` function automatically converts the ink color dependent on the used color scheme (light or dark mode), /// force the ink used in the `UIImage` of the `PKDrawing` to always be black by adjusting the signature ink according to the color scheme. private var blackInkSignatureImage: UIImage { @@ -26,23 +25,22 @@ extension ConsentDocument { transform: stroke.transform, mask: stroke.mask ) - + updatedDrawing.strokes.append(blackStroke) } - - #if os(iOS) + +#if os(iOS) let scale = UIScreen.main.scale - #else +#else let scale = 3.0 // retina scale is default - #endif - +#endif + return updatedDrawing.image( from: .init(x: 0, y: 0, width: signatureSize.width, height: signatureSize.height), scale: scale ) } - #endif - +#endif /// Creates a representation of the consent form that is ready to be exported via the SwiftUI `ImageRenderer`. /// @@ -54,45 +52,7 @@ extension ConsentDocument { /// - 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. - 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`. /// @@ -102,41 +62,193 @@ extension ConsentDocument { @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( + + let pageSize = CGSize( width: exportConfiguration.paperSize.dimensions.width, height: exportConfiguration.paperSize.dimensions.height ) - renderer.proposedSize = .init(paperSize) - + + let pages = paginatedViews(markdown: markdownString) + + print("NumPages: \(pages.count)") 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 - } - + guard let mutableData = CFDataCreateMutable(kCFAllocatorDefault, 0), + let consumer = CGDataConsumer(data: mutableData), + let pdf = CGContext(consumer: consumer, mediaBox: nil, nil) else { + continuation.resume(returning: nil) + return + } + + for page in pages { pdf.beginPDFPage(nil) - pdf.translateBy(x: 0, y: 0) - - context(pdf) + + let hostingController = UIHostingController(rootView: page) + hostingController.view.frame = CGRect(origin: .zero, size: pageSize) + + let renderer = UIGraphicsImageRenderer(bounds: hostingController.view.bounds) + let image = renderer.image { ctx in + hostingController.view.drawHierarchy(in: hostingController.view.bounds, afterScreenUpdates: true) + } + + pdf.saveGState() + + pdf.translateBy(x: 0, y: pageSize.height) + pdf.scaleBy(x: 1.0, y: -1.0) + + hostingController.view.layer.render(in: pdf) + + pdf.restoreGState() + pdf.endPDFPage() - pdf.closePDF() - - continuation.resume(returning: PDFDocument(data: mutableData as Data)) } + + pdf.closePDF() + continuation.resume(returning: PDFDocument(data: mutableData as Data)) } } -} + + private func paginatedViews(markdown: AttributedString) -> [AnyView] + { + /* + This algorithm splits the consent document consisting of title, text and signature + across multiple pages, if titleHeight + signatureHeight + textHeight is larger than 1 page. + + Let's call header = title, and footer = signature. + + The algorithm ensures that headerHeight + footerHeight + textHeight <= pageHeight. + headerHeight is set to 200 on the first page, and to 50 on all subsequent pages (as we do not have a title anymore). + footerHeight is always constant at 150 on each page, even if there is no footer, because we need the footerHeight + to determine if we are actually on the last page (check improvements below). + Tested for 1, 2 and 3 pages for dinA4 and usLetter size documents. + + Possible improvements: + * The header height on the first page should not be hardcoded to 200, but calculated from + VStack consisting of export tag + title; if there is no export tag, the headerHeight can be smaller. + * The footerHeight could/should only be set for the last page. However, the algorithm then becomes more complicated: To know if we are on the last page, we check if headerHeight + footerHeight + textHeight <= pageHeight. If footerHeight is 0 we have more space for the text. If we then find out that we are actually + on the last page, we would have to set footerHeight to 150 and thus we have less space for the text. Thus, + it could happen that know we are not on the last page anymore but need one extra page. + + Known problems: + * If we assume headerHeight too small (e.g., 100), then truncation happens. + */ + var pages = [AnyView]() + var remainingMarkdown = markdown + let pageSize = CGSize(width: exportConfiguration.paperSize.dimensions.width, height: exportConfiguration.paperSize.dimensions.height) + // Maximum header height on the first page, i.e., size of + // the VStack containing the export tag + title. + // Better calculate this instead of hardcoding. + let headerHeightFirstPage: CGFloat = 200 + // Header height on all subsequent pages. Should come from exportConfiguration. + let headerHeightOtherPages: CGFloat = 50 + let footerHeight: CGFloat = 150 + + var headerHeight = headerHeightFirstPage + + while !remainingMarkdown.unicodeScalars.isEmpty { + let (currentPageContent, nextPageContent) = split(markdown: remainingMarkdown, pageSize: pageSize, headerHeight: headerHeight, footerHeight: footerHeight) + + // In the first iteration, headerHeight was headerHeightFirstPage. + // In all subsequent iterations, we only need headerHeightOtherPages. + // Hence, more text fits on the page. + headerHeight = headerHeightOtherPages; + + let currentPage: AnyView = AnyView( + VStack { + if pages.isEmpty { // First page + + 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) + } + .padding() + + } else { + // If we are not on the first page, we add a spacing of headerHeight, + // which should now be set to "headerHeightOtherPages". + VStack{}.padding(.top, headerHeight) + } + + Text(currentPageContent) + .padding() + + Spacer() + + if nextPageContent.unicodeScalars.isEmpty { // Last page + 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 + } + .padding(.bottom, footerHeight) + } + } + .frame(width: pageSize.width, height: pageSize.height) + ) + + pages.append(currentPage) + remainingMarkdown = nextPageContent + } + + return pages + } + + private func split(markdown: AttributedString, pageSize: CGSize, headerHeight: CGFloat, footerHeight: CGFloat) -> (AttributedString, AttributedString) + { + // This algorithm determines at which index to split the text, if textHeight + headerHeight + footerHeight > pageSize. + // The algorithm returns the text that still fits on the current page, + // and the remaining text which needs to be placed on subsequent page(s). + // If remaining == 0, this means we have reached the last page, as all remaining text + // can fit on the current page. + + // The algorithm works by creating a virtual text storage container with width = pageSize.width + // and height = pageSize.height - footerHeight - headerHeight. + // We can then ask "how much text fits in this virtual text storage container" by checking it's + // glyphRange. The glyphRange tells us how many characters of the given text fit into the text container. + // Specifically, glyphRange is exactly the index AFTER the last word (not character) that STILL FITS on the page. + // We can then split the text as follows: + // let index = container.glyphRange // Index after last word which still fits on the page. + // currentPage = markdown[0:index] + // remaining = markdown[index:] + + let contentHeight = pageSize.height - headerHeight - footerHeight + var currentPage = AttributedString() + var remaining = markdown + + let textStorage = NSTextStorage(attributedString: NSAttributedString(markdown)) + let layoutManager = NSLayoutManager() + let textContainer = NSTextContainer(size: CGSize(width: pageSize.width, height: contentHeight)) + layoutManager.addTextContainer(textContainer) + textStorage.addLayoutManager(layoutManager) + + var accumulatedHeight: CGFloat = 0 + let maximumRange = layoutManager.glyphRange(for: textContainer) + + currentPage = AttributedString(textStorage.attributedSubstring(from: maximumRange)) + remaining = AttributedString(textStorage.attributedSubstring(from: NSRange(location: maximumRange.length, length: textStorage.length - maximumRange.length))) + + return (currentPage, remaining) + } +} \ No newline at end of file From 4ee81b1c7be155ef3c99130ab2dd071ddcd92709 Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Sat, 22 Jun 2024 16:20:29 +0200 Subject: [PATCH 02/29] Removed leftover continuation from older version of the code. --- .../ConsentView/ConsentDocument+Export.swift | 65 +++++++++---------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index 1e6c937..119a002 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -76,41 +76,40 @@ extension ConsentDocument { let pages = paginatedViews(markdown: markdownString) print("NumPages: \(pages.count)") - return await withCheckedContinuation { continuation in - guard let mutableData = CFDataCreateMutable(kCFAllocatorDefault, 0), - let consumer = CGDataConsumer(data: mutableData), - let pdf = CGContext(consumer: consumer, mediaBox: nil, nil) else { - continuation.resume(returning: nil) - return - } - - for page in pages { - pdf.beginPDFPage(nil) - - let hostingController = UIHostingController(rootView: page) - hostingController.view.frame = CGRect(origin: .zero, size: pageSize) - - let renderer = UIGraphicsImageRenderer(bounds: hostingController.view.bounds) - let image = renderer.image { ctx in - hostingController.view.drawHierarchy(in: hostingController.view.bounds, afterScreenUpdates: true) - } - - pdf.saveGState() - - pdf.translateBy(x: 0, y: pageSize.height) - pdf.scaleBy(x: 1.0, y: -1.0) - - hostingController.view.layer.render(in: pdf) - - pdf.restoreGState() - - - pdf.endPDFPage() - } + + guard let mutableData = CFDataCreateMutable(kCFAllocatorDefault, 0), + let consumer = CGDataConsumer(data: mutableData), + let pdf = CGContext(consumer: consumer, mediaBox: nil, nil) else { + + return nil; + } + + for page in pages { + pdf.beginPDFPage(nil) + + let hostingController = UIHostingController(rootView: page) + hostingController.view.frame = CGRect(origin: .zero, size: pageSize) + + let renderer = UIGraphicsImageRenderer(bounds: hostingController.view.bounds) + let image = renderer.image { ctx in + hostingController.view.drawHierarchy(in: hostingController.view.bounds, afterScreenUpdates: true) + } + + pdf.saveGState() + + pdf.translateBy(x: 0, y: pageSize.height) + pdf.scaleBy(x: 1.0, y: -1.0) + + hostingController.view.layer.render(in: pdf) - pdf.closePDF() - continuation.resume(returning: PDFDocument(data: mutableData as Data)) + pdf.restoreGState() + + + pdf.endPDFPage() } + + pdf.closePDF() + return PDFDocument(data: mutableData as Data); } private func paginatedViews(markdown: AttributedString) -> [AnyView] From 457b273f759d9812e83185575828d5dfe001081c Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Wed, 26 Jun 2024 12:20:16 +0200 Subject: [PATCH 03/29] Resolved swiftlint issues. Changed rendering to use ImageRenderer instead of UIGraphicsImageRenderer for compatibility with macOS and visionOS. --- .../ConsentView/ConsentDocument+Export.swift | 148 +++++++++--------- 1 file changed, 78 insertions(+), 70 deletions(-) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index 119a002..9a6ba3a 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 WebKit + /// Extension of `ConsentDocument` enabling the export of the signed consent page. extension ConsentDocument { #if !os(macOS) @@ -75,50 +75,42 @@ extension ConsentDocument { let pages = paginatedViews(markdown: markdownString) - print("NumPages: \(pages.count)") - + let paperSize = CGSize( + width: exportConfiguration.paperSize.dimensions.width, + height: exportConfiguration.paperSize.dimensions.height + ) + guard let mutableData = CFDataCreateMutable(kCFAllocatorDefault, 0), let consumer = CGDataConsumer(data: mutableData), let pdf = CGContext(consumer: consumer, mediaBox: nil, nil) else { - - return nil; + return nil } for page in pages { - pdf.beginPDFPage(nil) + let renderer = ImageRenderer(content: page) - let hostingController = UIHostingController(rootView: page) - hostingController.view.frame = CGRect(origin: .zero, size: pageSize) - - let renderer = UIGraphicsImageRenderer(bounds: hostingController.view.bounds) - let image = renderer.image { ctx in - hostingController.view.drawHierarchy(in: hostingController.view.bounds, afterScreenUpdates: true) - } - - pdf.saveGState() - - pdf.translateBy(x: 0, y: pageSize.height) - pdf.scaleBy(x: 1.0, y: -1.0) - - hostingController.view.layer.render(in: pdf) - - pdf.restoreGState() + renderer.render { _, context in + var box = CGRect(origin: .zero, size: paperSize) + + pdf.beginPDFPage(nil) + pdf.translateBy(x: 0, y: 0) - - pdf.endPDFPage() + context(pdf) + + pdf.endPDFPage() + } } pdf.closePDF() - return PDFDocument(data: mutableData as Data); + return PDFDocument(data: mutableData as Data) } - private func paginatedViews(markdown: AttributedString) -> [AnyView] - { + private func paginatedViews(markdown: AttributedString) -> [AnyView] { /* This algorithm splits the consent document consisting of title, text and signature across multiple pages, if titleHeight + signatureHeight + textHeight is larger than 1 page. - Let's call header = title, and footer = signature. + Let header = title, and footer = signature. The algorithm ensures that headerHeight + footerHeight + textHeight <= pageHeight. headerHeight is set to 200 on the first page, and to 50 on all subsequent pages (as we do not have a title anymore). @@ -146,41 +138,26 @@ extension ConsentDocument { // Header height on all subsequent pages. Should come from exportConfiguration. let headerHeightOtherPages: CGFloat = 50 let footerHeight: CGFloat = 150 - var headerHeight = headerHeightFirstPage while !remainingMarkdown.unicodeScalars.isEmpty { - let (currentPageContent, nextPageContent) = split(markdown: remainingMarkdown, pageSize: pageSize, headerHeight: headerHeight, footerHeight: footerHeight) + let (currentPageContent, nextPageContent) = + split(markdown: remainingMarkdown, pageSize: pageSize, headerHeight: headerHeight, footerHeight: footerHeight) // In the first iteration, headerHeight was headerHeightFirstPage. // In all subsequent iterations, we only need headerHeightOtherPages. // Hence, more text fits on the page. - headerHeight = headerHeightOtherPages; + headerHeight = headerHeightOtherPages - let currentPage: AnyView = AnyView( + let currentPage = AnyView( VStack { if pages.isEmpty { // First page - - 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) - } - .padding() - + renderTitle(headerHeight: headerHeight) } else { // If we are not on the first page, we add a spacing of headerHeight, // which should now be set to "headerHeightOtherPages". - VStack{}.padding(.top, headerHeight) + VStack { + }.padding(.top, headerHeight) } Text(currentPageContent) @@ -189,33 +166,23 @@ extension ConsentDocument { Spacer() if nextPageContent.unicodeScalars.isEmpty { // Last page - 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 - } - .padding(.bottom, footerHeight) + renderSignature(footerHeight: footerHeight) } } .frame(width: pageSize.width, height: pageSize.height) ) - pages.append(currentPage) remainingMarkdown = nextPageContent } - return pages } - private func split(markdown: AttributedString, pageSize: CGSize, headerHeight: CGFloat, footerHeight: CGFloat) -> (AttributedString, AttributedString) - { + private func split( + markdown: AttributedString, + pageSize: CGSize, + headerHeight: CGFloat, + footerHeight: CGFloat + ) -> (AttributedString, AttributedString) { // This algorithm determines at which index to split the text, if textHeight + headerHeight + footerHeight > pageSize. // The algorithm returns the text that still fits on the current page, // and the remaining text which needs to be placed on subsequent page(s). @@ -242,12 +209,53 @@ extension ConsentDocument { layoutManager.addTextContainer(textContainer) textStorage.addLayoutManager(layoutManager) - var accumulatedHeight: CGFloat = 0 + var accumulatedHeight: CGFloat = 0 let maximumRange = layoutManager.glyphRange(for: textContainer) currentPage = AttributedString(textStorage.attributedSubstring(from: maximumRange)) - remaining = AttributedString(textStorage.attributedSubstring(from: NSRange(location: maximumRange.length, length: textStorage.length - maximumRange.length))) + remaining = AttributedString( + textStorage.attributedSubstring( + from: NSRange( + location: maximumRange.length, + length: textStorage.length - maximumRange.length + ) + ) + ) return (currentPage, remaining) } -} \ No newline at end of file + + // Creates a View for the title, which can then be rendered during PDF export. + private func renderTitle(headerHeight: CGFloat) -> 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) + } + .padding() + } + + // Creates a View for the signature, which can then be rendered during PDF export. + private func renderSignature(footerHeight: CGFloat) -> some View { + 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 + } + .padding(.bottom, footerHeight) + } +} From 42c2443c90e152388f65d9f183f3609131c312cc Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Thu, 27 Jun 2024 14:45:54 +0200 Subject: [PATCH 04/29] Removed unused variables, further cleaned up code. --- .../ConsentView/ConsentDocument+Export.swift | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index 9a6ba3a..1696359 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -68,11 +68,6 @@ extension ConsentDocument { options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) )) ?? AttributedString(String(localized: "MARKDOWN_LOADING_ERROR", bundle: .module)) - let pageSize = CGSize( - width: exportConfiguration.paperSize.dimensions.width, - height: exportConfiguration.paperSize.dimensions.height - ) - let pages = paginatedViews(markdown: markdownString) let paperSize = CGSize( @@ -90,8 +85,6 @@ extension ConsentDocument { let renderer = ImageRenderer(content: page) renderer.render { _, context in - var box = CGRect(origin: .zero, size: paperSize) - pdf.beginPDFPage(nil) pdf.translateBy(x: 0, y: 0) @@ -121,9 +114,8 @@ extension ConsentDocument { Possible improvements: * The header height on the first page should not be hardcoded to 200, but calculated from VStack consisting of export tag + title; if there is no export tag, the headerHeight can be smaller. - * The footerHeight could/should only be set for the last page. However, the algorithm then becomes more complicated: To know if we are on the last page, we check if headerHeight + footerHeight + textHeight <= pageHeight. If footerHeight is 0 we have more space for the text. If we then find out that we are actually - on the last page, we would have to set footerHeight to 150 and thus we have less space for the text. Thus, - it could happen that know we are not on the last page anymore but need one extra page. + * The footerHeight could/should only be set for the last page. However, the algorithm then becomes more complicated: To know if we are on the last page, we check if headerHeight + footerHeight + textHeight <= pageHeight. If footerHeight is 0 we have more space for the text. + If we then find out that we are actually on the last page, we would have to set footerHeight to 150 and thus we have less space for the text. Thus, it could happen that know we are not on the last page anymore but need one extra page. Known problems: * If we assume headerHeight too small (e.g., 100), then truncation happens. @@ -209,7 +201,6 @@ extension ConsentDocument { layoutManager.addTextContainer(textContainer) textStorage.addLayoutManager(layoutManager) - var accumulatedHeight: CGFloat = 0 let maximumRange = layoutManager.glyphRange(for: textContainer) currentPage = AttributedString(textStorage.attributedSubstring(from: maximumRange)) From ee2f2f43841ab1df01d905edbb12e5259df572c9 Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Thu, 11 Jul 2024 16:11:10 +0200 Subject: [PATCH 05/29] Changed PDF export to use TPPDF for PDF generation, instead of creating the PDF manually. --- Package.swift | 6 +- .../ConsentView/ConsentDocument+Export.swift | 326 +++++++----------- 2 files changed, 135 insertions(+), 197 deletions(-) diff --git a/Package.swift b/Package.swift index 855ee32..eb21f89 100644 --- a/Package.swift +++ b/Package.swift @@ -25,7 +25,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") ], targets: [ .target( @@ -34,7 +35,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") ] ), .testTarget( diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index 1696359..56b59c8 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -9,15 +9,16 @@ import PDFKit import PencilKit import SwiftUI +import TPPDF /// Extension of `ConsentDocument` enabling the export of the signed consent page. extension ConsentDocument { -#if !os(macOS) + #if !os(macOS) /// As the `PKDrawing.image()` function automatically converts the ink color dependent on the used color scheme (light or dark mode), /// force the ink used in the `UIImage` of the `PKDrawing` to always be black by adjusting the signature ink according to the color scheme. 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), @@ -25,228 +26,163 @@ extension ConsentDocument { transform: stroke.transform, mask: stroke.mask ) - + updatedDrawing.strokes.append(blackStroke) } - -#if os(iOS) + + #if os(iOS) let scale = UIScreen.main.scale -#else + #else let scale = 3.0 // retina scale is default -#endif - + #endif + return updatedDrawing.image( from: .init(x: 0, y: 0, width: signatureSize.width, height: signatureSize.height), scale: scale ) } -#endif + #endif - /// Creates a representation of the consent form that is ready to be exported via the SwiftUI `ImageRenderer`. + /// Generates a `PDFAttributedText` containing the timestamp of when the PDF was exported. /// - /// - Parameters: - /// - markdown: The markdown consent content as an `AttributedString`. + /// - Returns: A TPPDF `PDFAttributedText` representation of the export time stamp. + func exportTimeStamp() -> PDFAttributedText { + let stampText = String(localized: "EXPORTED_TAG", bundle: .module) + ": " + + DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short) + "\n\n\n\n" + + #if !os(macOS) + let font = UIFont.preferredFont(forTextStyle: .caption1) + #else + let font = NSFont.preferredFont(forTextStyle: .caption1) + #endif + + let attributedTitle = NSMutableAttributedString( + string: stampText, + attributes: [ + NSAttributedString.Key.font: font + ] + ) + + 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 SwiftUI `View` representation of the consent content and signature. + /// - Returns: A TPPDF `PDFAttributedText` representation of the document title. + func exportHeader() -> PDFAttributedText { + #if !os(macOS) + let largeTitleFont = UIFont.preferredFont(forTextStyle: .largeTitle) + let boldLargeTitleFont = UIFont.boldSystemFont(ofSize: largeTitleFont.pointSize) + #else + let largeTitleFont = NSFont.preferredFont(forTextStyle: .largeTitle) + let boldLargeTitleFont = NSFont.boldSystemFont(ofSize: largeTitleFont.pointSize) + #endif + + let attributedTitle = NSMutableAttributedString( + string: exportConfiguration.consentTitle.localizedString() + "\n\n", + attributes: [ + NSAttributedString.Key.font: boldLargeTitleFont + ] + ) + + return PDFAttributedText(text: attributedTitle) + } + /// 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. /// - /// - 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. + /// - Returns: A TPPDF `PDFAttributedText` representation of the export time stamp. + func exportSignature() -> PDFGroup { + let personName = name.formatted(.name(style: .long)) + + #if !os(macOS) + + let group = PDFGroup( + allowsBreaks: false, + backgroundImage: PDFImage(image: blackInkSignatureImage), + padding: EdgeInsets(top: 50, left: 50, bottom: 0, right: 100) + ) + + let signaturePrefixFont = UIFont.preferredFont(forTextStyle: .title2) + let nameFont = UIFont.preferredFont(forTextStyle: .subheadline) + let signatureColor = UIColor.secondaryLabel + let signaturePrefix = "X" + #else + // On macOS, we do not have a "drawn" signature, hence 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 signaturePrefixFont = NSFont.preferredFont(forTextStyle: .title2) + let nameFont = NSFont.preferredFont(forTextStyle: .subheadline) + let signatureColor = NSColor.secondaryLabelColor + let signaturePrefix = "X " + personName + #endif + + group.set(font: signaturePrefixFont) + group.set(textColor: signatureColor) + group.add(PDFGroupContainer.left, text: signaturePrefix) + group.addLineSeparator(style: PDFLineStyle(color: .black)) + + group.set(font: nameFont) + group.add(PDFGroupContainer.left, text: personName) + return group + } - /// Exports the signed consent form as a `PDFDocument` via the SwiftUI `ImageRenderer`. + /// 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 getPDFFormat(paperSize: ExportConfiguration.PaperSize) -> PDFPageFormat { + switch paperSize { + case .dinA4: + return PDFPageFormat.a4 + case .usLetter: + return PDFPageFormat.usLetter + } + } + + /// 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() + func export() async -> PDFKit.PDFDocument? { + // swiftlint:disable:all + let markdown = await asyncMarkdown() let markdownString = (try? AttributedString( markdown: markdown, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) )) ?? AttributedString(String(localized: "MARKDOWN_LOADING_ERROR", bundle: .module)) - - let pages = paginatedViews(markdown: markdownString) - - let paperSize = CGSize( - width: exportConfiguration.paperSize.dimensions.width, - height: exportConfiguration.paperSize.dimensions.height - ) - - guard let mutableData = CFDataCreateMutable(kCFAllocatorDefault, 0), - let consumer = CGDataConsumer(data: mutableData), - let pdf = CGContext(consumer: consumer, mediaBox: nil, nil) else { - return nil - } - - for page in pages { - let renderer = ImageRenderer(content: page) - - renderer.render { _, context in - pdf.beginPDFPage(nil) - pdf.translateBy(x: 0, y: 0) - - context(pdf) - - pdf.endPDFPage() - } - } - - pdf.closePDF() - return PDFDocument(data: mutableData as Data) - } - - private func paginatedViews(markdown: AttributedString) -> [AnyView] { - /* - This algorithm splits the consent document consisting of title, text and signature - across multiple pages, if titleHeight + signatureHeight + textHeight is larger than 1 page. - Let header = title, and footer = signature. + let document = TPPDF.PDFDocument(format: getPDFFormat(paperSize: exportConfiguration.paperSize)) - The algorithm ensures that headerHeight + footerHeight + textHeight <= pageHeight. - headerHeight is set to 200 on the first page, and to 50 on all subsequent pages (as we do not have a title anymore). - footerHeight is always constant at 150 on each page, even if there is no footer, because we need the footerHeight - to determine if we are actually on the last page (check improvements below). - Tested for 1, 2 and 3 pages for dinA4 and usLetter size documents. - - Possible improvements: - * The header height on the first page should not be hardcoded to 200, but calculated from - VStack consisting of export tag + title; if there is no export tag, the headerHeight can be smaller. - * The footerHeight could/should only be set for the last page. However, the algorithm then becomes more complicated: To know if we are on the last page, we check if headerHeight + footerHeight + textHeight <= pageHeight. If footerHeight is 0 we have more space for the text. - If we then find out that we are actually on the last page, we would have to set footerHeight to 150 and thus we have less space for the text. Thus, it could happen that know we are not on the last page anymore but need one extra page. - - Known problems: - * If we assume headerHeight too small (e.g., 100), then truncation happens. - */ - var pages = [AnyView]() - var remainingMarkdown = markdown - let pageSize = CGSize(width: exportConfiguration.paperSize.dimensions.width, height: exportConfiguration.paperSize.dimensions.height) - // Maximum header height on the first page, i.e., size of - // the VStack containing the export tag + title. - // Better calculate this instead of hardcoding. - let headerHeightFirstPage: CGFloat = 200 - // Header height on all subsequent pages. Should come from exportConfiguration. - let headerHeightOtherPages: CGFloat = 50 - let footerHeight: CGFloat = 150 - var headerHeight = headerHeightFirstPage - - while !remainingMarkdown.unicodeScalars.isEmpty { - let (currentPageContent, nextPageContent) = - split(markdown: remainingMarkdown, pageSize: pageSize, headerHeight: headerHeight, footerHeight: footerHeight) - - // In the first iteration, headerHeight was headerHeightFirstPage. - // In all subsequent iterations, we only need headerHeightOtherPages. - // Hence, more text fits on the page. - headerHeight = headerHeightOtherPages - - let currentPage = AnyView( - VStack { - if pages.isEmpty { // First page - renderTitle(headerHeight: headerHeight) - } else { - // If we are not on the first page, we add a spacing of headerHeight, - // which should now be set to "headerHeightOtherPages". - VStack { - }.padding(.top, headerHeight) - } - - Text(currentPageContent) - .padding() - - Spacer() - - if nextPageContent.unicodeScalars.isEmpty { // Last page - renderSignature(footerHeight: footerHeight) - } - } - .frame(width: pageSize.width, height: pageSize.height) - ) - pages.append(currentPage) - remainingMarkdown = nextPageContent + if exportConfiguration.includingTimestamp { + document.add(.contentRight, attributedTextObject: exportTimeStamp()) } - return pages - } - - private func split( - markdown: AttributedString, - pageSize: CGSize, - headerHeight: CGFloat, - footerHeight: CGFloat - ) -> (AttributedString, AttributedString) { - // This algorithm determines at which index to split the text, if textHeight + headerHeight + footerHeight > pageSize. - // The algorithm returns the text that still fits on the current page, - // and the remaining text which needs to be placed on subsequent page(s). - // If remaining == 0, this means we have reached the last page, as all remaining text - // can fit on the current page. - - // The algorithm works by creating a virtual text storage container with width = pageSize.width - // and height = pageSize.height - footerHeight - headerHeight. - // We can then ask "how much text fits in this virtual text storage container" by checking it's - // glyphRange. The glyphRange tells us how many characters of the given text fit into the text container. - // Specifically, glyphRange is exactly the index AFTER the last word (not character) that STILL FITS on the page. - // We can then split the text as follows: - // let index = container.glyphRange // Index after last word which still fits on the page. - // currentPage = markdown[0:index] - // remaining = markdown[index:] - - let contentHeight = pageSize.height - headerHeight - footerHeight - var currentPage = AttributedString() - var remaining = markdown - - let textStorage = NSTextStorage(attributedString: NSAttributedString(markdown)) - let layoutManager = NSLayoutManager() - let textContainer = NSTextContainer(size: CGSize(width: pageSize.width, height: contentHeight)) - layoutManager.addTextContainer(textContainer) - textStorage.addLayoutManager(layoutManager) - - let maximumRange = layoutManager.glyphRange(for: textContainer) - - currentPage = AttributedString(textStorage.attributedSubstring(from: maximumRange)) - remaining = AttributedString( - textStorage.attributedSubstring( - from: NSRange( - location: maximumRange.length, - length: textStorage.length - maximumRange.length - ) - ) - ) - - return (currentPage, remaining) - } - - // Creates a View for the title, which can then be rendered during PDF export. - private func renderTitle(headerHeight: CGFloat) -> 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() + + document.add(.contentCenter, attributedTextObject: exportHeader()) + document.add(attributedText: NSAttributedString(markdownString)) + document.add(group: exportSignature()) + + // Convert TPPDF.PDFDocument to PDFKit.PDFDocument + let generator = PDFGenerator(document: document) + + if let data = try? generator.generateData() { + if let pdfKitDocument = PDFKit.PDFDocument(data: data) { + return pdfKitDocument + } else { + return nil } - OnboardingTitleView(title: exportConfiguration.consentTitle) - } - .padding() - } - - // Creates a View for the signature, which can then be rendered during PDF export. - private func renderSignature(footerHeight: CGFloat) -> some View { - 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 + } else { + return nil } - .padding(.bottom, footerHeight) } } From 23e533390a40a534345668356bc1e408a46207c0 Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Thu, 11 Jul 2024 16:17:57 +0200 Subject: [PATCH 06/29] Changed personName -> signature. --- .../ConsentView/ConsentDocument+Export.swift | 3 +-- Tests/UITests/UITests.xcodeproj/project.pbxproj | 9 +++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index 56b59c8..0b45fb8 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -96,7 +96,6 @@ extension ConsentDocument { let personName = name.formatted(.name(style: .long)) #if !os(macOS) - let group = PDFGroup( allowsBreaks: false, backgroundImage: PDFImage(image: blackInkSignatureImage), @@ -119,7 +118,7 @@ extension ConsentDocument { let signaturePrefixFont = NSFont.preferredFont(forTextStyle: .title2) let nameFont = NSFont.preferredFont(forTextStyle: .subheadline) let signatureColor = NSColor.secondaryLabelColor - let signaturePrefix = "X " + personName + let signaturePrefix = "X " + signature #endif group.set(font: signaturePrefixFont) diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 97e7633..6838380 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -12,8 +12,8 @@ 2F61BDCB29DDE76D00D71D33 /* SpeziOnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F61BDCA29DDE76D00D71D33 /* SpeziOnboardingTests.swift */; }; 2F6D139A28F5F386007C25D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */; }; - 61D77B542BC83F0100E3165F /* OnboardingCustomToggleTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D77B532BC83F0100E3165F /* OnboardingCustomToggleTestView.swift */; }; 61040A1D2BAFA2F600EDD4EC /* OnboardingIdentifiableTestViewCustom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61040A1B2BAFA2F600EDD4EC /* OnboardingIdentifiableTestViewCustom.swift */; }; + 61D77B542BC83F0100E3165F /* OnboardingCustomToggleTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D77B532BC83F0100E3165F /* OnboardingCustomToggleTestView.swift */; }; 61F1697E2BCA888600D1622B /* OnboardingTestViewNotIdentifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F1697D2BCA888600D1622B /* OnboardingTestViewNotIdentifiable.swift */; }; 970D444B2A6F031200756FE2 /* OnboardingConsentMarkdownTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970D444A2A6F031200756FE2 /* OnboardingConsentMarkdownTestView.swift */; }; 970D444F2A6F048A00756FE2 /* OnboardingWelcomeTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970D444E2A6F048A00756FE2 /* OnboardingWelcomeTestView.swift */; }; @@ -49,8 +49,8 @@ 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestApp.swift; sourceTree = ""; }; 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; - 61D77B532BC83F0100E3165F /* OnboardingCustomToggleTestView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingCustomToggleTestView.swift; sourceTree = ""; }; 61040A1B2BAFA2F600EDD4EC /* OnboardingIdentifiableTestViewCustom.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingIdentifiableTestViewCustom.swift; sourceTree = ""; }; + 61D77B532BC83F0100E3165F /* OnboardingCustomToggleTestView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingCustomToggleTestView.swift; sourceTree = ""; }; 61F1697D2BCA888600D1622B /* OnboardingTestViewNotIdentifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTestViewNotIdentifiable.swift; sourceTree = ""; }; 970D444A2A6F031200756FE2 /* OnboardingConsentMarkdownTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingConsentMarkdownTestView.swift; sourceTree = ""; }; 970D444E2A6F048A00756FE2 /* OnboardingWelcomeTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWelcomeTestView.swift; sourceTree = ""; }; @@ -458,10 +458,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; @@ -477,6 +477,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; From a947d7d76b905ae55224f75fce1a1e94d24468d2 Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Thu, 11 Jul 2024 16:23:30 +0200 Subject: [PATCH 07/29] Reverted changes. --- Tests/UITests/UITests.xcodeproj/project.pbxproj | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 6838380..97e7633 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -12,8 +12,8 @@ 2F61BDCB29DDE76D00D71D33 /* SpeziOnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F61BDCA29DDE76D00D71D33 /* SpeziOnboardingTests.swift */; }; 2F6D139A28F5F386007C25D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */; }; - 61040A1D2BAFA2F600EDD4EC /* OnboardingIdentifiableTestViewCustom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61040A1B2BAFA2F600EDD4EC /* OnboardingIdentifiableTestViewCustom.swift */; }; 61D77B542BC83F0100E3165F /* OnboardingCustomToggleTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D77B532BC83F0100E3165F /* OnboardingCustomToggleTestView.swift */; }; + 61040A1D2BAFA2F600EDD4EC /* OnboardingIdentifiableTestViewCustom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61040A1B2BAFA2F600EDD4EC /* OnboardingIdentifiableTestViewCustom.swift */; }; 61F1697E2BCA888600D1622B /* OnboardingTestViewNotIdentifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F1697D2BCA888600D1622B /* OnboardingTestViewNotIdentifiable.swift */; }; 970D444B2A6F031200756FE2 /* OnboardingConsentMarkdownTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970D444A2A6F031200756FE2 /* OnboardingConsentMarkdownTestView.swift */; }; 970D444F2A6F048A00756FE2 /* OnboardingWelcomeTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970D444E2A6F048A00756FE2 /* OnboardingWelcomeTestView.swift */; }; @@ -49,8 +49,8 @@ 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestApp.swift; sourceTree = ""; }; 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; - 61040A1B2BAFA2F600EDD4EC /* OnboardingIdentifiableTestViewCustom.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingIdentifiableTestViewCustom.swift; sourceTree = ""; }; 61D77B532BC83F0100E3165F /* OnboardingCustomToggleTestView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingCustomToggleTestView.swift; sourceTree = ""; }; + 61040A1B2BAFA2F600EDD4EC /* OnboardingIdentifiableTestViewCustom.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingIdentifiableTestViewCustom.swift; sourceTree = ""; }; 61F1697D2BCA888600D1622B /* OnboardingTestViewNotIdentifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTestViewNotIdentifiable.swift; sourceTree = ""; }; 970D444A2A6F031200756FE2 /* OnboardingConsentMarkdownTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingConsentMarkdownTestView.swift; sourceTree = ""; }; 970D444E2A6F048A00756FE2 /* OnboardingWelcomeTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWelcomeTestView.swift; sourceTree = ""; }; @@ -458,10 +458,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 637867499T; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -477,7 +477,6 @@ 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; From 5fbc2a7cb85604a438a5ff474211dc159ad6c5fd Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Fri, 12 Jul 2024 07:39:50 +0200 Subject: [PATCH 08/29] Update iPad Testing Identifier. --- .github/workflows/build-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index db6c4dd..9a1d309 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -107,7 +107,7 @@ jobs: runsonlabels: '["macOS", "self-hosted"]' path: 'Tests/UITests' scheme: TestApp - destination: 'platform=iOS Simulator,name=iPad Air (5th generation)' + destination: 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' buildConfig: ${{ matrix.buildConfig }} resultBundle: ${{ matrix.resultBundle }} artifactname: ${{ matrix.artifactname }} From 8ec56a28f8a0a02ea4258c3fe76ba143c073c5d2 Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Fri, 2 Aug 2024 21:53:48 -0700 Subject: [PATCH 09/29] Update Tests and try Beta 4 --- .../TestAppUITests/SpeziOnboardingTests.swift | 107 ++++++------------ 1 file changed, 32 insertions(+), 75 deletions(-) diff --git a/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift b/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift index 0cc1e14..f5c92f7 100644 --- a/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift @@ -256,11 +256,9 @@ final class OnboardingTests: XCTestCase { // swiftlint:disable:this type_body_le } #if !os(macOS) // Only test export on non macOS platforms - func testOnboardingConsentPDFExport() throws { // swiftlint:disable:this function_body_length + @MainActor + func testOnboardingConsentPDFExport() async throws { let app = XCUIApplication() - let filesApp = XCUIApplication(bundleIdentifier: "com.apple.DocumentsApp") - let maxRetries = 10 - app.launch() XCTAssert(app.buttons["Consent View (Markdown)"].waitForExistence(timeout: 2)) @@ -280,83 +278,42 @@ final class OnboardingTests: XCTestCase { // swiftlint:disable:this type_body_le XCTAssert(app.scrollViews["Signature Field"].waitForExistence(timeout: 2)) app.scrollViews["Signature Field"].swipeRight() - sleep(1) - - for _ in 0...maxRetries { - // Export consent form via share sheet button - XCTAssert(app.buttons["Share consent form"].waitForExistence(timeout: 2)) - 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 - 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 - sleep(3) - XCTAssert(app.buttons["Save"].waitForExistence(timeout: 2)) - app.buttons["Save"].tap() - sleep(10) // Wait until file is saved - - if app.staticTexts["Replace Existing Items?"].waitForExistence(timeout: 5) { - XCTAssert(app.buttons["Replace"].waitForExistence(timeout: 2)) - app.buttons["Replace"].tap() - sleep(3) // Wait until file is saved - } - - // Wait until share sheet closed and back on the consent form screen - XCTAssert(app.staticTexts["Consent"].waitForExistence(timeout: 10)) - - XCUIDevice.shared.press(.home) - - // Launch the Files app - filesApp.launch() - - // Handle already open files - if filesApp.buttons["Done"].waitForExistence(timeout: 2) { - filesApp.buttons["Done"].tap() - } - - // Check if file exists - If not, try the export procedure again - // Saving to files is very flakey on the runners, needs multiple attempts to succeed - if filesApp.staticTexts["Signed Consent Form"].waitForExistence(timeout: 2) { - break - } - - // Launch test app and try another export - app.launch() - } - - // Open File - XCTAssert(filesApp.staticTexts["Signed Consent Form"].waitForExistence(timeout: 2)) - XCTAssert(filesApp.collectionViews["File View"].cells["Signed Consent Form, pdf"].waitForExistence(timeout: 2)) - - XCTAssert(filesApp.collectionViews["File View"].cells["Signed Consent Form, pdf"].images.firstMatch.waitForExistence(timeout: 2)) - filesApp.collectionViews["File View"].cells["Signed Consent Form, pdf"].images.firstMatch.tap() - - sleep(3) // Wait until file is opened - + try await Task.sleep(for: .seconds(1)) + // Export consent form via share sheet button + XCTAssert(app.buttons["Share consent form"].waitForExistence(timeout: 2)) + app.buttons["Share consent form"].tap() + try await Task.sleep(for: .seconds(5)) + + // Store exported consent form in Files #if os(visionOS) - let fileView = XCUIApplication(bundleIdentifier: "com.apple.MRQuickLook") + // on visionOS the save to files button has no label + XCTAssert(app.cells["XCElementSnapshotPrivilegedValuePlaceholder"].waitForExistence(timeout: 10)) + app.cells["XCElementSnapshotPrivilegedValuePlaceholder"].tap() #else - let fileView = filesApp + XCTAssert(app.staticTexts["Save to Files"].waitForExistence(timeout: 10)) + app.staticTexts["Save to Files"].tap() #endif - - // Check if PDF contains consent title, name, and markdown message - for searchString in ["Spezi Consent", "This is a markdown example", "Leland Stanford"] { - let predicate = NSPredicate(format: "label CONTAINS[c] %@", searchString) - XCTAssert(fileView.otherElements.containing(predicate).firstMatch.waitForExistence(timeout: 2)) + try await Task.sleep(for: .seconds(5)) + + XCTAssert(app.buttons["Save"].waitForExistence(timeout: 10)) + app.buttons["Save"].tap() + try await Task.sleep(for: .seconds(3)) + + if app.staticTexts["Replace Existing Items?"].waitForExistence(timeout: 5) { + XCTAssert(app.buttons["Replace"].waitForExistence(timeout: 2)) + app.buttons["Replace"].tap() } + + try await Task.sleep(for: .seconds(10)) - #if os(iOS) - // Close File - XCTAssert(fileView.buttons["Done"].waitForExistence(timeout: 2)) - fileView.buttons["Done"].tap() - #endif + // Wait until share sheet closed and back on the consent form screen + XCTAssert(app.staticTexts["Consent"].waitForExistence(timeout: 20)) + + XCTAssert(app.buttons["I Consent"].waitForExistence(timeout: 2)) + app.buttons["I Consent"].tap() + + XCTAssert(app.staticTexts["Consent PDF rendering exists"].waitForExistence(timeout: 2)) } #endif From 5238a7b2e6d7f5bd74431c953718e3767e75fe3a Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Fri, 2 Aug 2024 22:18:36 -0700 Subject: [PATCH 10/29] Update Tests --- .../TestAppUITests/SpeziOnboardingTests.swift | 26 +++---------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift b/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift index f5c92f7..664225b 100644 --- a/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift @@ -285,35 +285,15 @@ final class OnboardingTests: XCTestCase { // swiftlint:disable:this type_body_le app.buttons["Share consent form"].tap() try await Task.sleep(for: .seconds(5)) - // Store exported consent form in Files + // Store exported consent form in Files. + // We stop here as don't want to test the iOS Files Sheet behavior as it changes across iOS Versions and + // Apple doesn't have a great API to test UI tests for share sheets ... #if os(visionOS) // on visionOS the save to files button has no label 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 - try await Task.sleep(for: .seconds(5)) - - XCTAssert(app.buttons["Save"].waitForExistence(timeout: 10)) - app.buttons["Save"].tap() - try await Task.sleep(for: .seconds(3)) - - if app.staticTexts["Replace Existing Items?"].waitForExistence(timeout: 5) { - XCTAssert(app.buttons["Replace"].waitForExistence(timeout: 2)) - app.buttons["Replace"].tap() - } - - try await Task.sleep(for: .seconds(10)) - - // Wait until share sheet closed and back on the consent form screen - XCTAssert(app.staticTexts["Consent"].waitForExistence(timeout: 20)) - - XCTAssert(app.buttons["I Consent"].waitForExistence(timeout: 2)) - app.buttons["I Consent"].tap() - - XCTAssert(app.staticTexts["Consent PDF rendering exists"].waitForExistence(timeout: 2)) } #endif From a417fa9a62f336e019553cee3e885d25b7cc8fe7 Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Fri, 2 Aug 2024 22:53:08 -0700 Subject: [PATCH 11/29] Update GitHub Action --- .github/workflows/build-and-test.yml | 42 ++++------------------------ 1 file changed, 6 insertions(+), 36 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 9a1d309..8b986db 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -75,62 +75,32 @@ 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"]' path: 'Tests/UITests' scheme: TestApp - buildConfig: ${{ matrix.buildConfig }} - resultBundle: ${{ matrix.resultBundle }} - artifactname: ${{ matrix.artifactname }} + resultBundle: TestApp-iOS.xcresult + artifactname: TestApp-iOS.xcresult buildandtestuitests_ipad: name: Build and Test UI Tests iPadOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - strategy: - matrix: - include: - - buildConfig: Debug - resultBundle: TestApp-iPad.xcresult - artifactname: TestApp-iPad.xcresult - - buildConfig: Release - resultBundle: TestApp-iPad-Release.xcresult - artifactname: TestApp-iPad-Release.xcresult with: runsonlabels: '["macOS", "self-hosted"]' path: 'Tests/UITests' scheme: TestApp destination: 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' - buildConfig: ${{ matrix.buildConfig }} - resultBundle: ${{ matrix.resultBundle }} - artifactname: ${{ matrix.artifactname }} + resultBundle: TestApp-iPad.xcresult + artifactname: TestApp-iPad.xcresult 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"]' 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] From a11df7100ab80f703e6edd9e86adf2a1f3f7be1a Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Wed, 14 Aug 2024 15:04:36 +0200 Subject: [PATCH 12/29] Separated PDF export functionality from ConsentDocument. Added type ConsentDocumentViewModel, which holds all data necessary for exporting a PDF, i.e., the markdown text and the export configuration. ConsentDocument.export() now uses the appropriate export functions of ConsentDocumentViewModel. Added unit test for PDF export. --- Package.swift | 14 +- .../ConsentView/ConsentDocument+Export.swift | 142 +---------- .../ConsentView/ConsentDocument.swift | 13 +- .../ConsentDocumentViewModel+Export.swift | 228 ++++++++++++++++++ .../ConsentDocumentViewModel.swift | 29 +++ .../ExportConfiguration+PDFPageFormat.swift | 28 +++ .../SpeziOnboardingTests.swift | 59 +++++ 7 files changed, 363 insertions(+), 150 deletions(-) create mode 100644 Sources/SpeziOnboarding/ConsentView/ConsentDocumentViewModel+Export.swift create mode 100644 Sources/SpeziOnboarding/ConsentView/ConsentDocumentViewModel.swift create mode 100644 Sources/SpeziOnboarding/ConsentView/ExportConfiguration+PDFPageFormat.swift diff --git a/Package.swift b/Package.swift index e7eb079..4d13747 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,14 +33,9 @@ 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"), -<<<<<<< HEAD .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0"), .package(url: "https://github.com/techprimate/TPPDF", from: "2.6.0") - ], -======= - .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0") ] + swiftLintPackage(), ->>>>>>> upstream/main targets: [ .target( name: "SpeziOnboarding", @@ -48,18 +43,13 @@ let package = Package( .product(name: "Spezi", package: "Spezi"), .product(name: "SpeziViews", package: "SpeziViews"), .product(name: "SpeziPersonalInfo", package: "SpeziViews"), -<<<<<<< HEAD .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "TPPDF", package: "TPPDF") - ] -======= - .product(name: "OrderedCollections", package: "swift-collections") ], swiftSettings: [ swiftConcurrency ], plugins: [] + swiftLintPlugin() ->>>>>>> upstream/main ), .testTarget( name: "SpeziOnboardingTests", diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index 15d29ba..2dfad34 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -43,109 +43,6 @@ extension ConsentDocument { } #endif - /// Generates a `PDFAttributedText` containing the timestamp of when the PDF was exported. - /// - /// - Returns: A TPPDF `PDFAttributedText` representation of the export time stamp. - func exportTimeStamp() -> PDFAttributedText { - let stampText = String(localized: "EXPORTED_TAG", bundle: .module) + ": " + - DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short) + "\n\n\n\n" - - #if !os(macOS) - let font = UIFont.preferredFont(forTextStyle: .caption1) - #else - let font = NSFont.preferredFont(forTextStyle: .caption1) - #endif - - let attributedTitle = NSMutableAttributedString( - string: stampText, - attributes: [ - NSAttributedString.Key.font: font - ] - ) - - 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. - func exportHeader() -> PDFAttributedText { - #if !os(macOS) - let largeTitleFont = UIFont.preferredFont(forTextStyle: .largeTitle) - let boldLargeTitleFont = UIFont.boldSystemFont(ofSize: largeTitleFont.pointSize) - #else - let largeTitleFont = NSFont.preferredFont(forTextStyle: .largeTitle) - let boldLargeTitleFont = NSFont.boldSystemFont(ofSize: largeTitleFont.pointSize) - #endif - - let attributedTitle = NSMutableAttributedString( - string: exportConfiguration.consentTitle.localizedString() + "\n\n", - attributes: [ - NSAttributedString.Key.font: boldLargeTitleFont - ] - ) - - return PDFAttributedText(text: attributedTitle) - } - /// 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. - func exportSignature() -> PDFGroup { - let personName = name.formatted(.name(style: .long)) - - #if !os(macOS) - let group = PDFGroup( - allowsBreaks: false, - backgroundImage: PDFImage(image: blackInkSignatureImage), - padding: EdgeInsets(top: 50, left: 50, bottom: 0, right: 100) - ) - - let signaturePrefixFont = UIFont.preferredFont(forTextStyle: .title2) - let nameFont = UIFont.preferredFont(forTextStyle: .subheadline) - let signatureColor = UIColor.secondaryLabel - let signaturePrefix = "X" - #else - // On macOS, we do not have a "drawn" signature, hence 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 signaturePrefixFont = NSFont.preferredFont(forTextStyle: .title2) - let nameFont = NSFont.preferredFont(forTextStyle: .subheadline) - let signatureColor = NSColor.secondaryLabelColor - let signaturePrefix = "X " + signature - #endif - - group.set(font: signaturePrefixFont) - group.set(textColor: signatureColor) - group.add(PDFGroupContainer.left, text: signaturePrefix) - - group.addLineSeparator(style: PDFLineStyle(color: .black)) - - group.set(font: nameFont) - group.add(PDFGroupContainer.left, text: personName) - return group - } - - /// 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 getPDFFormat(paperSize: ExportConfiguration.PaperSize) -> PDFPageFormat { - switch paperSize { - case .dinA4: - return PDFPageFormat.a4 - case .usLetter: - return PDFPageFormat.usLetter - } - } - /// 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``. @@ -153,35 +50,14 @@ extension ConsentDocument { /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` @MainActor func export() async -> PDFKit.PDFDocument? { - // swiftlint:disable:all - - let markdown = await asyncMarkdown() - let markdownString = (try? AttributedString( - markdown: markdown, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - )) ?? AttributedString(String(localized: "MARKDOWN_LOADING_ERROR", bundle: .module)) - - let document = TPPDF.PDFDocument(format: getPDFFormat(paperSize: exportConfiguration.paperSize)) - - if exportConfiguration.includingTimestamp { - document.add(.contentRight, attributedTextObject: exportTimeStamp()) - } - - document.add(.contentCenter, attributedTextObject: exportHeader()) - document.add(attributedText: NSAttributedString(markdownString)) - document.add(group: exportSignature()) - - // Convert TPPDF.PDFDocument to PDFKit.PDFDocument - let generator = PDFGenerator(document: document) - - if let data = try? generator.generateData() { - if let pdfKitDocument = PDFKit.PDFDocument(data: data) { - return pdfKitDocument - } else { - return nil - } - } else { - return nil - } + let personName = name.formatted(.name(style: .long)) + + #if !os(macOS) + return await viewModel.export( + personName: personName, signatureImage: blackInkSignatureImage + ) + #else + return await viewModel.export(personName: personName) + #endif } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift index f40b0c0..cd21907 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 viewModel: ConsentDocumentViewModel @Environment(\.colorScheme) var colorScheme @State var name = PersonNameComponents() @@ -134,7 +134,7 @@ public struct ConsentDocument: View { public var body: some View { VStack { - MarkdownView(asyncMarkdown: asyncMarkdown, state: $viewState.base) + MarkdownView(asyncMarkdown: viewModel.asyncMarkdown, state: $viewState.base) Spacer() Group { nameView @@ -203,13 +203,16 @@ public struct ConsentDocument: View { familyNamePlaceholder: LocalizedStringResource = LocalizationDefaults.familyNamePlaceholder, exportConfiguration: ExportConfiguration = .init() ) { - self.asyncMarkdown = markdown self._viewState = viewState self.givenNameTitle = givenNameTitle self.givenNamePlaceholder = givenNamePlaceholder self.familyNameTitle = familyNameTitle self.familyNamePlaceholder = familyNamePlaceholder - self.exportConfiguration = exportConfiguration + + self.viewModel = ConsentDocumentViewModel( + markdown: markdown, + exportConfiguration: exportConfiguration + ) } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentViewModel+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentViewModel+Export.swift new file mode 100644 index 0000000..73c92d8 --- /dev/null +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentViewModel+Export.swift @@ -0,0 +1,228 @@ +// +// 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 `ConsentDocumentViewModel` enabling the export of the signed consent page. +extension ConsentDocumentViewModel { + /// 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" + + #if !os(macOS) + let font = UIFont.preferredFont(forTextStyle: .caption1) + #else + let font = NSFont.preferredFont(forTextStyle: .caption1) + #endif + + let attributedTitle = NSMutableAttributedString( + string: stampText, + attributes: [ + NSAttributedString.Key.font: font + ] + ) + + 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 { + #if !os(macOS) + let largeTitleFont = UIFont.preferredFont(forTextStyle: .largeTitle) + let boldLargeTitleFont = UIFont.boldSystemFont(ofSize: largeTitleFont.pointSize) + #else + let largeTitleFont = NSFont.preferredFont(forTextStyle: .largeTitle) + let boldLargeTitleFont = NSFont.boldSystemFont(ofSize: largeTitleFont.pointSize) + #endif + + let attributedTitle = NSMutableAttributedString( + string: exportConfiguration.consentTitle.localizedString() + "\n\n", + attributes: [ + NSAttributedString.Key.font: boldLargeTitleFont + ] + ) + + 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() + let markdownString = (try? AttributedString( + markdown: markdown, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(String(localized: "MARKDOWN_LOADING_ERROR", bundle: .module)) + + return PDFAttributedText(text: NSAttributedString(markdownString)) + } + + #if !os(macOS) + /// 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. + /// + /// - Parameters: + /// - personName: A string containing the name of the person who signed the document. + /// - signatureImage: Signature drawn when signing the document. + /// - Returns: A TPPDF `PDFAttributedText` representation of the export time stamp. + @MainActor + private func exportSignature(personName: String, signatureImage: UIImage) -> PDFGroup { + let group = PDFGroup( + allowsBreaks: false, + backgroundImage: PDFImage(image: signatureImage), + padding: EdgeInsets(top: 50, left: 50, bottom: 0, right: 100) + ) + + let signaturePrefixFont = UIFont.preferredFont(forTextStyle: .title2) + let nameFont = UIFont.preferredFont(forTextStyle: .subheadline) + let signatureColor = UIColor.secondaryLabel + let signaturePrefix = "X" + + group.set(font: signaturePrefixFont) + group.set(textColor: signatureColor) + group.add(PDFGroupContainer.left, text: signaturePrefix) + + group.addLineSeparator(style: PDFLineStyle(color: .black)) + + group.set(font: nameFont) + group.add(PDFGroupContainer.left, text: personName) + return group + } + #else + /// 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. + /// + /// - Parameters: + /// - personName: A string containing the name of the person who signed the document. + /// - Returns: A TPPDF `PDFAttributedText` representation of the export time stamp. + @MainActor + private func exportSignature(personName: String) -> PDFGroup { + // On macOS, we do not have a "drawn" signature, hence 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 signaturePrefixFont = NSFont.preferredFont(forTextStyle: .title2) + let nameFont = NSFont.preferredFont(forTextStyle: .subheadline) + let signatureColor = NSColor.secondaryLabelColor + let signaturePrefix = "X " + signature + + group.set(font: signaturePrefixFont) + group.set(textColor: signatureColor) + group.add(PDFGroupContainer.left, text: signaturePrefix) + + group.addLineSeparator(style: PDFLineStyle(color: .black)) + + group.set(font: nameFont) + group.add(PDFGroupContainer.left, text: personName) + return group + } + #endif + + /// 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 -> 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) + + if let data = try? generator.generateData() { + if let pdfKitDocument = PDFKit.PDFDocument(data: data) { + return pdfKitDocument + } else { + return nil + } + } else { + return nil + } + } + + #if !os(macOS) + /// 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``. + /// + /// - Parameters: + /// - personName: A string containing the name of the person who signed the document. + /// - signatureImage: Signature drawn when signing the document. + /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` + @MainActor + public func export(personName: String, signatureImage: UIImage) async -> PDFKit.PDFDocument? { + let exportTimeStamp = exportConfiguration.includingTimestamp ? exportTimeStamp() : nil + let header = exportHeader() + let pdfTextContent = await exportDocumentContent() + let signature = exportSignature(personName: personName, signatureImage: signatureImage) + + return await createDocument( + header: header, + pdfTextContent: pdfTextContent, + signatureFooter: signature, + exportTimeStamp: exportTimeStamp + ) + } + #else + /// 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``. + /// + /// - Parameters: + /// - personName: A string containing the name of the person who signed the document. + /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` + @MainActor + public func export(personName: String) async -> PDFKit.PDFDocument? { + let exportTimeStamp = exportConfiguration.includingTimestamp ? exportTimeStamp() : nil + let header = exportHeader() + let pdfTextContent = await exportDocumentContent() + let signature = exportSignature(personName: personName) + + return await exportDocument( + header: header, + pdfTextContent: pdfTextContent, + signatureFooter: signature, + exportTimeStamp: exportTimeStamp + ) + } + #endif +} diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentViewModel.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentViewModel.swift new file mode 100644 index 0000000..5173f78 --- /dev/null +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentViewModel.swift @@ -0,0 +1,29 @@ +// +// 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 +import TPPDF + +/// A type holding all information required for exporting a PDF from a `ConsentDocumentVIew`, such as the PDF content (i.e., markdown string) and the ExportConfiguration. +public struct ConsentDocumentViewModel { + let asyncMarkdown: () async -> Data + let exportConfiguration: ConsentDocument.ExportConfiguration + + /// Creates a `ConsentDocumentViewModel` which holds information related to the PDF export of a `ConsentView`. + /// + /// - Parameters: + /// - markdown: Markdown string of the consent document. + /// - exportConfiguration: An `ExportConfiguration` defining properties of the PDF exported from the `ConsentDocument`. + public init( + markdown: @escaping () async -> Data, + exportConfiguration: ConsentDocument.ExportConfiguration + ) { + self.asyncMarkdown = markdown + self.exportConfiguration = exportConfiguration + } +} 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/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift index 5613633..462b4af 100644 --- a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift @@ -7,6 +7,7 @@ // @testable import SpeziOnboarding +import PDFKit import SwiftUI import XCTest @@ -38,4 +39,62 @@ final class SpeziOnboardingTests: XCTestCase { let final = hasher.finalize() XCTAssertEqual(identifier.identifierHash, final) } + + @MainActor + func testPDFExport() async throws { + let markdownData = { + Data("This is a *markdown* **example**".utf8) + } + + let exportConfiguration = ConsentDocument.ExportConfiguration( + paperSize: .dinA4, + consentTitle: "Spezi Onboarding", + includingTimestamp: true + ) + let viewModel = ConsentDocumentViewModel(markdown: markdownData, exportConfiguration: exportConfiguration) + + + let bundle = Bundle.module // Access the test bundle + + #if !os(macOS) + guard let url = bundle.url(forResource: "known_good_pdf", withExtension: "pdf") else { + XCTFail("Failed to locate known_good.pdf in resources.") + return + } + #else + guard let url = bundle.url(forResource: "known_good_pdf_macos", withExtension: "pdf") else { + XCTFail("Failed to locate known_good.pdf in resources.") + return + } + #endif + + guard let knownGoodPdf = PDFDocument(url: url) else { + XCTFail("Failed to load known good PDF from resources.") + return + } + + #if !os(macOS) + if let pdf = await viewModel.export(personName: "Leland Stanford", signatureImage: .init()) { + XCTAssert(comparePDFDocuments(doc1: pdf, doc2: knownGoodPdf)) + } else { + XCTFail("Failed to export PDF from ConsentDocumentViewModel.") + } + #else + if let pdf = await viewModel.export(personName: "Leland Stanford") { + XCTAssert(comparePDFDocuments(doc1: pdf, doc2: knownGoodPdf)) + } else { + XCTFail("Failed to export PDF from ConsentDocumentViewModel.") + } + #endif + } + + private func comparePDFDocuments(doc1: PDFDocument, doc2: PDFDocument) -> Bool { + + guard let data1 = doc1.dataRepresentation(), + let data2 = doc2.dataRepresentation() else { + return false + } + + return data1 == data2 + } } From 2d793db927b47a901ce5b91a3d14e959aee02758 Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Wed, 14 Aug 2024 18:25:10 +0200 Subject: [PATCH 13/29] Added known good PDF files for iOS, macOS and visionOS, which is used in the unit test "testPDFExport" to verify that the PDF document is generated correctly. --- Package.swift | 3 + .../ConsentView/ConsentDocument.swift | 4 +- ...wift => ConsentDocumentModel+Export.swift} | 4 +- ...Model.swift => ConsentDocumentModel.swift} | 4 +- .../SpeziOnboardingTests.swift | 76 +++++++++++++------ 5 files changed, 61 insertions(+), 30 deletions(-) rename Sources/SpeziOnboarding/ConsentView/{ConsentDocumentViewModel+Export.swift => ConsentDocumentModel+Export.swift} (98%) rename Sources/SpeziOnboarding/ConsentView/{ConsentDocumentViewModel.swift => ConsentDocumentModel.swift} (86%) diff --git a/Package.swift b/Package.swift index 4d13747..5438fb4 100644 --- a/Package.swift +++ b/Package.swift @@ -56,6 +56,9 @@ let package = Package( dependencies: [ .target(name: "SpeziOnboarding") ], + resources: [ + .process("Resources/") + ], swiftSettings: [ swiftConcurrency ], diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift index cd21907..be6ddb1 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift @@ -46,7 +46,7 @@ public struct ConsentDocument: View { private let familyNameTitle: LocalizedStringResource private let familyNamePlaceholder: LocalizedStringResource - let viewModel: ConsentDocumentViewModel + let viewModel: ConsentDocumentModel @Environment(\.colorScheme) var colorScheme @State var name = PersonNameComponents() @@ -209,7 +209,7 @@ public struct ConsentDocument: View { self.familyNameTitle = familyNameTitle self.familyNamePlaceholder = familyNamePlaceholder - self.viewModel = ConsentDocumentViewModel( + self.viewModel = ConsentDocumentModel( markdown: markdown, exportConfiguration: exportConfiguration ) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentViewModel+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel+Export.swift similarity index 98% rename from Sources/SpeziOnboarding/ConsentView/ConsentDocumentViewModel+Export.swift rename to Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel+Export.swift index 73c92d8..7356e53 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentViewModel+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel+Export.swift @@ -11,8 +11,8 @@ import PencilKit import SwiftUI import TPPDF -/// Extension of `ConsentDocumentViewModel` enabling the export of the signed consent page. -extension ConsentDocumentViewModel { +/// Extension of `ConsentDocumentModel` enabling the export of the signed consent page. +extension ConsentDocumentModel { /// 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. diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentViewModel.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel.swift similarity index 86% rename from Sources/SpeziOnboarding/ConsentView/ConsentDocumentViewModel.swift rename to Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel.swift index 5173f78..334a78a 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentViewModel.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel.swift @@ -10,11 +10,11 @@ import Foundation import TPPDF /// A type holding all information required for exporting a PDF from a `ConsentDocumentVIew`, such as the PDF content (i.e., markdown string) and the ExportConfiguration. -public struct ConsentDocumentViewModel { +public struct ConsentDocumentModel { let asyncMarkdown: () async -> Data let exportConfiguration: ConsentDocument.ExportConfiguration - /// Creates a `ConsentDocumentViewModel` which holds information related to the PDF export of a `ConsentView`. + /// Creates a `ConsentDocumentModel` which holds information related to the PDF export of a `ConsentView`. /// /// - Parameters: /// - markdown: Markdown string of the consent document. diff --git a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift index 462b4af..668020f 100644 --- a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift @@ -6,8 +6,8 @@ // SPDX-License-Identifier: MIT // -@testable import SpeziOnboarding import PDFKit +@testable import SpeziOnboarding import SwiftUI import XCTest @@ -49,52 +49,80 @@ final class SpeziOnboardingTests: XCTestCase { let exportConfiguration = ConsentDocument.ExportConfiguration( paperSize: .dinA4, consentTitle: "Spezi Onboarding", - includingTimestamp: true + includingTimestamp: false ) - let viewModel = ConsentDocumentViewModel(markdown: markdownData, exportConfiguration: exportConfiguration) - + + let viewModel = ConsentDocumentModel(markdown: markdownData, exportConfiguration: exportConfiguration) let bundle = Bundle.module // Access the test bundle - - #if !os(macOS) - guard let url = bundle.url(forResource: "known_good_pdf", withExtension: "pdf") else { - XCTFail("Failed to locate known_good.pdf in resources.") - return - } - #else - guard let url = bundle.url(forResource: "known_good_pdf_macos", withExtension: "pdf") else { - XCTFail("Failed to locate known_good.pdf in resources.") + var resourceName = "known_good_pdf" + #if os(macOS) + resourceName = "known_good_pdf_macos" + #elseif os(visionOS) + resourceName = "known_good_pdf_vision_os" + #endif + + guard let url = bundle.url(forResource: resourceName, withExtension: "pdf") else { + XCTFail("Failed to locate \(resourceName) in resources.") return } - #endif - + guard let knownGoodPdf = PDFDocument(url: url) else { - XCTFail("Failed to load known good PDF from resources.") + XCTFail("Failed to load \(resourceName) from resources.") return } #if !os(macOS) if let pdf = await viewModel.export(personName: "Leland Stanford", signatureImage: .init()) { - XCTAssert(comparePDFDocuments(doc1: pdf, doc2: knownGoodPdf)) + let fileManager = FileManager.default + let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + let saveURL = documentsURL.appendingPathComponent("exported.pdf") + try savePDFDocument(pdf, to: saveURL) + XCTAssert(comparePDFDocuments(pdf1: pdf, pdf2: knownGoodPdf)) } else { - XCTFail("Failed to export PDF from ConsentDocumentViewModel.") + XCTFail("Failed to export PDF from ConsentDocumentModel.") } #else if let pdf = await viewModel.export(personName: "Leland Stanford") { XCTAssert(comparePDFDocuments(doc1: pdf, doc2: knownGoodPdf)) } else { - XCTFail("Failed to export PDF from ConsentDocumentViewModel.") + XCTFail("Failed to export PDF from ConsentDocumentModel.") } #endif } - private func comparePDFDocuments(doc1: PDFDocument, doc2: PDFDocument) -> Bool { - - guard let data1 = doc1.dataRepresentation(), - let data2 = doc2.dataRepresentation() else { + 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.. Date: Wed, 14 Aug 2024 18:57:09 +0200 Subject: [PATCH 14/29] Added known-good PDF documents to test against in testPDFExport. --- .../ConsentView/ConsentDocument+Export.swift | 2 +- .../ConsentDocumentModel+Export.swift | 8 +- .../Resources/Localizable.xcstrings | 310 ++++++++++++++++++ .../Resources/Localizable.xcstrings.license | 5 + .../Resources/known_good_pdf.pdf | Bin 0 -> 18601 bytes .../Resources/known_good_pdf_mac_os.pdf | Bin 0 -> 20034 bytes .../Resources/known_good_pdf_vision_os.pdf | Bin 0 -> 18624 bytes .../SpeziOnboardingTests.swift | 20 +- 8 files changed, 323 insertions(+), 22 deletions(-) create mode 100644 Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings create mode 100644 Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings.license create mode 100644 Tests/SpeziOnboardingTests/Resources/known_good_pdf.pdf create mode 100644 Tests/SpeziOnboardingTests/Resources/known_good_pdf_mac_os.pdf create mode 100644 Tests/SpeziOnboardingTests/Resources/known_good_pdf_vision_os.pdf diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index 2dfad34..2b06ac5 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -57,7 +57,7 @@ extension ConsentDocument { personName: personName, signatureImage: blackInkSignatureImage ) #else - return await viewModel.export(personName: personName) + return await viewModel.export(personName: personName, signature: signature) #endif } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel+Export.swift index 7356e53..486309c 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel+Export.swift @@ -113,7 +113,7 @@ extension ConsentDocumentModel { /// - personName: A string containing the name of the person who signed the document. /// - Returns: A TPPDF `PDFAttributedText` representation of the export time stamp. @MainActor - private func exportSignature(personName: String) -> PDFGroup { + private func exportSignature(personName: String, signature: String) -> PDFGroup { // On macOS, we do not have a "drawn" signature, hence do // not set a backgroundImage for the PDFGroup. // Instead, we render the person name. @@ -211,13 +211,13 @@ extension ConsentDocumentModel { /// - personName: A string containing the name of the person who signed the document. /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` @MainActor - public func export(personName: String) async -> PDFKit.PDFDocument? { + public func export(personName: String, signature: String) async -> PDFKit.PDFDocument? { let exportTimeStamp = exportConfiguration.includingTimestamp ? exportTimeStamp() : nil let header = exportHeader() let pdfTextContent = await exportDocumentContent() - let signature = exportSignature(personName: personName) + let signature = exportSignature(personName: personName, signature: signature) - return await exportDocument( + return await createDocument( header: header, pdfTextContent: pdfTextContent, signatureFooter: signature, diff --git a/Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings b/Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings new file mode 100644 index 0000000..93dd60e --- /dev/null +++ b/Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings @@ -0,0 +1,310 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CONSENT_ACTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ich Stimme Zu" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "I Consent" + } + } + } + }, + "CONSENT_EXPORT_ERROR_DESCRIPTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die unterschriebene Einwilligung konnte nicht exportiert werden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to export the signed consent form." + } + } + } + }, + "CONSENT_EXPORT_ERROR_FAILURE_REASON" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Speicherplatz für das Exportieren der Einwilligung konnte nicht reserviert werden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The system wasn't able to reserve the necessary memory for rendering the consent form." + } + } + } + }, + "CONSENT_EXPORT_ERROR_RECOVERY_SUGGESTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte versuche es erneut oder starte die Applikation neu." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please try again or restart the application." + } + } + } + }, + "CONSENT_SHARE" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teile die Einwilligung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share consent form" + } + } + } + }, + "CONSENT_TITLE" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spezi Einwilligung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spezi Consent" + } + } + } + }, + "CONSENT_VIEW_TITLE" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einwilligung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consent" + } + } + } + }, + "EXPORTED_TAG" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportiert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exported" + } + } + } + }, + "FILE_NAME_EXPORTED_CONSENT_FORM" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unterschriebe Einwilligung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signed Consent Form" + } + } + } + }, + "ILLEGAL_ONBOARDING_STEP" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "SpeziOnboarding: Unerlaubter Schritt während des Onboardings" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SpeziOnboarding: Illegal Onboarding Step" + } + } + } + }, + "MARKDOWN_LOADING_ERROR" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Dokument konnte nicht geladen werden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not load and parse the document." + } + } + } + }, + "NAME_FIELD_FAMILY_NAME_PLACEHOLDER" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gib deinen Nachnamen ein ..." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter your last name ..." + } + } + } + }, + "NAME_FIELD_FAMILY_NAME_TITLE" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nachname" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last Name" + } + } + } + }, + "NAME_FIELD_GIVEN_NAME_PLACEHOLDER" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gib deinen Vornamen ein ..." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter your first name ..." + } + } + } + }, + "NAME_FIELD_GIVEN_NAME_TITLE" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vorname" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "First Name" + } + } + } + }, + "SEQUENTIAL_ONBOARDING_NEXT" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nächster Schritt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next" + } + } + } + }, + "SIGNATURE_FIELD" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unterschrift Feld" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signature Field" + } + } + } + }, + "SIGNATURE_NAME %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name: %@" + } + } + } + }, + "SIGNATURE_VIEW_UNDO" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rückgängig Machen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Undo" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings.license b/Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings.license new file mode 100644 index 0000000..7f16969 --- /dev/null +++ b/Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings.license @@ -0,0 +1,5 @@ +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 \ No newline at end of file diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3737073d7a504aee0a42394223ecdba6e6bd9de7 GIT binary patch literal 18601 zcmd6PWk6Kj+V;>XAkvDAAe}R`NVjxIN;7mxcb9~KG}6+IfONN#(jZ-u(jDInK0a}t z^PY3w_s{v(hS_W1d)2*SuWQ}w+M7~dM2r!{#EMSYv3jt&m3x>z)X{~`3SzkoJ ze-308GqtdX*h0@1diD?zh=H{s1jsB6u`;$d0kUy{fP8%DcJ{UqJxg?FxNs${h!GYX z_tVk@@%4;1JCmjdpI-|BpK_U@J+;TC?4Lz?mm`B7baq^{^EBpLkZf$7xmyJ*&ttOJ z0ThVW(t45R0Znbj+flDDaaRRUgeCF6PDu1fchKd2tx8`eC}j{~$YSe@lc*{^5F{*P z#5Lb|IQh^WY5q|GKRda`A@+^eZSl-SRzy1DS;^e|*QD2Ub%7p1Qd=9m2%cEA0$EIb zo;oKzh##Ln%$LINvVB5&UXWNCZo+}&NvJ1^w(KC$fc~KnamtKhB1J@)zs~z%xpbvZG>9O41dYggToh5U8Y8H&e|7*p;V#-#-imGOS^SRCfBJ` z)j@CUSSiQ2*H;Y2;>Q+`>}JwSd+l5;uZ|;UOwd1MD>1svylB|%AzYDPB_$~w%@c~2 z8mN#iu8JCH?0xw0V8?q!Y0N_IxO6kqyspWrP)R4gPq-f|8qqLh_%YpNdyqw?ILUTv zvq*wHxtgEbBlYheZCe|k>eJ_^eI8L$6vaqMav9S-Y_28+_b-GmmMEKK2t>RetHU}51y+?;w8UEJRP#>&=fd14`lwE9~Jwsm4`0m#hVr35mLvJernH3>+)(*A? z5IZ2-Pl2$tl|A&n9T3*hpaEnchNgN#*3Ljp7N~%on;po-2G&AnRmC+R1*) z5thQQt|@P8ZJ-3P2WmpI6%hk6D?^;^fy|PY&>{%^b{78aED6*CG7DQqJaWYy&K%^(IpBTHI>fLy=s{GlC8^h>*+_4z9j zvyy|p{ap>;=?7$1H8q5dKu&fbvjoJ{*u)+Ptu|&!XvNyvSi{CD(B9So0#)q~rGIL8 z7Y3>WvpfXaCN}mCdKN%tAya!h=(Vu5rH!=}tm(LZALJl*4$!a106MdXu<~yYfj!e~IxFb7Q;7W=m|d^-HE5O6c;2*3OQ9azxY9RQcS ziM748or$##kd=uAz$GLpg9u9z>hAoMcV3)P(y*xrz(>RlZ?uH%Ga=907QlT}fDYUY z0vtI2{yrSSeYo2W02wrAB)B_w7Y+IY2akY=gp7iUhK>OZP=y14heJSsM?^qEf@K5e z0lg1EypM$Y1SE+3KtT_M{52lfCn^J#La4kIUvYTvDXYG%FB&=lArUbNB^5OdEgc&> z2PYTzGhq=?F>wjW7fQ-6RaDi~H4F?PM#d(lW_I=tj!w=lu73UjfkDA>0TBhJ7aY73O!51ONKZh>xPl5Odaoal zgMCo(grYLaThS<375DJxQL`H-;WEA8(hw__4y>n}uRjjOB&`I#0 zLDoNJD`&WDRg_mssx$nYr$%LMso*xTI5^0c;6A{|papU&AOHLMK-Az+#UkC=r51(Z z%skOTe4l3up(>kQ#mu9Mg87{quhcx!-g~#r+|2#B^win?VRO6Ay{vwF_zgj5TGLt4 zlnLoc3^i>`0JY86ob2q1@2B?>h{{|Quata=tr8u2!{6NSirbX)yuv$2J2ILG z$y&9nS*Qi$NjdX^u)DSixvq7~wdhg|QHvm|f<;z<5-va-o}kC>9k+K19H-Iews&wS zfF}BP;*rj{LNyBcG_?3-XbazyuV`$G@fW0flM~ch(lKs*b#RigCgE(}OY~6M86lj# zVc_>kUJk}-#X}Vqg7YKwuda!F&lX0H5rQkpJt_H(1|KQf(U+;fm4g|KRYt1Iqi4mH zPk53cCWW!w!^uvd$NfMo2(&6~Gs+K=UF5P|LHBC8Um9OR$~s(kP_8#=9Nx z1HYGCg8NMBVdNT}GcqWn{=hI}5g3#(6bd;&1Y<2wc*M)UlkR%JCFB*X$nqFB)13|F z<{%2ufr5ZeKwmjaKs`9W3MG9U9nH60gtEU2R>i&rOas}Ns9OGS^8sBIC z+S)Q(kpUt2Nx|B8-zdpQrYM|tUvBr2jekcc@#}9Evn3+T@FXIHOA4vOd#7K2n zjBQsVSQys$<|DaFBdb^Z71=g<^ebl(C7B8;UR}(V1b^%&B)@)KWpslqOZ0g(VP6?( z|21)u2RDeC8PI~fhzu3tQ-AVs_@dYDYjsnE#=39uhek5e@+g|LKxh26f!c)(LoPlf z-F}`j9%JX@ToXFB5^xlcL^eD9QfQD-O-R<_6pJ#_fdr`m?V*H{tbnCd{}? z_kzMO3!_a^Uu!isZZ{hqD{+WhJbXVkIozPYBDu#o=gqXqzKOpnym_y~_mp{5){Hsb zZS->Q+qubL?xVdS8aE39|J_qv5-t2RS?swuTdt!)l*L@`MkRS{fON`Q$j-;@r*shMC%M^t>mg2psjocr5-N zTK4CwsM-fi;V#5}pHrq#rmnZOwy(Ddw}V1xXb!rYI&Z>fd*|RNCGZU<+@~e4uwrAr8^ugfE5T(zRMs=VKY4IGC==P3;xV;)yKY%)Gal){) zFj2VfG^=pJGGCX$Q-fOI6JinYj|YUY4$Sw89_f*6q@jLfR#N7kD{H>ppfC@MH$=jV z;4@A@PBzS@g4`n>=EhYO4eVAPG8}p;cG!YN+!0%Zg`3A)1hVUymQi8Uea@j3)$X-G z(;sgnhmk$it^uji9~LsXU&+#uSsP_Lji~Wdm&}$xPqya%t3228U{M=XY;qH#=vW)@ zb3;<)uATr?|6u^R{~ne3iiveAF2thO?0hiwEnk4m1PvZRy9ZLng1hM(>10Ye!`BN& zPYqvFx~m$;@yy(73SQw2P4*iKb?&@IWBtDH1tR)_SgYySlS#WG5&malT5Uw61upA*2xa&i8E@A`0(lSZUQ`YGV{% zAVH8I6xnY-rhOIRmZ_E4$g)>RRYZR6U$}@|L%Hkgw%qE>kowkm3GDtvJ78kL!I?tY z$MNdfT)^qFH7ZGlR2eE^j|^gz^xm7=4k3N+^2AADw)@6!wnR8V0u8xl@-q3d$~tuM z&%%>LQ^bq2#^ty2#q-wF%}(WYzM8NhECdklQHICKyqD9Ioq53{z$04!!cF91*2qSe zJ#`&FZ`{BK!msoj@4sJeP;X3p=RKOhWDi7YPr2VEVJtzk8b;Ek-G$c0-eu4g84?## z98&tCC`2Gz1d{!kDOM%+3mq?=aKUuJNx@BlWWiK{lrm=lM?tDmW9fyEj7mLH}Cs}9X(&8$ZqMoGu~#*Fj6jH%}RP~p$^6u%A#3=B*WixjI9%jiN6M-FEt z!DiQHbx16cW>RW-)lmfD0F{E$Obbk=&1~6>P2 zWjaWi%WJC6$ndD}$hp;?XzyT5H$Jl`t7+G`uHJrd zy+!5>-=}EuMdYNhSRdrGIfe{3cX%6e*tiou`ZCTkQA|T8QP326lWwp z>6SOC=_?!FOIoD=@LEhzOy~pM2L!PcG3#*ku8c13uEOvL5`LB^(vM`8WY!WbSh6ub`Vnq|*Uc$P$!Fu*x$I<5E2#K?-kii&;4(6Os%{#(oQp64^}Dd%X1 zd>C4x0F3g^XQ9yqa%vY0SoHB#Q4VqJx~68tdbHKw^jvyvdp^(6O%;6G^d3Vne`#*L zCB5Y^ZaI#?LBOG8`Pt%R>RZ*z^5akVQ&we`)gBOI>v*&3(cL_)(#9y>iKbD{v}+HP zdqDz0wO9^VoLwVbZV`jT%??-i;vpY`KJ=82?|*#I**hY+T{*T8fwXd0Q1|91p%y7o`sIE%MIzrC`igyIDQ6in3a@Dz@^f;i)^E zHJ`PX9!((Pcs<;)_GXZpTLxElnq%WrP)WCPMD}CL$6hY3E}x%2{`43|A;|qodhp3Uh4mIyuwR>2D5TzZ3K|Uy!FHR|D z`SyZ)8l*um`z#jBZo4_1e`;W1@DwC&puoWCXu0jjby-vUdcJ#PX?e+f*-OSoibl#r zZd4{YrG|4K@--$aR`T>6>ATflOWGt_Rz(s;?d-s;63Lw;4!vn#Q^FDHuN|iS8%IYm zM;Aw}8yO=Laz7FmK_6_p`V&uBLyff)AYgi@@%^c!3YQ9OV|i9Z))S+K<<5gmXFt0d zyH9<@3n+)2duiyth+BvrLSBm`i_6ltee8*i>No1<6&E5tnIsC$Txt#KSI>FK-P}$J zlP8mBxyfvm?66lr2jByZr_bI!>YZ{wyjr>{RHQb|t~4+C@$J|S#Zckn1$>dNk)U11 z<13}3s9|{;OqPn#3l*wonAcGeEV^!Zjv zjMk2ho7b$owW5yJ?SxhDZV4}%+W?0%JKHwNo9I)g;%P*0|P) zaQ|^}y=xHHZ5FXbrsE=XEYkS~n_5Z^EA?IKCVz^K{2Omx&#F<^SEjIVQa~(u?%ZW3&Xq(k-JJgJ+rg7#Qj?hER;cjx9qr zer!4~vqi>)lw;`{d{oT50?T>IOW0+<78YyGz?xRf|2Wu+;CputAY z#vxpCAmtH>D;n@Cj|4uV!W&6%dH6toDn}6{F}(YXa_j* zU9ActqN}rE=4d~P*$?NEuDjLer`D?{ri_1hOwh!HsWmvoci{cUQzBzyg zpvH7?H`ro@o^zX|0-SSFQD zLg-y;9VZG$scxBQ617wOXlF-#e9h#%d20T#r_wcVkL%^jeX8xjRKc)R^?8f!BIZT{ zz{yjM_oNj~u`dCYmdFCDi$!ujfPe!eK;>NW;p0FH?ROnZnA8}d+uIg)aB1dD^xruz z(|oNzHwSW`CVttyRN=`XQCg5(Y3&3v!qAp7lY#idyuU>& zJZ>b4eL6au4-*SKgYLaY-mEqVb3lN&sz-{0dRE<(>@&rnk5NdSI&*3*4NKDXQ-t4t}O!K_o#IcB0TY&O;4U7hyqp zEu?*z_=sPc&D{0cIJ<#~@N>`CfSwfrv9QHFqQ$k%Wl{`V8$NA&JZ0zP?9 zmGL}66>s~pAmWUlj6&Elv-qNhfDfy>jd1bd9)KZ0r!5*)pa&IUSWFG|;l7Aaq}o%s z`%lcH$g|+{MUx`2vL4okOGLe1K|l`5&`Tg9=ptSYQV(0ySJ4~Sf59@AV3439O}NHk zgv07{?whSYR-U6qUBvH*#&Q3BIAe#3ZiKF8S$sLdq~12qBdqQg;-yD6o;5_Tz*g)f zh}GB=VfEfy?e`X`_MIE(=J0;_Vz=7u>AOE^Lic*)^>{vltgD$i5={fq+!y;v9Pp{d zQ)L_y)OFNr|NCuh3O;#q_T+&$E*-D*Y0KYE3N}bSqLPiqlMH-HsQ8pNvC>Cb(u`b- zikGVKNp$ZsLvdRobpdU;lBa1v9ZKwX{PHn0RCsbP9*GJ~$#TD_6>t%75zbb;muEGq zR*+Daq9NBP;_Y{m$~<-}-i=Z&W+L7xUWX}-TIY$^i<_LQ8hC|P zfmS7-4`nJDVvCh+yaAG;NtvEbf@^g3jpZ&{P7>`jW~JIU}~8Kue5Ic43t!OB5Q^EDGHwnFy$ z?#!}p>gq*eI7`eY`;*Bv`7J7y<&r><)*>9M7-qr^kIV{)U{Nd_^rgX2q8qf|?) z-<|6JF#D0q_l)liUpU``CdZ~i59TwyYxrx&i`m18z4eQUOF2X?#3Hos=vjzjh$<+2 z7+^G9BnQOyR=C!BUoz&eI!|AX62J>x2|ETnM`Op-!oRR1Vq#z`qS|(;dFkxw?1adu zFDfG3Q7;kE^R-7jG@L++z)7rEyi;^cTt?JQG+tCHUX4M$Vnr-WBMg|sOF;Gz-|eZR zZT{)kS*f7q?IrZ(T_)C7aTVLswpFY7Uj|Debz0S5(>;DjMmmpk`{qGg{;G7zkxs4C zD&j6S)+<#vzd5u?crnnFH0dya+KfRZOyBDsGiUEo%ruGW}jqVIxRS&I(?hn zs5?|k)K}M@8Y)@PDqih7$ULa7SKV$>(bIgbi)|WV7*Pq%``GxR)J%LVcQb3#ha@6t zt#Q+>dM9PNduF0}-qLFXE*zl~h3_%mqXw^@Q+e}ZLCa3PZ;M6kgIk%Kb z68lku;sN5>-E9#LV?3i1oOzrv9Btw6y2_VdcUxaC91FWWDBO@|{ZM;`$n}JKf2sS7 z>_;l*^C_1QkAe;A>$HwXk=l9*KS z*;r~YY5+g^J$IUu=YddHz<%f(!k}lW_I!1fD_>Je%1Y+G_{5iL@7LPq&oo|My^Jq3 zF5%YV@ksG#+zGr6AAek*8mF`EX}VGM!?JP1rs6tox%h1plE?am)+O`YNu9^)+uM|f z2%*RqyG%`Px7y!$H$3L9hf(sewfXy8xvovF4YrpvsnO-=QVaNtugA_NjPe_gw$mqc z2P=Y&BLZuLnQvpyOXb>;2J;8$#VW-%#f!xU!_LEIUCWO;_r8S1k1Pi?)82eKjU6sb z^K`mwG}iC!*fSsAO5}erm)FF7F@9pXZuos=#L4tWPHxCtVH5v}&vnM7!BKbA#P-4Q*-G8k=Z)yAj%`k4k&pm!&J$pS1YvZ5f3AJJ9PBvI2 z!wwD*jF<6G4`@p4tWX9Bl*0t|alx)&U4oqx76S~1Q89iV?Ce}Xc6QF+?y$1K_N4xN zuyKRX|I)($yovr)k#`jXQ*AF?G8|G>lAWmu4S!G<}|Ii(BP3)@FWmRmkK|*{gIgnDwode8AaK?T=c(d<{t-E!BK z4%tew#!POxJ2sc^iQN>vggHeO__rsrIJ(?k5dWwqwc>eK-o5ix^6OOo(~k-vTBF|= ze9?V*Vjo&4LH7J@eaoM}kA1*hlBt{V*_Y%ZoGx@%N&T}+xIwOjez(~+4i z{l!B3?$h$@tF#pOPNJPmdg z#48RU&)<8Ds$t-vA3a(AbdJ?Q$&3Od3(2*Id`G6`KbXpVH)@f8d~YMPib=^eeqAK< zdTYJ1cRx>TW@_?eQu|om`byWe12V~=i4~)qcAsA(2Xf#@-P(K7WBJU= zfn#Vq2szfAY~3nATkuf-VNuIKW)l(d2iCG3X~Rlwdtnap8_oLRB}aoR?uQx81{x)H z({0IA*HR9i1L*YwIiTExPgpur^d@k^vf_xUf;H#_A4nOr!^h3yr;oia5#K&X^G;MT zlbd4RmM~HBi8Fz()GCOQCojP6-!{;+(5WEqYZxa;I!`<`EA=c1*C_5>eBm|~SGLUY z-kN8dyu_(m>DOzuHF*2Dref{Qe4eu|%Sl@{b&qdMC!Z{<5l(bQAD@+r z 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 literal 0 HcmV?d00001 diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_mac_os.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_mac_os.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8fa3c53df26efdf20e021246d23c7d3ab5349899 GIT binary patch literal 20034 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 literal 0 HcmV?d00001 diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_vision_os.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_vision_os.pdf new file mode 100644 index 0000000000000000000000000000000000000000..93dd7efe22fb3dd3c6a0a1405ab9737e7fad882a GIT binary patch literal 18624 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 literal 0 HcmV?d00001 diff --git a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift index 668020f..f961850 100644 --- a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift @@ -57,7 +57,7 @@ final class SpeziOnboardingTests: XCTestCase { let bundle = Bundle.module // Access the test bundle var resourceName = "known_good_pdf" #if os(macOS) - resourceName = "known_good_pdf_macos" + resourceName = "known_good_pdf_mac_os" #elseif os(visionOS) resourceName = "known_good_pdf_vision_os" #endif @@ -74,17 +74,13 @@ final class SpeziOnboardingTests: XCTestCase { #if !os(macOS) if let pdf = await viewModel.export(personName: "Leland Stanford", signatureImage: .init()) { - let fileManager = FileManager.default - let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] - let saveURL = documentsURL.appendingPathComponent("exported.pdf") - try savePDFDocument(pdf, to: saveURL) XCTAssert(comparePDFDocuments(pdf1: pdf, pdf2: knownGoodPdf)) } else { XCTFail("Failed to export PDF from ConsentDocumentModel.") } #else - if let pdf = await viewModel.export(personName: "Leland Stanford") { - XCTAssert(comparePDFDocuments(doc1: pdf, doc2: knownGoodPdf)) + if let pdf = await viewModel.export(personName: "Leland Stanford", signature: "Stanford") { + XCTAssert(comparePDFDocuments(pdf1: pdf, pdf2: knownGoodPdf)) } else { XCTFail("Failed to export PDF from ConsentDocumentModel.") } @@ -115,14 +111,4 @@ final class SpeziOnboardingTests: XCTestCase { // If all pages are identical, the documents are equal return true } - - func savePDFDocument(_ pdfDocument: PDFDocument, to fileURL: URL) throws { - guard let pdfData = pdfDocument.dataRepresentation() else { - throw NSError(domain: "PDFSaveError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to get PDF data"]) - } - - try pdfData.write(to: fileURL) - print("PDF saved successfully at: \(fileURL.path)") -} - } From e6080f649f136eaebb857f1f49dd8591fa7fa278 Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Sat, 24 Aug 2024 18:26:21 +0200 Subject: [PATCH 15/29] Merged in changes from #52. Moved functionality of ConsentDocumentModel to ConsentDocumentExport. ConsentDocumentExport now is a container storing all information required for exporting a ConsentDocument to a PDF. The ConsentDocument holds a ConsentDocumentExport and fills in the information such as exportConfiguration, content, signature and name of the signee as they come in. PDF export is thus separated from the ConsentDocument. The ConsentDocumentExport now supports "lazy loading" of PDFs, i.e., if the PDF was not exported before (no cached PDF was set), the export function will be called automatically when accessing the async pdf property. --- .../ConsentView/ConsentDocument+Export.swift | 11 +- .../ConsentView/ConsentDocument.swift | 25 +- ...ift => ConsentDocumentExport+Export.swift} | 16 +- .../ConsentView/ConsentDocumentExport.swift | 48 ++- .../ConsentView/ConsentDocumentModel.swift | 29 -- .../ConsentView/ConsentViewState.swift | 2 +- .../OnboardingConsentView.swift | 11 +- .../OnboardingDataSource.swift | 10 +- .../Resources/Localizable.xcstrings | 310 ------------------ .../Resources/Localizable.xcstrings.license | 5 - .../Resources/known_good_pdf.pdf | Bin 18601 -> 130 bytes .../Resources/known_good_pdf_mac_os.pdf | Bin 20034 -> 130 bytes .../Resources/known_good_pdf_vision_os.pdf | Bin 18624 -> 130 bytes 13 files changed, 84 insertions(+), 383 deletions(-) rename Sources/SpeziOnboarding/ConsentView/{ConsentDocumentModel+Export.swift => ConsentDocumentExport+Export.swift} (94%) delete mode 100644 Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel.swift delete mode 100644 Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings delete mode 100644 Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings.license diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index 2b06ac5..cc52c64 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -50,14 +50,13 @@ extension ConsentDocument { /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` @MainActor func export() async -> PDFKit.PDFDocument? { - let personName = name.formatted(.name(style: .long)) - #if !os(macOS) - return await viewModel.export( - personName: personName, signatureImage: blackInkSignatureImage - ) + documentExport.signature = signature + documentExport.name = name + documentExport.signatureImage = blackInkSignatureImage + return await documentExport.export() #else - return await viewModel.export(personName: personName, signature: signature) + return await viewModel.export() #endif } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift index ed86b8e..d1d2f08 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift @@ -46,7 +46,7 @@ public struct ConsentDocument: View { private let familyNameTitle: LocalizedStringResource private let familyNamePlaceholder: LocalizedStringResource - let viewModel: ConsentDocumentModel + 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: viewModel.asyncMarkdown, state: $viewState.base) + MarkdownView(asyncMarkdown: documentExport.asyncMarkdown, state: $viewState.base) Spacer() Group { nameView @@ -152,11 +154,13 @@ public struct ConsentDocument: View { .onChange(of: viewState) { if case .export = viewState { Task { - guard let exportedConsent = await export() else { + if let exportedConsent = await export() { + documentExport.cachedPDF = exportedConsent + viewState = .exported(document: exportedConsent, export: documentExport) + } else { viewState = .base(.error(Error.memoryAllocationError)) return } - viewState = .exported(document: exportedConsent) } } else if case .base(let baseViewState) = viewState, case .idle = baseViewState { @@ -194,6 +198,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``. + /// - identifier: 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,7 +206,8 @@ 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._viewState = viewState self.givenNameTitle = givenNameTitle @@ -209,10 +215,15 @@ public struct ConsentDocument: View { self.familyNameTitle = familyNameTitle self.familyNamePlaceholder = familyNamePlaceholder - self.viewModel = ConsentDocumentModel( + self.documentExport = ConsentDocumentExport( markdown: markdown, - exportConfiguration: exportConfiguration + 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/ConsentDocumentModel+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift similarity index 94% rename from Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel+Export.swift rename to Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift index 486309c..3cd882d 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift @@ -11,8 +11,8 @@ import PencilKit import SwiftUI import TPPDF -/// Extension of `ConsentDocumentModel` enabling the export of the signed consent page. -extension ConsentDocumentModel { +/// 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. @@ -83,7 +83,9 @@ extension ConsentDocumentModel { /// - signatureImage: Signature drawn when signing the document. /// - Returns: A TPPDF `PDFAttributedText` representation of the export time stamp. @MainActor - private func exportSignature(personName: String, signatureImage: UIImage) -> PDFGroup { + private func exportSignature() -> PDFGroup { + let personName = name.formatted(.name(style: .long)) + let group = PDFGroup( allowsBreaks: false, backgroundImage: PDFImage(image: signatureImage), @@ -113,7 +115,7 @@ extension ConsentDocumentModel { /// - personName: A string containing the name of the person who signed the document. /// - Returns: A TPPDF `PDFAttributedText` representation of the export time stamp. @MainActor - private func exportSignature(personName: String, signature: String) -> PDFGroup { + private func exportSignature() -> PDFGroup { // On macOS, we do not have a "drawn" signature, hence do // not set a backgroundImage for the PDFGroup. // Instead, we render the person name. @@ -134,7 +136,7 @@ extension ConsentDocumentModel { group.addLineSeparator(style: PDFLineStyle(color: .black)) group.set(font: nameFont) - group.add(PDFGroupContainer.left, text: personName) + group.add(PDFGroupContainer.left, text: name) return group } #endif @@ -189,11 +191,11 @@ extension ConsentDocumentModel { /// - signatureImage: Signature drawn when signing the document. /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` @MainActor - public func export(personName: String, signatureImage: UIImage) async -> PDFKit.PDFDocument? { + public func export() async -> PDFKit.PDFDocument? { let exportTimeStamp = exportConfiguration.includingTimestamp ? exportTimeStamp() : nil let header = exportHeader() let pdfTextContent = await exportDocumentContent() - let signature = exportSignature(personName: personName, signatureImage: signatureImage) + let signature = exportSignature() return await createDocument( header: header, diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift index 18a9545..829e208 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,60 @@ 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 + + public var name = PersonNameComponents() + #if !os(macOS) + public var signature = PKDrawing() + public var signatureImage = UIImage() + #else + 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 cached = cachedPDF { + return cached + } else { + cachedPDF = await export() + // If the export failed, return an empty document. + return cachedPDF ?? .init() + } } } - + /// Creates a `ConsentDocumentExport`, which holds an exported PDF and the corresponding document identifier string. /// - Parameters: /// - 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/ConsentDocumentModel.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel.swift deleted file mode 100644 index 334a78a..0000000 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentModel.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// 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 -import TPPDF - -/// A type holding all information required for exporting a PDF from a `ConsentDocumentVIew`, such as the PDF content (i.e., markdown string) and the ExportConfiguration. -public struct ConsentDocumentModel { - let asyncMarkdown: () async -> Data - let exportConfiguration: ConsentDocument.ExportConfiguration - - /// Creates a `ConsentDocumentModel` which holds information related to the PDF export of a `ConsentView`. - /// - /// - Parameters: - /// - markdown: Markdown string of the consent document. - /// - exportConfiguration: An `ExportConfiguration` defining properties of the PDF exported from the `ConsentDocument`. - public init( - markdown: @escaping () async -> Data, - exportConfiguration: ConsentDocument.ExportConfiguration - ) { - self.asyncMarkdown = markdown - self.exportConfiguration = exportConfiguration - } -} 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/OnboardingConsentView.swift b/Sources/SpeziOnboarding/OnboardingConsentView.swift index 051c6a8..1ddb441 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 diff --git a/Sources/SpeziOnboarding/OnboardingDataSource.swift b/Sources/SpeziOnboarding/OnboardingDataSource.swift index 55fa7ee..ad24d85 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 @@ -49,12 +49,12 @@ public class OnboardingDataSource: Module, EnvironmentAccessible { /// 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 = 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/Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings b/Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings deleted file mode 100644 index 93dd60e..0000000 --- a/Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings +++ /dev/null @@ -1,310 +0,0 @@ -{ - "sourceLanguage" : "en", - "strings" : { - "CONSENT_ACTION" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ich Stimme Zu" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "I Consent" - } - } - } - }, - "CONSENT_EXPORT_ERROR_DESCRIPTION" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die unterschriebene Einwilligung konnte nicht exportiert werden." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Failed to export the signed consent form." - } - } - } - }, - "CONSENT_EXPORT_ERROR_FAILURE_REASON" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Der Speicherplatz für das Exportieren der Einwilligung konnte nicht reserviert werden." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The system wasn't able to reserve the necessary memory for rendering the consent form." - } - } - } - }, - "CONSENT_EXPORT_ERROR_RECOVERY_SUGGESTION" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bitte versuche es erneut oder starte die Applikation neu." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Please try again or restart the application." - } - } - } - }, - "CONSENT_SHARE" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Teile die Einwilligung" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Share consent form" - } - } - } - }, - "CONSENT_TITLE" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Spezi Einwilligung" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Spezi Consent" - } - } - } - }, - "CONSENT_VIEW_TITLE" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Einwilligung" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Consent" - } - } - } - }, - "EXPORTED_TAG" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Exportiert" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Exported" - } - } - } - }, - "FILE_NAME_EXPORTED_CONSENT_FORM" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unterschriebe Einwilligung" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Signed Consent Form" - } - } - } - }, - "ILLEGAL_ONBOARDING_STEP" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "SpeziOnboarding: Unerlaubter Schritt während des Onboardings" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "SpeziOnboarding: Illegal Onboarding Step" - } - } - } - }, - "MARKDOWN_LOADING_ERROR" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Das Dokument konnte nicht geladen werden." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Could not load and parse the document." - } - } - } - }, - "NAME_FIELD_FAMILY_NAME_PLACEHOLDER" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gib deinen Nachnamen ein ..." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enter your last name ..." - } - } - } - }, - "NAME_FIELD_FAMILY_NAME_TITLE" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nachname" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Last Name" - } - } - } - }, - "NAME_FIELD_GIVEN_NAME_PLACEHOLDER" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gib deinen Vornamen ein ..." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enter your first name ..." - } - } - } - }, - "NAME_FIELD_GIVEN_NAME_TITLE" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vorname" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "First Name" - } - } - } - }, - "SEQUENTIAL_ONBOARDING_NEXT" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nächster Schritt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Next" - } - } - } - }, - "SIGNATURE_FIELD" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unterschrift Feld" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Signature Field" - } - } - } - }, - "SIGNATURE_NAME %@" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Name: %@" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Name: %@" - } - } - } - }, - "SIGNATURE_VIEW_UNDO" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rückgängig Machen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Undo" - } - } - } - } - }, - "version" : "1.0" -} \ No newline at end of file diff --git a/Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings.license b/Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings.license deleted file mode 100644 index 7f16969..0000000 --- a/Tests/SpeziOnboardingTests/Resources/Localizable.xcstrings.license +++ /dev/null @@ -1,5 +0,0 @@ -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 \ No newline at end of file diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf.pdf index 3737073d7a504aee0a42394223ecdba6e6bd9de7..74234e19ae83bff79a599f5d430244d2fe883fa2 100644 GIT binary patch literal 130 zcmWm2K@P$o5CFhCuiyg~7GWvN-X#J_{oX6OVxwUz@m$Ce; z@4Qfdm~jZ16I5@kN8Me(jWMA`a9I-NKp{rSlm(~&`$Qsv*caRxuhCl1Hh2#IaS&6? Ms~YVi8dkKRz7KaN{Qv*} literal 18601 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 z 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 diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_mac_os.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_mac_os.pdf index 8fa3c53df26efdf20e021246d23c7d3ab5349899..809e4c47c4b06dbbd344e91f1d34501a6c145d84 100644 GIT binary patch literal 130 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 diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_vision_os.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_vision_os.pdf index 93dd7efe22fb3dd3c6a0a1405ab9737e7fad882a..67633bdb2b06be68d0452d4e95e3955c0d677844 100644 GIT binary patch literal 130 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 From d15f5e3342204b4cec34082b7f736a555c384b1b Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Sat, 24 Aug 2024 18:36:50 +0200 Subject: [PATCH 16/29] Resolved errors on macOS. --- .../ConsentView/ConsentDocument+Export.swift | 2 +- .../ConsentView/ConsentDocumentExport+Export.swift | 8 +++++--- Sources/SpeziOnboarding/OnboardingConsentView.swift | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index cc52c64..5cda262 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -56,7 +56,7 @@ extension ConsentDocument { documentExport.signatureImage = blackInkSignatureImage return await documentExport.export() #else - return await viewModel.export() + return await documentExport.export() #endif } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift index 3cd882d..12f3ff0 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift @@ -116,6 +116,8 @@ extension ConsentDocumentExport { /// - Returns: A TPPDF `PDFAttributedText` representation of the export time stamp. @MainActor private func exportSignature() -> PDFGroup { + let personName = name.formatted(.name(style: .long)) + // On macOS, we do not have a "drawn" signature, hence do // not set a backgroundImage for the PDFGroup. // Instead, we render the person name. @@ -136,7 +138,7 @@ extension ConsentDocumentExport { group.addLineSeparator(style: PDFLineStyle(color: .black)) group.set(font: nameFont) - group.add(PDFGroupContainer.left, text: name) + group.add(PDFGroupContainer.left, text: personName) return group } #endif @@ -213,11 +215,11 @@ extension ConsentDocumentExport { /// - personName: A string containing the name of the person who signed the document. /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` @MainActor - public func export(personName: String, signature: String) async -> PDFKit.PDFDocument? { + public func export() async -> PDFKit.PDFDocument? { let exportTimeStamp = exportConfiguration.includingTimestamp ? exportTimeStamp() : nil let header = exportHeader() let pdfTextContent = await exportDocumentContent() - let signature = exportSignature(personName: personName, signature: signature) + let signature = exportSignature() return await createDocument( header: header, diff --git a/Sources/SpeziOnboarding/OnboardingConsentView.swift b/Sources/SpeziOnboarding/OnboardingConsentView.swift index 1ddb441..e94c090 100644 --- a/Sources/SpeziOnboarding/OnboardingConsentView.swift +++ b/Sources/SpeziOnboarding/OnboardingConsentView.swift @@ -179,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() From 4e919db03258302256fee6f63de6bfa074ae4c5b Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Sat, 24 Aug 2024 20:28:17 +0200 Subject: [PATCH 17/29] Expanded unit test for PDF export to include documents with two pages. --- README.md | 7 +- .../ConsentView/ConsentDocument+Export.swift | 2 +- ...df.pdf => known_good_pdf_one_page_ios.pdf} | Bin ...pdf => known_good_pdf_one_page_mac_os.pdf} | Bin ... => known_good_pdf_one_page_vision_os.pdf} | Bin .../known_good_pdf_two_pages_ios.pdf | Bin 0 -> 130 bytes .../known_good_pdf_two_pages_mac_os.pdf | Bin 0 -> 130 bytes .../known_good_pdf_two_pages_vision_os.pdf | Bin 0 -> 130 bytes .../Resources/markdown_data_one_page.md | 1 + .../Resources/markdown_data_two_pages.md | 13 +++ .../SpeziOnboardingTests.swift | 105 ++++++++++++------ 11 files changed, 90 insertions(+), 38 deletions(-) rename Tests/SpeziOnboardingTests/Resources/{known_good_pdf.pdf => known_good_pdf_one_page_ios.pdf} (100%) rename Tests/SpeziOnboardingTests/Resources/{known_good_pdf_mac_os.pdf => known_good_pdf_one_page_mac_os.pdf} (100%) rename Tests/SpeziOnboardingTests/Resources/{known_good_pdf_vision_os.pdf => known_good_pdf_one_page_vision_os.pdf} (100%) create mode 100644 Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_ios.pdf create mode 100644 Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_mac_os.pdf create mode 100644 Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_vision_os.pdf create mode 100644 Tests/SpeziOnboardingTests/Resources/markdown_data_one_page.md create mode 100644 Tests/SpeziOnboardingTests/Resources/markdown_data_two_pages.md diff --git a/README.md b/README.md index 5c68b8f..b070520 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,12 @@ 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. - +## Information: Running the 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 5cda262..bd2a3f3 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -50,9 +50,9 @@ extension ConsentDocument { /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` @MainActor func export() async -> PDFKit.PDFDocument? { - #if !os(macOS) documentExport.signature = signature documentExport.name = name + #if !os(macOS) documentExport.signatureImage = blackInkSignatureImage return await documentExport.export() #else diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_ios.pdf similarity index 100% rename from Tests/SpeziOnboardingTests/Resources/known_good_pdf.pdf rename to Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_ios.pdf diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_mac_os.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_mac_os.pdf similarity index 100% rename from Tests/SpeziOnboardingTests/Resources/known_good_pdf_mac_os.pdf rename to Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_mac_os.pdf diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_vision_os.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_vision_os.pdf similarity index 100% rename from Tests/SpeziOnboardingTests/Resources/known_good_pdf_vision_os.pdf rename to Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_vision_os.pdf 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 0000000000000000000000000000000000000000..9510ce9b24b01dce1a5bb9ff506890f49f9edf3d GIT binary patch literal 130 zcmWN@$q~aK3;@7CRnS0;ggD&|ha8wmVo$(+r26=^d+~Sl{Uh6)$5@qm_W5{iW4Uc- zUdsMfX&%&|%CcjLpFkW39|7BN$FE~l20E)`@8T=tvyCIr%mF_G5Ida^)62o4S) M!Q)${u}PEo0nN82)Bpeg literal 0 HcmV?d00001 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..af47621 --- /dev/null +++ b/Tests/SpeziOnboardingTests/Resources/markdown_data_one_page.md @@ -0,0 +1 @@ +This is a **markdown** example 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..4f29ebe --- /dev/null +++ b/Tests/SpeziOnboardingTests/Resources/markdown_data_two_pages.md @@ -0,0 +1,13 @@ +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. + +Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. + +Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. + +Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. + +Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis. + +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 \ No newline at end of file diff --git a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift index f961850..ff36bbb 100644 --- a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift @@ -42,49 +42,81 @@ final class SpeziOnboardingTests: XCTestCase { @MainActor func testPDFExport() async throws { - let markdownData = { - Data("This is a *markdown* **example**".utf8) - } - let exportConfiguration = ConsentDocument.ExportConfiguration( - paperSize: .dinA4, - consentTitle: "Spezi Onboarding", - includingTimestamp: false - ) - - let viewModel = ConsentDocumentModel(markdown: markdownData, exportConfiguration: exportConfiguration) + let markdownDataFiles: [String] = ["markdown_data_one_page", "markdown_data_two_pages"] + let knownGoodPDFFiles: [String] = ["known_good_pdf_one_page", "known_good_pdf_two_pages"] - let bundle = Bundle.module // Access the test bundle - var resourceName = "known_good_pdf" - #if os(macOS) - resourceName = "known_good_pdf_mac_os" - #elseif os(visionOS) - resourceName = "known_good_pdf_vision_os" - #endif - - guard let url = bundle.url(forResource: resourceName, withExtension: "pdf") else { - XCTFail("Failed to locate \(resourceName) in resources.") - return + 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 = await documentExport.export() { + XCTAssert(comparePDFDocuments(pdf1: pdf, pdf2: knownGoodPdf)) + } else { + XCTFail("Failed to export PDF from ConsentDocumentExport.") + } } - - guard let knownGoodPdf = PDFDocument(url: url) else { - XCTFail("Failed to load \(resourceName) from resources.") - return + } + + 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() } - #if !os(macOS) - if let pdf = await viewModel.export(personName: "Leland Stanford", signatureImage: .init()) { - XCTAssert(comparePDFDocuments(pdf1: pdf, pdf2: knownGoodPdf)) - } else { - XCTFail("Failed to export PDF from ConsentDocumentModel.") + // 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)") } - #else - if let pdf = await viewModel.export(personName: "Leland Stanford", signature: "Stanford") { - XCTAssert(comparePDFDocuments(pdf1: pdf, pdf2: knownGoodPdf)) - } else { - XCTFail("Failed to export PDF from ConsentDocumentModel.") + 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() } - #endif + + 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 { @@ -111,4 +143,5 @@ final class SpeziOnboardingTests: XCTestCase { // If all pages are identical, the documents are equal return true } + } From c7476060b75cd5f5d65832981753c831a52ff40b Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Sat, 24 Aug 2024 20:53:10 +0200 Subject: [PATCH 18/29] Fixed swiftlint issues. Added license information. --- .reuse/dep5 | 5 +++++ Tests/SpeziOnboardingTests/.gitattributes | 1 + Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift | 4 +--- 3 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 .reuse/dep5 create mode 100644 Tests/SpeziOnboardingTests/.gitattributes diff --git a/.reuse/dep5 b/.reuse/dep5 new file mode 100644 index 0000000..1b69990 --- /dev/null +++ b/.reuse/dep5 @@ -0,0 +1,5 @@ +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 \ No newline at end of file diff --git a/Tests/SpeziOnboardingTests/.gitattributes b/Tests/SpeziOnboardingTests/.gitattributes new file mode 100644 index 0000000..6ebbada --- /dev/null +++ b/Tests/SpeziOnboardingTests/.gitattributes @@ -0,0 +1 @@ +Resources/*.pdf filter=lfs diff=lfs merge=lfs -text diff --git a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift index ff36bbb..5baa760 100644 --- a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift @@ -42,7 +42,6 @@ final class SpeziOnboardingTests: XCTestCase { @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"] @@ -60,7 +59,7 @@ final class SpeziOnboardingTests: XCTestCase { let documentExport = ConsentDocumentExport( markdown: markdownData, exportConfiguration: exportConfiguration, - documentIdentifier: ConsentDocumentExport.Defaults.documentIdentifier + documentIdentifier: ConsentDocumentExport.Defaults.documentIdentifier ) documentExport.name = PersonNameComponents(givenName: "Leland", familyName: "Stanford") @@ -143,5 +142,4 @@ final class SpeziOnboardingTests: XCTestCase { // If all pages are identical, the documents are equal return true } - } From d42fa7dcf5bfb898525de564c26db2b7134773c8 Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Sat, 24 Aug 2024 20:57:27 +0200 Subject: [PATCH 19/29] Added missing files. --- .reuse/dep5 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.reuse/dep5 b/.reuse/dep5 index 1b69990..b4d839a 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -2,4 +2,12 @@ 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 + +Files: Tests/SpeziOnboardingTests/.gitattributes +Copyright: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) License: MIT \ No newline at end of file From eb1bceaca96da46697686bcdbe3b75603c1df2be Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Sat, 24 Aug 2024 21:04:52 +0200 Subject: [PATCH 20/29] Added missing comments. --- .../SpeziOnboarding/ConsentView/ConsentDocumentExport.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift index 829e208..4a6a885 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift @@ -28,11 +28,15 @@ public final class ConsentDocumentExport: Equatable, Sendable { /// 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 @@ -55,6 +59,7 @@ public final class ConsentDocumentExport: Equatable, Sendable { /// 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`. From bd5d97571f37b7454c603be8174acf00e6975bf9 Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Fri, 30 Aug 2024 15:38:10 -0700 Subject: [PATCH 21/29] Try LFS Support --- .github/workflows/build-and-test.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 67c7707..3a72e9f 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -18,7 +18,7 @@ on: jobs: buildandtest_ios: name: Build and Test Swift Package iOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs strategy: matrix: include: @@ -36,7 +36,7 @@ jobs: artifactname: ${{ matrix.artifactname }} buildandtest_ios_latest: name: Build and Test Swift Package iOS Latest - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs with: runsonlabels: '["macOS", "self-hosted"]' scheme: SpeziOnboarding @@ -46,7 +46,7 @@ jobs: artifactname: SpeziOnboarding-iOS-Latest.xcresult buildandtest_visionos: name: Build and Test Swift Package visionOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs strategy: matrix: include: @@ -65,7 +65,7 @@ jobs: artifactname: ${{ matrix.artifactname }} buildandtest_macos: name: Build and Test Swift Package macOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs strategy: matrix: include: @@ -84,7 +84,7 @@ jobs: artifactname: ${{ matrix.artifactname }} buildandtestuitests_ios: name: Build and Test UI Tests iOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs with: runsonlabels: '["macOS", "self-hosted"]' path: 'Tests/UITests' @@ -94,7 +94,7 @@ jobs: artifactname: ${{ matrix.artifactname }} buildandtestuitests_ios_latest: name: Build and Test UI Tests iOS Latest - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs with: runsonlabels: '["macOS", "self-hosted"]' path: Tests/UITests @@ -105,7 +105,7 @@ jobs: artifactname: TestApp-iOS-Latest.xcresult buildandtestuitests_ipad: name: Build and Test UI Tests iPadOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs with: runsonlabels: '["macOS", "self-hosted"]' path: 'Tests/UITests' @@ -115,7 +115,7 @@ jobs: artifactname: TestApp-iPadOS.xcresult buildandtestuitests_visionos: name: Build and Test UI Tests visionOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs with: runsonlabels: '["macOS", "self-hosted"]' path: 'Tests/UITests' From b813492dc7c7a8a4a5e0fda0c89d452511971b9e Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Fri, 30 Aug 2024 15:40:41 -0700 Subject: [PATCH 22/29] Update build-and-test.yml --- .github/workflows/build-and-test.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 3a72e9f..a217bde 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@lfs 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 }} @@ -87,6 +91,7 @@ jobs: uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs with: runsonlabels: '["macOS", "self-hosted"]' + checkout_lfs: true path: 'Tests/UITests' scheme: TestApp buildConfig: ${{ matrix.buildConfig }} @@ -97,6 +102,7 @@ jobs: uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs with: runsonlabels: '["macOS", "self-hosted"]' + checkout_lfs: true path: Tests/UITests scheme: TestApp xcodeversion: latest @@ -108,6 +114,7 @@ jobs: uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs with: runsonlabels: '["macOS", "self-hosted"]' + checkout_lfs: true path: 'Tests/UITests' scheme: TestApp destination: 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' @@ -118,6 +125,7 @@ jobs: uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs with: runsonlabels: '["macOS", "self-hosted"]' + checkout_lfs: true path: 'Tests/UITests' scheme: TestApp destination: 'platform=visionOS Simulator,name=Apple Vision Pro' From aa8492919d38e0e5467da45c27a8fb762b3a5778 Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Fri, 30 Aug 2024 16:23:07 -0700 Subject: [PATCH 23/29] Update build-and-test.yml --- .github/workflows/build-and-test.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index a217bde..56a12d1 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -18,7 +18,7 @@ on: jobs: buildandtest_ios: name: Build and Test Swift Package iOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 strategy: matrix: include: @@ -37,7 +37,7 @@ jobs: artifactname: ${{ matrix.artifactname }} buildandtest_ios_latest: name: Build and Test Swift Package iOS Latest - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: runsonlabels: '["macOS", "self-hosted"]' checkout_lfs: true @@ -48,7 +48,7 @@ jobs: artifactname: SpeziOnboarding-iOS-Latest.xcresult buildandtest_visionos: name: Build and Test Swift Package visionOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 strategy: matrix: include: @@ -68,7 +68,7 @@ jobs: artifactname: ${{ matrix.artifactname }} buildandtest_macos: name: Build and Test Swift Package macOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 strategy: matrix: include: @@ -88,7 +88,7 @@ jobs: artifactname: ${{ matrix.artifactname }} buildandtestuitests_ios: name: Build and Test UI Tests iOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: runsonlabels: '["macOS", "self-hosted"]' checkout_lfs: true @@ -99,7 +99,7 @@ jobs: artifactname: ${{ matrix.artifactname }} buildandtestuitests_ios_latest: name: Build and Test UI Tests iOS Latest - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: runsonlabels: '["macOS", "self-hosted"]' checkout_lfs: true @@ -111,7 +111,7 @@ jobs: artifactname: TestApp-iOS-Latest.xcresult buildandtestuitests_ipad: name: Build and Test UI Tests iPadOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: runsonlabels: '["macOS", "self-hosted"]' checkout_lfs: true @@ -122,7 +122,7 @@ jobs: artifactname: TestApp-iPadOS.xcresult buildandtestuitests_visionos: name: Build and Test UI Tests visionOS - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@lfs + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: runsonlabels: '["macOS", "self-hosted"]' checkout_lfs: true From 1e0c5395054d282d855ddb409f83c57b5827183c Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Wed, 4 Sep 2024 21:55:33 +0200 Subject: [PATCH 24/29] Added new pdf documents for the tests with better spacing. Readded old store function to OnboardingDataSource. Changed ConsentDocument to use guard let instead of if let. --- .reuse/dep5 | 4 --- README.md | 5 ++- .../ConsentView/ConsentDocument.swift | 10 +++--- .../OnboardingDataSource.swift | 30 ++++++++++++++++++ .../Resources/known_good_pdf_one_page_ios.pdf | Bin 130 -> 130 bytes .../known_good_pdf_one_page_mac_os.pdf | Bin 130 -> 130 bytes .../known_good_pdf_one_page_vision_os.pdf | Bin 130 -> 130 bytes .../known_good_pdf_two_pages_ios.pdf | Bin 130 -> 130 bytes .../known_good_pdf_two_pages_mac_os.pdf | Bin 130 -> 130 bytes .../known_good_pdf_two_pages_vision_os.pdf | Bin 130 -> 130 bytes .../Resources/markdown_data_one_page.md | 4 ++- .../Resources/markdown_data_two_pages.md | 16 ++++++---- .../SpeziOnboardingTests.swift | 22 +++++++++++++ 13 files changed, 74 insertions(+), 17 deletions(-) diff --git a/.reuse/dep5 b/.reuse/dep5 index b4d839a..8702f3a 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -6,8 +6,4 @@ License: MIT Files: Tests/SpeziOnboardingTests/Resources/*.md Copyright: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -License: MIT - -Files: Tests/SpeziOnboardingTests/.gitattributes -Copyright: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) License: MIT \ No newline at end of file diff --git a/README.md b/README.md index b070520..73742da 100644 --- a/README.md +++ b/README.md @@ -155,12 +155,15 @@ 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. -## Information: Running the tests locally + +## 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.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift index b5fa111..0141361 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift @@ -154,13 +154,13 @@ public struct ConsentDocument: View { .onChange(of: viewState) { if case .export = viewState { Task { - if let exportedConsent = await export() { - documentExport.cachedPDF = exportedConsent - viewState = .exported(document: exportedConsent, export: documentExport) - } else { + guard let exportedConsent = await export() else { viewState = .base(.error(Error.memoryAllocationError)) return } + + documentExport.cachedPDF = exportedConsent + viewState = .exported(document: exportedConsent, export: documentExport) } } else if case .base(let baseViewState) = viewState, case .idle = baseViewState { @@ -198,7 +198,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``. - /// - identifier: A unique identifier or "name" for the consent form, helpful for distinguishing consent forms when storing in the `Standard`. + /// - 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, diff --git a/Sources/SpeziOnboarding/OnboardingDataSource.swift b/Sources/SpeziOnboarding/OnboardingDataSource.swift index ad24d85..530f978 100644 --- a/Sources/SpeziOnboarding/OnboardingDataSource.swift +++ b/Sources/SpeziOnboarding/OnboardingDataSource.swift @@ -45,6 +45,36 @@ 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``. /// diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_ios.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_ios.pdf index 74234e19ae83bff79a599f5d430244d2fe883fa2..d0199690dc906cde1985c74d2c9c0ebbecfbc3ec 100644 GIT binary patch delta 83 zcmV~$u@QhU2nEnfn<*TDzz32YTmlW=S=(7=0!Q|}RaW_I@1wbbFil#JjW8aZAPq#K dhs(?~L}n;Vmf*B^@(6+8d{ delta 83 zcmWm2xeb6Y3;;l-%@mAa2S3RUEPLhO dR^ip*m0Ebrr92=gbQOh=h9N)qyAGDpDF3|67Ht3k delta 83 zcmV~$u@QhU2nEoy%@mG62>iqyTmlT-S=(7=0!Q|}wXN;L2~<0{Yn#?C}d$`76Y7LNb` 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 index 67633bdb2b06be68d0452d4e95e3955c0d677844..88188c7fdbc6582867b1cbe3e4e0396604a32240 100644 GIT binary patch delta 83 zcmV~$u@S%^2mruK>l7IQ5fOIC5(s#gI$cU9$jJHM$38y31UqS3HPduO@W~Zr28=q0 e)J+srAz{hjMv>{O2h6Y#T-eHUzj-1sbNK;@vzZlt diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_ios.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_two_pages_ios.pdf index 9510ce9b24b01dce1a5bb9ff506890f49f9edf3d..dc17dc9944d3dde73c6dec5820e7c72de729c34f 100644 GIT binary patch delta 83 zcmV~$yAgmO5Cy<7r3y>H`#iWFYy$8xV{B$f1(xjY+Sc~rEL0=aYa7kc%~*pRGaRZJ dZFUB%Kt1D%N-2I=kLoeS0ZGsOau?UE=?BUT75V@G delta 83 zcmWN@u@QhE3O`H6_Ee{ delta 83 zcmV~$yAgmu2nE0~y(%n0;N6EFYyyFsF*Y;23M|>(wXN-IXBy@hkZ`yG%#|v|;Z@Nr eK&~Q0qt~=PN3=^7$P|%`*)7U*zqxZ~*75_uI2N1$ 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 index 24c142eeac0964cd69567625f654222853d09857..773bcd61880df399494360b6bddbf89a44e1b77a 100644 GIT binary patch delta 83 zcmV~$u@QhE3G8V)GF;(HG dTb(vW0|r>T2CSn)XrgvAVMKoJ*AEgZ$UpDw7Y6_U delta 83 zcmV~$u@QhU2nEnfn<*Rt0wJ)2Q~Vh2tnDl_fg}6gF1vhoI=n4I3P$iy4bO0z3oH{F c{p!7$F<}g6qLj`8B+L{FaDDD~9Zj6L{;*aR{r~^~ diff --git a/Tests/SpeziOnboardingTests/Resources/markdown_data_one_page.md b/Tests/SpeziOnboardingTests/Resources/markdown_data_one_page.md index af47621..d9c4053 100644 --- a/Tests/SpeziOnboardingTests/Resources/markdown_data_one_page.md +++ b/Tests/SpeziOnboardingTests/Resources/markdown_data_one_page.md @@ -1 +1,3 @@ -This is a **markdown** example +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 index 4f29ebe..deb6be5 100644 --- a/Tests/SpeziOnboardingTests/Resources/markdown_data_two_pages.md +++ b/Tests/SpeziOnboardingTests/Resources/markdown_data_two_pages.md @@ -1,13 +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. -Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. +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. -Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. +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. -Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. +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. -Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis. +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. -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 \ No newline at end of file +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 5baa760..0a39b0f 100644 --- a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift @@ -80,6 +80,7 @@ final class SpeziOnboardingTests: XCTestCase { #endif if let pdf = await documentExport.export() { + savePDF(fileName: pdfPath, pdfDocument: pdf) XCTAssert(comparePDFDocuments(pdf1: pdf, pdf2: knownGoodPdf)) } else { XCTFail("Failed to export PDF from ConsentDocumentExport.") @@ -142,4 +143,25 @@ final class SpeziOnboardingTests: XCTestCase { // If all pages are identical, the documents are equal return true } + + func savePDF(fileName: String, pdfDocument: PDFDocument) -> 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 + } + } } From 448e0e47d7eae307cd41de994b17a897c944a066 Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Wed, 4 Sep 2024 21:58:58 +0200 Subject: [PATCH 25/29] Resolved swiftlint issues. --- .gitattributes | 14 ++++++++++++++ Sources/SpeziOnboarding/OnboardingDataSource.swift | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 .gitattributes 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/Sources/SpeziOnboarding/OnboardingDataSource.swift b/Sources/SpeziOnboarding/OnboardingDataSource.swift index 530f978..6746e6b 100644 --- a/Sources/SpeziOnboarding/OnboardingDataSource.swift +++ b/Sources/SpeziOnboarding/OnboardingDataSource.swift @@ -56,7 +56,7 @@ public class OnboardingDataSource: Module, EnvironmentAccessible { deprecated, message: """ Storing consent documents using an exported PDF and an identifier is deprecated. - Please store the consent document from the corresponding `ConsentDocumentExport`, + Please store the consent document from the corresponding `ConsentDocumentExport`, by using `ConsentConstraint.store(_ consent: ConsentDocumentExport)` instead. """ ) From af181aaa74b0da0b8c66ef2d3eea69eda8de7059 Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Wed, 4 Sep 2024 22:06:45 +0200 Subject: [PATCH 26/29] Removed leftover code. --- .../SpeziOnboardingTests.swift | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift index 0a39b0f..5baa760 100644 --- a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift @@ -80,7 +80,6 @@ final class SpeziOnboardingTests: XCTestCase { #endif if let pdf = await documentExport.export() { - savePDF(fileName: pdfPath, pdfDocument: pdf) XCTAssert(comparePDFDocuments(pdf1: pdf, pdf2: knownGoodPdf)) } else { XCTFail("Failed to export PDF from ConsentDocumentExport.") @@ -143,25 +142,4 @@ final class SpeziOnboardingTests: XCTestCase { // If all pages are identical, the documents are equal return true } - - func savePDF(fileName: String, pdfDocument: PDFDocument) -> 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 - } - } } From 26978556adfe964014a63def57ea1e5cbaeda937 Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Wed, 4 Sep 2024 22:34:47 +0200 Subject: [PATCH 27/29] Made PDF export throwing if PDF generation fails. A possible exception propagates up to the ConsentDocumentExport. --- .../ConsentView/ConsentDocument+Export.swift | 6 ++--- .../ConsentView/ConsentDocument.swift | 2 +- .../ConsentDocumentExport+Export.swift | 24 ++++++++--------- .../ConsentView/ConsentDocumentExport.swift | 14 +++++----- .../ConsentDocumentExportError.swift | 27 +++++++++++++++++++ .../OnboardingDataSource.swift | 2 +- .../Resources/Localizable.xcstrings | 3 +++ Tests/SpeziOnboardingTests/.gitattributes | 7 +++++ .../SpeziOnboardingTests.swift | 2 +- Tests/UITests/TestApp/ExampleStandard.swift | 2 +- 10 files changed, 62 insertions(+), 27 deletions(-) create mode 100644 Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportError.swift diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index bd2a3f3..9408dbe 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -49,14 +49,14 @@ extension ConsentDocument { /// /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` @MainActor - func export() async -> PDFKit.PDFDocument? { + func export() async throws -> PDFKit.PDFDocument { documentExport.signature = signature documentExport.name = name #if !os(macOS) documentExport.signatureImage = blackInkSignatureImage - return await documentExport.export() + return try await documentExport.export() #else - return await documentExport.export() + return try await documentExport.export() #endif } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift index 0141361..470e3f6 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift @@ -154,7 +154,7 @@ public struct ConsentDocument: View { .onChange(of: viewState) { if case .export = viewState { Task { - guard let exportedConsent = await export() else { + guard let exportedConsent = try? await export() else { viewState = .base(.error(Error.memoryAllocationError)) return } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift index 12f3ff0..c685d9e 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift @@ -158,7 +158,7 @@ extension ConsentDocumentExport { pdfTextContent: PDFAttributedText, signatureFooter: PDFGroup, exportTimeStamp: PDFAttributedText? = nil - ) async -> PDFKit.PDFDocument? { + ) async throws -> PDFKit.PDFDocument { let document = TPPDF.PDFDocument(format: exportConfiguration.getPDFPageFormat()) if let exportStamp = exportTimeStamp { @@ -172,15 +172,13 @@ extension ConsentDocumentExport { // Convert TPPDF.PDFDocument to PDFKit.PDFDocument let generator = PDFGenerator(document: document) - if let data = try? generator.generateData() { - if let pdfKitDocument = PDFKit.PDFDocument(data: data) { - return pdfKitDocument - } else { - return nil - } - } else { - return nil + let data = try generator.generateData() + + guard let pdfDocument = PDFKit.PDFDocument(data: data) else { + throw ConsentDocumentExportError.invalidPdfData("PDF data not compatible with PDFDocument") } + + return pdfDocument } #if !os(macOS) @@ -193,13 +191,13 @@ extension ConsentDocumentExport { /// - signatureImage: Signature drawn when signing the document. /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` @MainActor - public func export() async -> PDFKit.PDFDocument? { + public func export() async throws -> PDFKit.PDFDocument{ let exportTimeStamp = exportConfiguration.includingTimestamp ? exportTimeStamp() : nil let header = exportHeader() let pdfTextContent = await exportDocumentContent() let signature = exportSignature() - return await createDocument( + return try await createDocument( header: header, pdfTextContent: pdfTextContent, signatureFooter: signature, @@ -215,13 +213,13 @@ extension ConsentDocumentExport { /// - personName: A string containing the name of the person who signed the document. /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` @MainActor - public func export() async -> PDFKit.PDFDocument? { + public func export() async throws -> PDFKit.PDFDocument { let exportTimeStamp = exportConfiguration.includingTimestamp ? exportTimeStamp() : nil let header = exportHeader() let pdfTextContent = await exportDocumentContent() let signature = exportSignature() - return await createDocument( + return try await createDocument( header: header, pdfTextContent: pdfTextContent, signatureFooter: signature, diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift index 4a6a885..b1bcec2 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift @@ -45,14 +45,14 @@ public final class ConsentDocumentExport: Equatable, Sendable { /// 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. @MainActor public var pdf: PDFDocument { - get async { - if let cached = cachedPDF { - return cached - } else { - cachedPDF = await export() - // If the export failed, return an empty document. - return cachedPDF ?? .init() + get async throws { + if let pdf = cachedPDF { + return pdf } + + let pdf = try await export() + cachedPDF = pdf + return pdf } } 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/OnboardingDataSource.swift b/Sources/SpeziOnboarding/OnboardingDataSource.swift index 6746e6b..e78e330 100644 --- a/Sources/SpeziOnboarding/OnboardingDataSource.swift +++ b/Sources/SpeziOnboarding/OnboardingDataSource.swift @@ -83,7 +83,7 @@ public class OnboardingDataSource: Module, EnvironmentAccessible { if let consentConstraint = standard as? any ConsentConstraint { try await consentConstraint.store(consent: consent) } else if let onboardingConstraint = standard as? any OnboardingConstraint { - let pdf = await consent.pdf + 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 index 6ebbada..3d6f2c3 100644 --- a/Tests/SpeziOnboardingTests/.gitattributes +++ b/Tests/SpeziOnboardingTests/.gitattributes @@ -1 +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/SpeziOnboardingTests.swift b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift index 5baa760..850c5bb 100644 --- a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift @@ -79,7 +79,7 @@ final class SpeziOnboardingTests: XCTestCase { documentExport.signature = "Stanford" #endif - if let pdf = await documentExport.export() { + if let pdf = try? await documentExport.export() { XCTAssert(comparePDFDocuments(pdf1: pdf, pdf2: knownGoodPdf)) } else { XCTFail("Failed to export PDF from ConsentDocumentExport.") 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 { From 9194b05f458c1a058893e7b5d89ae53aa32b9aa8 Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Wed, 4 Sep 2024 22:40:33 +0200 Subject: [PATCH 28/29] Resolved swiftlint issue. --- .../ConsentView/ConsentDocumentExport+Export.swift | 2 +- Tests/UITests/UITests.xcodeproj/project.pbxproj | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift index c685d9e..a9f5053 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift @@ -191,7 +191,7 @@ extension ConsentDocumentExport { /// - signatureImage: Signature drawn when signing the document. /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` @MainActor - public func export() async throws -> PDFKit.PDFDocument{ + public func export() async throws -> PDFKit.PDFDocument { let exportTimeStamp = exportConfiguration.includingTimestamp ? exportTimeStamp() : nil let header = exportHeader() let pdfTextContent = await exportDocumentContent() 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; From 7abf6cc942e929975c5308f23bef8a46aff5cdd4 Mon Sep 17 00:00:00 2001 From: Patrick Langer Date: Wed, 30 Oct 2024 16:55:51 +0100 Subject: [PATCH 29/29] Introduced ExportConfiguration.FontSettings to enable more precise control over appearance of the exported ConsentDocument. Added Defaults to ExportConfiguration to specify default FontSettings. Improved PDF export unit test. Generated new known good PDFs for PDF export unit test on macOS, iOS and visionOS, using fixed font sizes, to ensure similiar appearance across platforms. Removed "testOnboardingConsentPDFExport" from UI test. --- .../ConsentDocument+ExportConfiguration.swift | 86 +++++++++++- .../ConsentView/ConsentDocument.swift | 15 +- .../ConsentDocumentExport+Export.swift | 101 ++------------ .../ConsentView/ConsentDocumentExport.swift | 7 +- .../ExportConfiguration+Defaults.swift | 63 +++++++++ .../Resources/known_good_pdf_one_page_ios.pdf | Bin 130 -> 130 bytes .../known_good_pdf_one_page_mac_os.pdf | Bin 130 -> 130 bytes .../known_good_pdf_one_page_vision_os.pdf | Bin 130 -> 130 bytes .../known_good_pdf_two_pages_ios.pdf | Bin 130 -> 130 bytes .../known_good_pdf_two_pages_mac_os.pdf | Bin 130 -> 130 bytes .../known_good_pdf_two_pages_vision_os.pdf | Bin 130 -> 130 bytes .../SpeziOnboardingTests.swift | 21 +++ .../UITests/TestApp/OnboardingTestsView.swift | 2 +- .../TestAppUITests/SpeziOnboardingTests.swift | 131 +----------------- 14 files changed, 199 insertions(+), 227 deletions(-) create mode 100644 Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift 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 470e3f6..0e8fc66 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift @@ -154,13 +154,16 @@ public struct ConsentDocument: View { .onChange(of: viewState) { if case .export = viewState { Task { - guard let exportedConsent = try? 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))) } - - documentExport.cachedPDF = exportedConsent - viewState = .exported(document: exportedConsent, export: documentExport) } } else if case .base(let baseViewState) = viewState, case .idle = baseViewState { diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift index a9f5053..bd069d0 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift @@ -20,16 +20,10 @@ extension ConsentDocumentExport { let stampText = String(localized: "EXPORTED_TAG", bundle: .module) + ": " + DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short) + "\n\n\n\n" - #if !os(macOS) - let font = UIFont.preferredFont(forTextStyle: .caption1) - #else - let font = NSFont.preferredFont(forTextStyle: .caption1) - #endif - let attributedTitle = NSMutableAttributedString( string: stampText, attributes: [ - NSAttributedString.Key.font: font + NSAttributedString.Key.font: exportConfiguration.fontSettings.headerExportTimeStampFont ] ) @@ -41,18 +35,10 @@ extension ConsentDocumentExport { /// /// - Returns: A TPPDF `PDFAttributedText` representation of the document title. private func exportHeader() -> PDFAttributedText { - #if !os(macOS) - let largeTitleFont = UIFont.preferredFont(forTextStyle: .largeTitle) - let boldLargeTitleFont = UIFont.boldSystemFont(ofSize: largeTitleFont.pointSize) - #else - let largeTitleFont = NSFont.preferredFont(forTextStyle: .largeTitle) - let boldLargeTitleFont = NSFont.boldSystemFont(ofSize: largeTitleFont.pointSize) - #endif - let attributedTitle = NSMutableAttributedString( string: exportConfiguration.consentTitle.localizedString() + "\n\n", attributes: [ - NSAttributedString.Key.font: boldLargeTitleFont + NSAttributedString.Key.font: exportConfiguration.fontSettings.headerTitleFont ] ) @@ -66,82 +52,52 @@ extension ConsentDocumentExport { @MainActor private func exportDocumentContent() async -> PDFAttributedText { let markdown = await asyncMarkdown() - let markdownString = (try? AttributedString( + 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)) } - #if !os(macOS) /// 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. /// - /// - Parameters: - /// - personName: A string containing the name of the person who signed the document. - /// - signatureImage: Signature drawn when signing the document. /// - 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 signaturePrefixFont = UIFont.preferredFont(forTextStyle: .title2) - let nameFont = UIFont.preferredFont(forTextStyle: .subheadline) - let signatureColor = UIColor.secondaryLabel let signaturePrefix = "X" - - group.set(font: signaturePrefixFont) - group.set(textColor: signatureColor) - group.add(PDFGroupContainer.left, text: signaturePrefix) - - group.addLineSeparator(style: PDFLineStyle(color: .black)) - - group.set(font: nameFont) - group.add(PDFGroupContainer.left, text: personName) - return group - } - #else - /// 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. - /// - /// - Parameters: - /// - personName: A string containing the name of the person who signed the document. - /// - Returns: A TPPDF `PDFAttributedText` representation of the export time stamp. - @MainActor - private func exportSignature() -> PDFGroup { - let personName = name.formatted(.name(style: .long)) - - // On macOS, we do not have a "drawn" signature, hence do + #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 signaturePrefixFont = NSFont.preferredFont(forTextStyle: .title2) - let nameFont = NSFont.preferredFont(forTextStyle: .subheadline) - let signatureColor = NSColor.secondaryLabelColor let signaturePrefix = "X " + signature - - group.set(font: signaturePrefixFont) - group.set(textColor: signatureColor) + #endif + + group.set(font: exportConfiguration.fontSettings.signaturePrefixFont) group.add(PDFGroupContainer.left, text: signaturePrefix) group.addLineSeparator(style: PDFLineStyle(color: .black)) - group.set(font: nameFont) + group.set(font: exportConfiguration.fontSettings.signatureNameFont) group.add(PDFGroupContainer.left, text: personName) return group } - #endif + /// Creates a `PDFKit.PDFDocument` containing the header, content and signature from the exported `ConsentDocument`. /// An export time stamp can be added optionally. @@ -177,40 +133,14 @@ extension ConsentDocumentExport { guard let pdfDocument = PDFKit.PDFDocument(data: data) else { throw ConsentDocumentExportError.invalidPdfData("PDF data not compatible with PDFDocument") } - + return pdfDocument } - #if !os(macOS) /// 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``. /// - /// - Parameters: - /// - personName: A string containing the name of the person who signed the document. - /// - signatureImage: Signature drawn when signing the document. - /// - 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 - ) - } - #else - /// 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``. - /// - /// - Parameters: - /// - personName: A string containing the name of the person who signed the document. /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` @MainActor public func export() async throws -> PDFKit.PDFDocument { @@ -226,5 +156,4 @@ extension ConsentDocumentExport { exportTimeStamp: exportTimeStamp ) } - #endif } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift index b1bcec2..462c354 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift @@ -45,12 +45,15 @@ public final class ConsentDocumentExport: Equatable, Sendable { /// 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. @MainActor public var pdf: PDFDocument { - get async throws { + get async { if let pdf = cachedPDF { return pdf } - let pdf = try await export() + guard let pdf = try? await export() else { + return .init() + } + cachedPDF = pdf return pdf } 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/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_ios.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_ios.pdf index d0199690dc906cde1985c74d2c9c0ebbecfbc3ec..769a4db8f48a5929dc929aca06d11c7907f31dff 100644 GIT binary patch delta 83 zcmV~$yAgmO3;@uhWeP_K`9XGY2@=DdwVfpsII{2UvdgDuH}IiL$(bM_BpA(xcN~Dk d-B+Ls%S-__OT=iT!7F*DwW~h&n@1a>P=BG~76kwR delta 83 zcmV~$u@QhU2nEnfn<*TDzz32YTmlW=S=(7=0!Q|}RaW_I@1wbbFil#JjW8aZAPq#K dhs(?~L}n;Vmf*B^@(6+8d{ 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 index 7a954e6f8791f611ae323ca7bad3694e24685f9f..d67492b9c5ba2dccf0151a60048347f6bf61e7c7 100644 GIT binary patch delta 83 zcmV~$yAgmO3;@uhWeP_K`9XGYi3G!)wVfpsII{2UvdgC@PDWW`;L2%>nkWG{Sb!N1 d7HTo4!xflf)VmD~cUamjk*GfRn@5wTT7Q@y6`TM7 delta 83 zcmV~$u@QhE3LhO dR^ip*m0Ebrr92=gbQOh=h9N)qyAGDpDF3|67Ht3k 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 index 88188c7fdbc6582867b1cbe3e4e0396604a32240..3979b41b865e672aa986d6514918fd1d0e8abb15 100644 GIT binary patch delta 83 zcmWm0xeb6Y3;;mUW(r1#<9`R1Fo`2k(@|yuMn+t9Yg^kZM`MK45i^{W#L4S_P|l7IQ5fOIC5(s#gI$cU9$jJHM$38y31UqS3HPduO@W~Zr28=q0 e)J+srAz{hjMv>{O2h6Y#T-eHUzj-1sbNKH`#iWFYy$8xV{B$f1(xjY+Sc~rEL0=aYa7kc%~*pRGaRZJ dZFUB%Kt1D%N-2I=kLoeS0ZGsOau?UE=?BUT75V@G 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 index 4a6ff9182fcf22db2700a948f749ff1602766918..1fb5f036d39268292cef32d4064c57b5e0dafb20 100644 GIT binary patch delta 83 zcmV~$u@QhE3O`H6_Ee{ 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 index 773bcd61880df399494360b6bddbf89a44e1b77a..c690e464d0f10021c50c6b5a5b3a54980194266c 100644 GIT binary patch delta 83 zcmWN_yAgmO3;@uxWeP`t{780i34+O;wVfpsII_O-t!-_u9HDFyW6ZE5HA;2{$QJQy4A6n1)+(U?WoS_D>xmtRJC+aLgBCvk delta 83 zcmV~$u@QhE3G8V)GF;(HG dTb(vW0|r>T2CSn)XrgvAVMKoJ*AEgZ$UpDw7Y6_U diff --git a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift index 850c5bb..3252dd6 100644 --- a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift @@ -142,4 +142,25 @@ final class SpeziOnboardingTests: XCTestCase { // If all pages are identical, the documents are equal return true } + + func savePDF(fileName: String, pdfDocument: PDFDocument) -> 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/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()