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
Changes from 1 commit
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
260 changes: 186 additions & 74 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,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 {
Expand All @@ -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`.
///
Expand All @@ -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`.
///
Expand All @@ -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))
PSchmiedmayer marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

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