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

Open
wants to merge 32 commits into
base: main
Choose a base branch
from
Open
Changes from 4 commits
Commits
Show all changes
32 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
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
246 changes: 178 additions & 68 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 @@ -10,10 +10,9 @@
import PencilKit
import SwiftUI


/// 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 {
Expand All @@ -26,23 +25,22 @@
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`.
///
Expand All @@ -54,45 +52,7 @@
/// - 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`.
///
Expand All @@ -102,41 +62,191 @@
@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 pages = paginatedViews(markdown: markdownString)

let paperSize = CGSize(

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package macOS (Debug, SpeziOnboarding-macOS.xcresult, SpeziOnboarding-macOS.... / Test using xcodebuild or run fastlane

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package iOS (Debug, SpeziOnboarding-iOS.xcresult, SpeziOnboarding-iOS.xcresult) / Test using xcodebuild or run fastlane

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package iOS (Release, SpeziOnboarding-iOS-Release.xcresult, SpeziOnboarding-... / Test using xcodebuild or run fastlane

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package macOS (Release, SpeziOnboarding-macOS-Release.xcresult, SpeziOnboard... / Test using xcodebuild or run fastlane

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package visionOS (Debug, SpeziOnboarding-visionOS.xcresult, SpeziOnboarding-... / Test using xcodebuild or run fastlane

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package visionOS (Release, SpeziOnboarding-visionOS-Release.xcresult, SpeziO... / Test using xcodebuild or run fastlane

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift

View workflow job for this annotation

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

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.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

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.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

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift

View workflow job for this annotation

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

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.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

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.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

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift

View workflow job for this annotation

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

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.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

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 73 in Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.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

initialization of immutable value 'paperSize' was never used; consider replacing with assignment to '_' or removing it
width: exportConfiguration.paperSize.dimensions.width,
height: exportConfiguration.paperSize.dimensions.height
)
renderer.proposedSize = .init(paperSize)

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 {
return nil
}

for page in pages {
let renderer = ImageRenderer(content: page)

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

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.

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
}
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()
}
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)
}
}
Loading