Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added algorithm for pagination to PDF export, allowing to export consent forms with more than 1 page (#49) #52

Merged
merged 48 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
ec18eab
Introduced algorithm to split exported consent document across multip…
RealLast Jun 22, 2024
4ee81b1
Removed leftover continuation from older version of the code.
RealLast Jun 22, 2024
457b273
Resolved swiftlint issues.
RealLast Jun 26, 2024
42c2443
Removed unused variables, further cleaned up code.
RealLast Jun 27, 2024
ee2f2f4
Changed PDF export to use TPPDF for PDF generation, instead of creati…
RealLast Jul 11, 2024
23e5333
Changed personName -> signature.
RealLast Jul 11, 2024
a947d7d
Reverted changes.
RealLast Jul 11, 2024
5fbc2a7
Update iPad Testing Identifier.
RealLast Jul 12, 2024
8ec56a2
Update Tests and try Beta 4
PSchmiedmayer Aug 3, 2024
5238a7b
Update Tests
PSchmiedmayer Aug 3, 2024
a417fa9
Update GitHub Action
PSchmiedmayer Aug 3, 2024
f2e767c
Merging in changes from main.
RealLast Aug 14, 2024
a11df71
Separated PDF export functionality from ConsentDocument. Added type C…
RealLast Aug 14, 2024
2d793db
Added known good PDF files for iOS, macOS and visionOS, which is used…
RealLast Aug 14, 2024
c7cc518
Added known-good PDF documents to test against in testPDFExport.
RealLast Aug 14, 2024
0d287e5
Merge remote-tracking branch 'upstream/main' into PDFPagination
RealLast Aug 24, 2024
e6080f6
Merged in changes from #52.
RealLast Aug 24, 2024
d15f5e3
Resolved errors on macOS.
RealLast Aug 24, 2024
4e919db
Expanded unit test for PDF export to include documents with two pages.
RealLast Aug 24, 2024
c747606
Fixed swiftlint issues. Added license information.
RealLast Aug 24, 2024
d42fa7d
Added missing files.
RealLast Aug 24, 2024
eb1bcea
Added missing comments.
RealLast Aug 24, 2024
b716ada
Merge branch 'main' into PDFPagination
PSchmiedmayer Aug 30, 2024
bd5d975
Try LFS Support
PSchmiedmayer Aug 30, 2024
b813492
Update build-and-test.yml
PSchmiedmayer Aug 30, 2024
aa84929
Update build-and-test.yml
PSchmiedmayer Aug 30, 2024
1e0c539
Added new pdf documents for the tests with better spacing.
RealLast Sep 4, 2024
448e0e4
Resolved swiftlint issues.
RealLast Sep 4, 2024
af181aa
Removed leftover code.
RealLast Sep 4, 2024
2697855
Made PDF export throwing if PDF generation fails. A possible exceptio…
RealLast Sep 4, 2024
9194b05
Resolved swiftlint issue.
RealLast Sep 4, 2024
7abf6cc
Introduced ExportConfiguration.FontSettings to enable more precise co…
RealLast Oct 30, 2024
f8edd68
Update Dependencies & Retrigger Build
PSchmiedmayer Jan 20, 2025
aa16be4
Update SwiftLint
PSchmiedmayer Jan 20, 2025
5675af8
Fix Builds
PSchmiedmayer Jan 20, 2025
3c0e6bf
Update Markdown Link Check
PSchmiedmayer Jan 20, 2025
a2632b8
Back
PSchmiedmayer Jan 20, 2025
4820d75
And Use Action Again
PSchmiedmayer Jan 20, 2025
9ac9bd1
Update GitHub Action
PSchmiedmayer Jan 20, 2025
2c47a1f
Update GitHub Action
PSchmiedmayer Jan 20, 2025
69365d7
Update GitHub Action
PSchmiedmayer Jan 20, 2025
0091173
Update GitHub Action
PSchmiedmayer Jan 20, 2025
45d95c4
Update GitHub Action
PSchmiedmayer Jan 20, 2025
5ed2d83
Update Setup
PSchmiedmayer Jan 20, 2025
90c7031
Update Setup
PSchmiedmayer Jan 20, 2025
95589b3
Use Updated GitHub Actions
PSchmiedmayer Jan 20, 2025
65b1802
Update Actions
PSchmiedmayer Jan 20, 2025
7372063
Update Actions
PSchmiedmayer Jan 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
6 changes: 4 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
199 changes: 122 additions & 77 deletions Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift
RealLast marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import PDFKit
import PencilKit
import SwiftUI

import TPPDF

/// Extension of `ConsentDocument` enabling the export of the signed consent page.
extension ConsentDocument {
Expand All @@ -18,7 +18,7 @@ extension ConsentDocument {
/// force the ink used in the `UIImage` of the `PKDrawing` to always be black by adjusting the signature ink according to the color scheme.
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),
Expand All @@ -43,100 +43,145 @@ extension ConsentDocument {
}
#endif


/// Creates a representation of the consent form that is ready to be exported via the SwiftUI `ImageRenderer`.
///
/// - Parameters:
/// - markdown: The markdown consent content as an `AttributedString`.
/// Generates a `PDFAttributedText` containing the timestamp of when the PDF was exported.
///
/// - Returns: A SwiftUI `View` representation of the consent content and signature.
/// - 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.
///
/// - 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()
/// - 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

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)
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)
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
}
#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
}

/// 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 {
RealLast marked this conversation as resolved.
Show resolved Hide resolved
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? {
func export() async -> PDFKit.PDFDocument? {
RealLast marked this conversation as resolved.
Show resolved Hide resolved
// 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 renderer = ImageRenderer(content: exportBody(markdown: markdownString))
let paperSize = CGSize(
width: exportConfiguration.paperSize.dimensions.width,
height: exportConfiguration.paperSize.dimensions.height
)
renderer.proposedSize = .init(paperSize)
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)

return await withCheckedContinuation { continuation in
renderer.render { _, context in
var box = CGRect(origin: .zero, size: paperSize)

/// Create in-memory `CGContext` that stores the PDF
guard let mutableData = CFDataCreateMutable(kCFAllocatorDefault, 0),
let consumer = CGDataConsumer(data: mutableData),
let pdf = CGContext(consumer: consumer, mediaBox: &box, nil) else {
continuation.resume(returning: nil)
return
}

pdf.beginPDFPage(nil)
pdf.translateBy(x: 0, y: 0)

context(pdf)

pdf.endPDFPage()
pdf.closePDF()

continuation.resume(returning: PDFDocument(data: mutableData as Data))
if let data = try? generator.generateData() {
if let pdfKitDocument = PDFKit.PDFDocument(data: data) {
return pdfKitDocument
} else {
return nil
}
} else {
return nil
}
}
}
91 changes: 14 additions & 77 deletions Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
throw XCTSkip("PKCanvas view-related tests are currently skipped on Intel-based iOS simulators due to a metal bug on the simulator.")
#endif

XCTAssert(app.staticTexts["First Name"].waitForExistence(timeout: 2))

Check warning on line 52 in Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests iOS (Release, TestApp-iOS-Release.xcresult, TestApp-iOS-Release.xcresult) / Test using xcodebuild or run fastlane

code after 'throw' will never be executed

Check warning on line 52 in Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests iPadOS (Release, TestApp-iPad-Release.xcresult, TestApp-iPad-Release.xcre... / Test using xcodebuild or run fastlane

code after 'throw' will never be executed

Check warning on line 52 in Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests visionOS (Release, TestApp-visionOS-Release.xcresult, TestApp-visionOS-Re... / Test using xcodebuild or run fastlane

code after 'throw' will never be executed
try app.textFields["Enter your first name ..."].enter(value: "Leland")

XCTAssert(app.staticTexts["Last Name"].waitForExistence(timeout: 2))
Expand Down Expand Up @@ -174,7 +174,7 @@
throw XCTSkip("PKCanvas view-related tests are currently skipped on Intel-based iOS simulators due to a metal bug on the simulator.")
#endif

XCTAssert(app.staticTexts["First Name"].waitForExistence(timeout: 2))

Check warning on line 177 in Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests iOS (Release, TestApp-iOS-Release.xcresult, TestApp-iOS-Release.xcresult) / Test using xcodebuild or run fastlane

code after 'throw' will never be executed

Check warning on line 177 in Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests iPadOS (Release, TestApp-iPad-Release.xcresult, TestApp-iPad-Release.xcre... / Test using xcodebuild or run fastlane

code after 'throw' will never be executed

Check warning on line 177 in Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests visionOS (Release, TestApp-visionOS-Release.xcresult, TestApp-visionOS-Re... / Test using xcodebuild or run fastlane

code after 'throw' will never be executed
try app.textFields["Enter your first name ..."].enter(value: "Leland")

XCTAssert(app.staticTexts["Last Name"].waitForExistence(timeout: 2))
Expand Down Expand Up @@ -232,7 +232,7 @@
throw XCTSkip("PKCanvas view-related tests are currently skipped on Intel-based iOS simulators due to a metal bug on the simulator.")
#endif

XCTAssert(app.staticTexts["First Name"].waitForExistence(timeout: 2))

Check warning on line 235 in Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests iOS (Release, TestApp-iOS-Release.xcresult, TestApp-iOS-Release.xcresult) / Test using xcodebuild or run fastlane

code after 'throw' will never be executed

Check warning on line 235 in Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests visionOS (Release, TestApp-visionOS-Release.xcresult, TestApp-visionOS-Re... / Test using xcodebuild or run fastlane

code after 'throw' will never be executed
try app.textFields["Enter your first name ..."].enter(value: "Leland")

XCTAssert(app.staticTexts["Last Name"].waitForExistence(timeout: 2))
Expand All @@ -256,11 +256,9 @@
}

#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))
Expand All @@ -280,82 +278,21 @@
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.
// 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)
let fileView = XCUIApplication(bundleIdentifier: "com.apple.MRQuickLook")
// on visionOS the save to files button has no label
XCTAssert(app.cells["XCElementSnapshotPrivilegedValuePlaceholder"].waitForExistence(timeout: 10))
#else
let fileView = filesApp
#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))
}

#if os(iOS)
// Close File
XCTAssert(fileView.buttons["Done"].waitForExistence(timeout: 2))
fileView.buttons["Done"].tap()
RealLast marked this conversation as resolved.
Show resolved Hide resolved
XCTAssert(app.staticTexts["Save to Files"].waitForExistence(timeout: 10))
#endif
}
#endif
Expand Down
Loading