Create pdf with WKWebView.pdf(configuration:) - swift

I want to create a pdf on macOS with the new WKWebView.pdf(configuration:) which was introduced in macOS 12/iOS 15. It tries to make use of the new async/await functionality (which I most likely have not grasped entirely I am afraid...).
Right now, I am getting an error that I have no idea how to handle:
Error Domain=WKErrorDomain Code=1 "An unknown error occurred"
UserInfo={NSLocalizedDescription=An unknown error occurred}
I try to load a html string into a web view, which I then want to generate the pdf from. The function I use to generate my PDFDocument looks like this:
func generatePdf() async {
let webView = WKWebView()
await webView.loadHTMLString(html, baseURL: nil)
let config = WKPDFConfiguration()
config.rect = .init(origin: .zero, size: .init(width: 595.28, height: 841.89))
do {
//this is where the error is happening
let pdfData = try await webView.pdf(configuration: config)
self.pdf = PDFDocument(data: pdfData)
} catch {
print(error) //this error gets printed
}
}
My best guess as it currently stands is that WKWebView's loadHTMLString has not finished loading the html–I did allow for outgoing connection in the app sandbox that's not it...
For the sake of completeness, here's the entire code:
import SwiftUI
import PDFKit
import WebKit
#main
struct AFPdfApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#StateObject private var vm = ViewModel()
var body: some View {
VStack {
TextEditor(text: $vm.html)
.frame(width: 300.0, height: 200.0)
.border(Color.accentColor, width: 1.0)
.padding()
PdfViewWrapper(pdfDocument: $vm.pdf)
}
.toolbar {
Button("Create PDF") {
Task {
await vm.generatePdf()
}
}
}
}
static let initHtml = """
<h1>Some fancy html</h1>
<h2>…and now how do I create a pdf from this?</h2>
"""
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
class ViewModel: ObservableObject {
#Published var html = """
<h1>Some fancy html</h1>
<h2>…and now let's create some pdf…</h2>
"""
#Published var pdf: PDFDocument? = nil
func generatePdf() async {
let webView = WKWebView()
await webView.loadHTMLString(html, baseURL: nil)
let config = WKPDFConfiguration()
config.rect = .init(origin: .zero, size: .init(width: 595.28, height: 841.89))
do {
let pdfData = try await webView.pdf(configuration: config)
self.pdf = PDFDocument(data: pdfData)
} catch {
print(error)
}
}
}
struct PdfViewWrapper: NSViewRepresentable {
#Binding var pdfDocument: PDFDocument?
func makeNSView(context: Context) -> PDFView {
return PDFView()
}
func updateNSView(_ nsView: PDFView, context: Context) {
nsView.document = pdfDocument
}
}

After Chris made me take another look at it (thanks for that :-) ) I am now a step closer to a working solution.
It really seems as though I really have to wait for the webView to load the html prior to creating the pdf. While I was not able to make it work with WKWebView.pdf(configuration:), I now have a (kind of…) working solution by using WKWebView.createPDF(configuration:completionHandler:):
func generatePdf() {
webView.loadHTMLString(htmlString, baseURL: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let config = WKPDFConfiguration()
config.rect = .init(origin: .zero, size: .init(width: 595.28, height: 841.89))
self.webView.createPDF(configuration: config){ result in
switch result {
case .success(let data):
self.pdf = PDFDocument(data: data)
case .failure(let error):
print(error)
}
}
}
}
I said "kind of works" above, because the resulting pdf seems to introduce a new line after each word, which is weird–but I will scope that issue to another research/question on SO.
Again, for the sake of completeness, here's the whole "app":
import SwiftUI
import PDFKit
import WebKit
#main
struct AFPdfApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
//MARK: View
struct ContentView: View {
#StateObject private var vm = ViewModel()
var body: some View {
VStack {
TextEditor(text: $vm.htmlString)
.frame(width: 300.0, height: 200.0)
.border(Color.accentColor, width: 1.0)
.padding()
WebViewWrapper(htmlString: $vm.htmlString)
PdfViewRepresentable(pdfDocument: $vm.pdf)
}
.toolbar {
Button("Create PDF") {
Task {
vm.generatePdf()
}
}
}
}
}
//MARK: ViewModel
class ViewModel: ObservableObject {
#Published var htmlString = """
<h1>Some fancy html</h1>
<h2>…and now let's create some pdf…</h2>
"""
#Published var webView = WKWebView()
#Published var pdf: PDFDocument? = nil
func generatePdf() {
webView.loadHTMLString(htmlString, baseURL: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let config = WKPDFConfiguration()
config.rect = .init(origin: .zero, size: .init(width: 595.28, height: 841.89))
self.webView.createPDF(configuration: config){ result in
switch result {
case .success(let data):
self.pdf = PDFDocument(data: data)
case .failure(let error):
print(error)
}
}
}
}
}
//MARK: ViewRepresentables
struct WebViewWrapper: NSViewRepresentable {
#Binding var htmlString: String
public func makeNSView(context: Context) -> WKWebView {
return WKWebView()
}
public func updateNSView(_ nsView: WKWebView, context: Context) {
nsView.loadHTMLString(htmlString, baseURL: nil)
}
}
struct PdfViewRepresentable: NSViewRepresentable {
#Binding var pdfDocument: PDFDocument?
func makeNSView(context: Context) -> PDFView {
return PDFView()
}
func updateNSView(_ nsView: PDFView, context: Context) {
nsView.document = pdfDocument
}
}

Here is a way that I got this working. It uses a completely hidden WKWebView to render the content, using a delegate callback and javascript execution to determine when the page has fully loaded.
When ready, the new iOS 15 WKWebView method .pdf() is called, which generates the PDF data.
Bindings are used to update the parent view, which generates a PDFDocument from the data and navigates to it.
Caveat: The native WKWebView PDF methods do not produce paginated PDF files, so it will be one long page!
In the main view it starts with a button tap. This view has a pdfData variable that is of type Data and will be used to make the PDF document.
Button("Generate PDF") {
// Prevent triggering again while already processing
guard !isWorking else { return }
// Reset flags
isReadyToRenderPDF = false
isWorking = true
// Initialise webView with frame equal to A4 paper size in points
// (I also tried UIScreen.main.bounds)
self.wkWebView = WKWebView(frame: CGRect(origin: .zero, size: CGSize(width: 595, height: 842))) // A4 size
// Set web view navigation delegate.
// You must keep a strong reference to this, so I made it an #State property on this view
// This is a class that takes bindings so it can update the data and signal completion
self.navigationDelegate = WKWebViewDelegate(wkWebView!, pdfData: $data, isReadyToRenderPDF: $isReadyToRenderPDF)
wkWebView!.navigationDelegate = self.navigationDelegate
// Generate HTML. You could also use a simple HTML string.
// This is just my implementation.
let htmlContent = htmlComposer.makeHTMLString()
// Load HTML into web view
wkWebView!.loadHTMLString(htmlContent, baseURL: nil)
// Now the navigation delegate responds when data is updated.
}
The WKWebView delegate class has this callback method.
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// This ensures following code only runs once, as delegate method is called multiple times.
guard !readyOnce else { return }
// Use javascript to check document ready state (all images and resources loaded)
webView.evaluateJavaScript("document.readyState == \"complete\"") { result, error in
if (error != nil) {
print(error!.localizedDescription)
return
} else if result as? Int == 1 {
self.readyOnce = true
do {
// Create PDF using WKWebView method and assign it to the binding (updates data variable in main view)
self.pdfData = try await webView.pdf()
// Signal to parent view via binding
self.isReadyToRenderPDF = true
} catch {
print(error.localizedDescription)
}
} else {
return
}
}
}
Back in parent view, respond to the change of Boolean value.
.onChange(of: isReadyToRenderPDF) { _ in
isWorking = false
DispatchQueue.main.async {
// This toggle navigation link or sheet displaying PDF View using pdfData variable
isShowingPDFView = true
}
}
Finally, here is a PDFKit PDFView wrapped in UIViewRepresentable.
import SwiftUI
import PDFKit
struct PDFKitRepresentedView: UIViewRepresentable {
let data: Data
init(_ data: Data) {
self.data = data
}
func makeUIView(context: UIViewRepresentableContext<PDFKitRepresentedView>) -> PDFKitRepresentedView.UIViewType {
// Create PDFKit view and document
let pdfView = PDFView()
pdfView.document = PDFDocument(data: data)
pdfView.autoScales = true
pdfView.displaysPageBreaks = true
pdfView.usePageViewController(true, withViewOptions: nil)
pdfView.displayDirection = .horizontal
pdfView.displayMode = .singlePage
return pdfView
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<PDFKitRepresentedView>) {
// Not implemented
}
}

Related

How do I inject CSS/JS in a WKWebView using SwiftUI?

I'm new to SwiftUI and trying to inject some custom CSS/JS into a page loaded with WKWebView:
import SwiftUI
import WebKit
struct WebView: UIViewRepresentable {
func makeUIView(context: Context) -> WKWebView {
return WKWebView()
}
func updateUIView(_ webView: WKWebView, context: Context) {
let request = URLRequest(url: URL(string: "https://example.com")!)
WKWebsiteDataStore.default().removeData(ofTypes: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache], modifiedSince: Date(timeIntervalSince1970: 0), completionHandler:{ })
webView.load(request)
webView.configuration.userContentController.addUserScript(WKUserScript( source: "alert('debug')", injectionTime: .atDocumentEnd, forMainFrameOnly: true))
}
}
Which is load like this:
struct ContentView: View {
var body: some View {
WebView()
}
}
Sadly, the code doesn't seem to actually inject anything. I've tried running it before webView.load as well. Having been googling quite a bit, I only see examples done in UIKit and unfortunately, I'm too inexperienced to wrap UIKit in a way that I can use with SwiftUI.
Any guidance would be greatly appreciated.
First of all try to avoid including business code in your views whenever you can. You may use two functions in the Webkit API if you want to include/inject JS to the webview content: EvaluateJS and AddUserScript. You may use "AddUserScript" before the "load" starts. Also please not that "alert" function in JS, would not work in current Mobile Safari. You should have see the text colors to appear in blue with the script below.
Result:
import SwiftUI
import WebKit
struct ContentView: View {
var body: some View {
VStack {
CustomWebview()
}
.padding()
}
}
struct SwiftUIWebView: UIViewRepresentable {
typealias UIViewType = WKWebView
let webView: WKWebView
func makeUIView(context: Context) -> WKWebView {
webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {
}
}
final class SwiftUIWebViewModel: ObservableObject {
#Published var addressStr = "https://www.stackoverflow.com"
let webView: WKWebView
init() {
webView = WKWebView(frame: .zero)
loadUrl()
}
func loadUrl() {
guard let url = URL(string: addressStr) else {
return
}
webView.configuration.userContentController.addUserScript(WKUserScript( source: """
window.userscr ="hey this is prior injection";
""", injectionTime: .atDocumentStart, forMainFrameOnly: false))
webView.load(URLRequest(url: url))
// You will have the chance in 8 seconds to open Safari debugger if needed. PS: Also put a breakpoint to injectJS function.
DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) {
self.injectJS()
}
}
func injectJS () {
webView.evaluateJavaScript("""
window.temp = "hey here!";
document.getElementById("content").style.color = "blue";
""")
}
}
struct CustomWebview: View {
#StateObject private var model = SwiftUIWebViewModel()
var body: some View {
VStack {
SwiftUIWebView(webView: model.webView)
.padding()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Rasterising an NSView to NSImage in Swift

I'm trying to convert an NSView to a NSImage. The most popular solution suggested here is using bitmapImageRepForCachingDisplay which works fine most of the time. But in my particular case it fails to capture part of the view.
Here is the result I'm getting.
Input NSView:
Output Image:
As you can the code snippet highlighting is ignored in the output image. I'm using CodeEditor as the editor here. Is there an alternative way to capture the view?
import SwiftUI
import CodeEditor // from https://github.com/ZeeZide/CodeEditor
extension NSView {
func bitmapImage() -> NSImage? {
guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else {
return nil
}
cacheDisplay(in: bounds, to: rep)
guard let cgImage = rep.cgImage else {
return nil
}
return NSImage(cgImage: cgImage, size: bounds.size)
}
}
extension View {
func renderAsImage() -> NSImage? {
let view = NSHostingView(rootView: self)
view.setFrameSize(view.fittingSize)
return view.bitmapImage()
}
}
struct CustomCodeEditor: View {
#State var source = """
struct ContentView: View {
var body: some View {
Text("Hello SwiftUI!")
.padding()
}
}
"""
var codeEditorView : some View {
return CodeEditor(source: $source, language: .swift, theme: .default).frame(width: 500, height: 500)
}
var body: some View{
Image(nsImage: codeEditorView.renderAsImage()!)
}
}

How to resolve "AttributeGraph: cycle" warnings in the following code?

For context, the goal of the code below is to intercept a particular type of link inside a webview and handle navigation across a tabview natively (to a separate webview displaying the desired page) rather let the webview navigate itself. However, when I attempt to change the currentSelection to the desired index, I get a long list of "===AttributeGraph: cycle...===" messages. Below is the entirety of the code needed to repro this behavior:
import SwiftUI
import WebKit
#main
struct AttributeGraphCycleProofApp: App {
var body: some Scene {
WindowGroup {
ContentView(theController: Controller())
}
}
}
struct ContentView: View {
#StateObject var theController: Controller
#State var currentSelection = 0
private let selectedBackgroundColor = Color.green
var body: some View {
VStack(spacing: 12.0) {
HStack(spacing: .zero) {
ForEach(Array(0..<theController.viewModel.menuEntries.count), id: \.self) { i in
let currentMenuEntry = theController.viewModel.menuEntries[i]
Text(currentMenuEntry.title)
.padding()
.background(i == currentSelection ? selectedBackgroundColor : .black)
.foregroundColor(i == currentSelection ? .black : .gray)
}
}
TabView(selection: $currentSelection) {
let menuEntries = theController.viewModel.menuEntries
ForEach(Array(0..<menuEntries.count), id: \.self) { i in
let currentMenuEntry = theController.viewModel.menuEntries[i]
WrappedWebView(slug: currentMenuEntry.slug, url: currentMenuEntry.url) { destinationIndex in
// cycle warnings are logged when this line is executed
currentSelection = destinationIndex
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
}
.padding()
.background(.black)
.onAppear { theController.start() }
}
}
class Controller: ObservableObject {
#Published var viewModel: ViewModel = ViewModel(menuEntries: [])
func start() {
// Represents network request to create dynamic menu entries
DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: DispatchTimeInterval.seconds(1)), execute: { [weak self] in
self?.viewModel = ViewModel.create()
})
}
}
struct ViewModel {
let menuEntries: [MenuEntry]
static func create() -> Self {
return Self(menuEntries: [
MenuEntry(title: "Domain", slug: "domain", url: "https://www.example.com/"),
MenuEntry(title: "Iana", slug: "iana", url: "https://www.iana.org/domains/reserved"),
])
}
}
struct MenuEntry {
let title: String
let slug: String
let url: String
}
struct WrappedWebView: UIViewRepresentable {
var slug: String
var url: String
var navigateToSlug: ((Int) -> Void)? = nil
func makeCoordinator() -> WrappedWebView.Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.navigationDelegate = context.coordinator
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
webView.isOpaque = false
if let wrappedUrl = URL(string: url), webView.url != wrappedUrl {
let request = URLRequest(url: wrappedUrl)
webView.load(request)
}
}
class Coordinator: NSObject, WKNavigationDelegate {
let parent: WrappedWebView
init(_ parent: WrappedWebView) {
self.parent = parent
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
let url = navigationAction.request.url
if url?.absoluteString == parent.url {
return .allow
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.parent.navigateToSlug?(1)
}
return .cancel
}
}
}
All instrumentation and memory graph debugging are failing me as they don't describe the moment the leak occurs, all I know is that the critical line causing the leaks is the assignment of navigationIndex to currentSelection.

Refreshing PDFViewer on SwiftUI UIViewRepresentable

I have created a SwiftUI wrapper for creating and displaying PDFs. I have a couple of functionalities that output the new pdf as Data. I have a binding to my PDFViewer but it is not working as expected. The challenge comes when I want to refresh the view (for example I added new text, so the binding data changes) without calling 'updateUIView'. I would like to solve it without calling updateUIView as I would like to do not create PDFDocument(data: data) again if possible.
I have researched about delegates and did not find any 'update' or similar function.
I also tried layoutDocumentView without success
Would like a solution that refreshes the view without creating the doc again
Would also have access to the currentPage in a better way if possible (now I am working with Notifications)
struct PDFViewer: UIViewRepresentable {
typealias UIViewType = PDFView
#Binding var data: Data
#Binding var currentPageNumber: Int?
var pdfView: PDFView
let singlePage: Bool
init(pdfView: PDFView, data: Binding<Data>, singlePage: Bool = false, currentPage: Binding<Int?>) {
self.pdfView = pdfView
self._data = data
self.singlePage = singlePage
self._currentPageNumber = currentPage
}
func makeUIView(context: UIViewRepresentableContext<PDFViewer>) -> UIViewType {
pdfView.autoScales = true
if singlePage {
pdfView.displayMode = .singlePage
}
pdfView.delegate = context.coordinator
pdfView.document = PDFDocument(data: data) // <- DO NOT REFRESH EVEN IF DATA CHANGES
NotificationCenter.default.addObserver(forName: .PDFViewSelectionChanged, object: nil, queue: nil) { (notification) in
DispatchQueue.main.async {
let newPage = (pdfView.currentPage?.pageRef!.pageNumber)!
print(newPage)
if currentPageNumber != newPage {
currentPageNumber = newPage
}
}
}
return pdfView
}
func updateUIView(_ pdfView: UIViewType, context _: UIViewRepresentableContext<PDFViewer>) {
//// let newPDFDoc = PDFDocument(data: data) <---- DO NOT WANT TO CREATE IT AGAIN
// if pdfView.document?.dataRepresentation() != newPDFDoc?.dataRepresentation() {
//// pdfView.document = newPDFDoc
//// pdfView.go(to: pdfView.currentPage!)
// }
}
class Coordinator: NSObject, PDFViewDelegate, UIGestureRecognizerDelegate {
var parent: PDFViewer
init(_ parent: PDFViewer) {
self.parent = parent
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
}
you could try something very simple like this approach, to not refresh the view
even when data changes:
#State private var firstTimeOnly = true
....
func updateUIView(_ pdfView: UIViewType, context _: UIViewRepresentableContext<PDFViewer>) {
if self.firstTimeOnly {
self.firstTimeOnly = false
let newPDFDoc = PDFDocument(data: data) // <---- DO NOT WANT TO CREATE IT AGAIN
if pdfView.document?.dataRepresentation() != newPDFDoc?.dataRepresentation() {
pdfView.document = newPDFDoc
pdfView.go(to: pdfView.currentPage!)
}
}
}
Similarly in makeUIView.

LinkPresentation views not fully loading in SwiftUI

I'm using code I found from a great article here that demonstrates how to use the LinkPresentation framework in SwiftUI.
However I'm having a small problem that I can't find solution to - the link previews loads their metadata but don't refresh the view once fully loaded unless I do something which forces the view to refresh, like rotating the phone.
They load as much as this:
Then look like this after rotating:
I'd like the views to fully refresh once the metadata is loaded. I feel like I probably need to add some binding in somewhere but I don't know where. Can anyone help at all?
Here's the UIViewRepresentable
import SwiftUI
import LinkPresentation
struct URLPreview : UIViewRepresentable {
var previewURL:URL
func makeUIView(context: Context) -> LPLinkView {
LPLinkView(url: previewURL)
}
func updateUIView(_ view: LPLinkView, context: Context) {
// New instance for each update
let provider = LPMetadataProvider()
provider.startFetchingMetadata(for: previewURL) { (metadata, error) in
if let md = metadata {
DispatchQueue.main.async {
view.metadata = md
view.sizeToFit()
}
}
}
}
}
and here's how it's called:
struct Content: View {
var body: some View {
URLPreview(previewURL: URL(string: "www.apple.com")!)
}
}
Triggering a redraw is what you need. Not a fan of this, but you can try Binding a State CGSize and set frame to width/height.
struct URLPreview : UIViewRepresentable {
var previewURL:URL
//Add binding
#Binding var metaSize: CGSize
func makeUIView(context: Context) -> LPLinkView {
LPLinkView(url: previewURL)
}
func updateUIView(_ view: LPLinkView, context: Context) {
// New instance for each update
let provider = LPMetadataProvider()
provider.startFetchingMetadata(for: previewURL) { (metadata, error) in
if let md = metadata {
DispatchQueue.main.async {
view.metadata = md
view.sizeToFit()
//Set binding after resize
self.metaSize = view.frame.size
}
}
}
}
}
struct ContentView: View {
//can default original state
#State var metaSize: CGSize = CGSize()
var body: some View {
URLPreview(previewURL: URL(string: "www.apple.com")!, metaSize: $metaSize)
.frame(width: metaSize.width, height: metaSize.height)
}
}
UPDATE
NSPratik is right, the solution is not really viable for Lists. So an amended solution is actually just to use a simple Bool State to toggle the Views generated by a list:
struct ContentView: View {
//can default original state
#State var togglePreview = false
let urls: [String] = ["https://medium.com","https://apple.com","https://yahoo.com","https://stackoverflow.com"]
var body: some View {
List(urls, id: \.self) { url in
URLPreview(previewURL: URL(string: url)!, togglePreview: self.$togglePreview)
.aspectRatio(contentMode: .fit)
.padding()
}
}
}
struct URLPreview : UIViewRepresentable {
var previewURL:URL
//Add binding
#Binding var togglePreview: Bool
func makeUIView(context: Context) -> LPLinkView {
let view = LPLinkView(url: previewURL)
let provider = LPMetadataProvider()
provider.startFetchingMetadata(for: previewURL) { (metadata, error) in
if let md = metadata {
DispatchQueue.main.async {
view.metadata = md
view.sizeToFit()
self.togglePreview.toggle()
}
}
}
return view
}
func updateUIView(_ uiView: LPLinkView, context: UIViewRepresentableContext<URLPreview>) {
}
}
We simply use togglePreview as our trigger, pass it to a Binding var in the UIView, and then setup our List. Even if this triggers all the Views in the List, there won't be any animation to reflect the resize of fully loaded LinkViews.
Using LPLinkViews in a List causes huge memory leaks. Your best bet is to use a VStack embedded inside a ScrollView.
ScrollView {
VStack {
ForEach(links, id: \.self) { link in
if let url = URL(string: link) {
LinkRow(url: url)
}
}
}
.padding()
}
This will make the LPLinkViews resize themselves as they load.
I have done this in an app and it has significant improvement over using a List. However a little caveat, if the user stars scrolling up and down as soon as the view comes on screen while the previews are still loading, it might causes crashes at random. Unfortunately I haven't been able to find a solution for that yet. I think all these crashes happen because the LPMetadataProvider requires you to be called on the main thread and obviously that doesn't play well with smooth scrolling.