Web page as PDF

I am trying to print a WKWebView to PDF. This webView loads and displays the webpage assigned to it correctly. But when it comes to printing, it renders PDF files with empty pages.
Here is a shorten version of my original script:

-- use framework "Foundation"
use framework "AppKit"
use framework "WebKit"
use scripting additions

property didEnded : false
property theError : missing value

my performSelectorOnMainThread:"webPage2pdf" withObject:(missing value) waitUntilDone:true

on webPage2pdf()
	set pageURL to current application's NSURL's URLWithString:"https://www.macscripter.net"
	set fileName to "PDF from Web Page [PDFWithConf]"
	set pdfPath to (POSIX path of ("" & (path to desktop) & fileName & ".pdf"))
	set theSize to {640, 1200}
	
	set webConf to current application's WKWebViewConfiguration's alloc()'s init()
	set webView to current application's WKWebView's alloc()'s initWithFrame:{{0, 0}, theSize} configuration:webConf
	webView's setNavigationDelegate:me
	webView's setUIDelegate:me
	webView's setTranslatesAutoresizingMaskIntoConstraints:true
	log {"view created", webView}
	
	set theRequest to current application's NSURLRequest's requestWithURL:pageURL
	webView's loadRequest:theRequest
	log {"load started", theRequest}
	
	repeat until (my didEnded) = true
		delay 0.01
	end repeat
	log {"load ended", (my didEnded)}
	log {"load error", theError}
	
	-- set up printing specs
	set printInf to current application's NSPrintInfo's sharedPrintInfo()'s |copy|()
	printInf's setJobDisposition:(current application's NSPrintSaveJob)
	printInf's dictionary()'s setObject:pdfPath forKey:(current application's NSPrintSavePath)
	printInf's setHorizontalPagination:(current application's NSClipPagination)
	printInf's setVerticalPagination:(current application's NSAutoPagination)
	printInf's setPaperSize:theSize
	
	-- set up and run print operation
	set theOp to current application's NSPrintOperation's printOperationWithView:webView printInfo:printInf
	theOp's setShowsPrintPanel:false
	theOp's setShowsProgressPanel:false
	theOp's runOperation()
	
end webPage2pdf

on webView:anWKWebView didFinishNavigation:anWKNavigation
	set my theError to missing value
	set my didEnded to true
end webView:didFinishNavigation:

on webView:anWKWebView didFailNavigation:anWKNavigation withError:anNSError
	set my theError to anNSError
	set my didEnded to true
end webView:didFailNavigation:withError:

Is there a mistake somewhere or is this just not doable with AppleScriptObjC?

1 Like

The runOperation method of NSPrintOperation doesn’t seem to work for webviews, at least not in my experience (and others, it seems). You just have to use runOperationModalForWindow:delegate:didRunSelector:contextInfo: instead. You also have to create a hidden window and add the webview as a subview, then wait for the operation to complete.

This works on my machine (m1, Sequoia 15.4):

-- use framework "Foundation"
use framework "AppKit"
use framework "WebKit"
use scripting additions

property didEnded : false
property theError : missing value
property finishedPrinting : false

my performSelectorOnMainThread:"webPage2pdf" withObject:(missing value) waitUntilDone:true

on webPage2pdf()
	set pageURL to current application's NSURL's URLWithString:"https://www.macscripter.net"
	set fileName to "PDF from Web Page [PDFWithConf]"
	set pdfPath to (POSIX path of ("" & (path to desktop) & fileName & ".pdf"))
	set theSize to {640, 1200}
	
	-- Hidden window used to render the view
	set theWindow to current application's NSWindow's alloc()'s initWithContentRect:{{0, 0}, theSize} styleMask:(current application's NSWindowStyleMaskBorderless) backing:(current application's NSBackingStoreBuffered) defer:false
	
	set webConf to current application's WKWebViewConfiguration's alloc()'s init()
	set webView to current application's WKWebView's alloc()'s initWithFrame:{{0, 0}, theSize} configuration:webConf
	webView's setNavigationDelegate:me
	webView's setUIDelegate:me
	webView's setTranslatesAutoresizingMaskIntoConstraints:true
	log {"view created", webView}
	
	set theRequest to current application's NSURLRequest's requestWithURL:pageURL
	webView's loadRequest:theRequest
	log {"load started", theRequest}
	
	repeat until (my didEnded) = true
		delay 0.01
	end repeat
	log {"load ended", (my didEnded)}
	log {"load error", theError}
	
	-- set up printing specs
	set printInf to current application's NSPrintInfo's sharedPrintInfo()'s |copy|()
	printInf's setJobDisposition:(current application's NSPrintSaveJob)
	printInf's dictionary()'s setObject:pdfPath forKey:(current application's NSPrintSavePath)
	printInf's setHorizontalPagination:(current application's NSClipPagination)
	printInf's setVerticalPagination:(current application's NSAutoPagination)
	printInf's setPaperSize:theSize
	
	-- Add the webview to the window
	theWindow's contentView()'s addSubview:webView
	
	set theOp to webView's printOperationWithPrintInfo:printInf
	theOp's setShowsPrintPanel:false
	theOp's setShowsProgressPanel:false
	
	theOp's runOperationModalForWindow:theWindow delegate:me didRunSelector:(current application's NSSelectorFromString("printOperationDidRun")) contextInfo:(missing value)
	
	repeat while my finishedPrinting is false
		delay 0.01
	end repeat
	
end webPage2pdf

on webView:anWKWebView didFinishNavigation:anWKNavigation
	set my theError to missing value
	set my didEnded to true
end webView:didFinishNavigation:

on webView:anWKWebView didFailNavigation:anWKNavigation withError:anNSError
	set my theError to anNSError
	set my didEnded to true
end webView:didFailNavigation:withError:

on printOperationDidRun()
	set my finishedPrinting to true
end printOperationDidRun

Hi @HelloImSteven,

Thanks for the script that works fine although the PDF does not renders the web page properly.
Do you think we can modify the printing settings to get an acurate rendering?
Here is what I’m getting: on the left is the web page displayed in a NSPanel and on the right is the printed PDF.

Ah, NSPrintOperation uses the same formatting that you’d get if you try to print from Safari, with some differences in the default configuration. You can adjust the configuration a bit to get background colors and somewhat more accurate sizing, but it’ll only do so much:

-- Enable the "Print backgrounds" setting
set webPrefs to current application's WKPreferences's alloc()'s init()
webPrefs's setShouldPrintBackgrounds:true
webConf's setPreferences:webPrefs

-- Use desktop user agent
webView's setCustomUserAgent:"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) AppleWebKit/601.6.17 (KHTML, like Gecko) Version/9.1.1 Safari/601.6.17"

AFAIK, to have it fully match what the NSPanel displays, you’d need to use takeSnapshotWithConfiguration:completionHandler:, which won’t work in ASObjC but will in JXA.

I made a script a while back that creates webview from raw HTML or and/or a URL, takes a snapshot of the render, and outputs the base64 representation of the image. You might be able to use that as a reference. Note that this script seems to always crash in Script Editor, but Script Debugger and osascript run/used to run it fine.

JXA WebView Snapshot Example
function run() {
    ObjC.import('AppKit');
    ObjC.import('ApplicationServices');
    ObjC.import('Quartz');
    ObjC.import('CoreGraphics');
    ObjC.import('WebKit');
    ObjC.import('objc');
	
	app = Application.currentApplication()
	app.includeStandardAdditions = true
	
	/*
	const args = $.NSProcessInfo.processInfo.arguments
    let html = args.js[4].js
	const baseURL = args.js[5].js
	const width = args.js[6].js
	const height = args.js[7].js
	*/ 
	
	let html = "";
	const baseURL = "https://apple.com";
	const width = 1440;
	const height = 900;
	
	let css = "";
	const cssMatch = html.matchAll(/<style type="text\/css">([\s\S]*?)<\/style>/g);
	if (cssMatch) {
		for (const m of cssMatch) {
			css += m[1];
            html = html.replace(m[0], "");
		}
	}
	
    const WKNavigationDelegate = $.objc_getProtocol('WKNavigationDelegate')

    const _NSApp = $.NSApplication.sharedApplication;
    
      if (!$['WebViewDelegate']) {
      ObjC.registerSubclass({
        name: 'WebViewDelegate',
        superclass: 'NSObject',
        protocols: ['WKNavigationDelegate'],
		properties: {
		   		result: 'id',
		},
        methods: {
          'webView:didFinishNavigation:': {
            types: ['void', ['id', 'id']],
            implementation: function (webview, navigation) {
    				let jsString = `var style = document.createElement('style'); style.innerHTML = '${css.replaceAll("\n", "\\n")}'; document.head.appendChild(style);`
					webview.evaluateJavaScriptCompletionHandler(jsString + "document.body.style.borderRadius = '20px';" + "[document.body.scrollWidth, document.body.scrollHeight];", (result, error) => {
						if (error.localizedDescription) {
                      	    $.NSLog(error.localizedDescription)
			  			  	this.result = $.NSString.stringWithString("Error")
                      	    return;
                      	}
												
                    	const snapConfig = $.WKSnapshotConfiguration.alloc.init
                  		snapConfig.setRect($.NSMakeRect(0, 0, width, height))
				  		snapConfig.setSnapshotWidth(width)						
						
						let bodyColor = "";
						const bodyColorMatch = jsString.matchAll(/body ?{[\s\S]*?background: ?(.*?);[\s\S]*?}/g);
						if (bodyColorMatch) {
							for (const m of bodyColorMatch) {
								bodyColor = m[1];
							}
						}
						
							if (bodyColor == "" || bodyColor == "transparent") {		
								webview.setValueForKey(true, "drawsTransparentBackground");
							}
														
                    		webview.takeSnapshotWithConfigurationCompletionHandler(snapConfig, (image, error) => {
                      			if (error.localizedDescription) {
                      			    $.NSLog(error.localizedDescription)
			  					  	this.result = $.NSString.stringWithString("Error")
                      			    return;
                      			}
                      			
								const rounded_img = $.NSImage.alloc.initWithSize(image.size)
								rounded_img.lockFocus

        						const ctx = $.NSGraphicsContext.currentContext
        						ctx.setImageInterpolation($.NSImageInterpolationHigh)

        						const imageFrame = $.NSMakeRect(0, 0, image.size.width, image.size.height)
        						const clipPath = $.NSBezierPath.bezierPathWithRoundedRectXRadiusYRadius(imageFrame, 10, 10)
        						clipPath.setWindingRule($.NSWindingRuleEvenOdd)
        						clipPath.addClip

        						image.drawAtPointFromRectOperationFraction($.NSZeroPoint, imageFrame, $.NSCompositingOperationSourceOver, 1)
        						rounded_img.unlockFocus
								
					  			const CGImage = rounded_img.CGImageForProposedRectContextHints(null, $.NSGraphicsContext.currentContext, $.NSDictionary.alloc.init);
   					  			const bitmap = $.NSBitmapImageRep.alloc.initWithCGImage(CGImage)
					  			const data = bitmap.representationUsingTypeProperties($.NSPNGFileType, $.NSDictionary.alloc.init)
                      			this.result = data.base64EncodedStringWithOptions(null)
					  		})
                  })
              }
          },
        },
      });
    }
    
 	const frame = $.NSMakeRect(0, 0, width, height / 2);
	const config = $.WKWebViewConfiguration.alloc.init
	const view = $.WKWebView.alloc.initWithFrameConfiguration(frame, config)
	const delegate = $.WebViewDelegate.alloc.init
	view.navigationDelegate = delegate
		
	if (baseURL != "https://" && baseURL != "") {
		const request = $.NSMutableURLRequest.requestWithURL($.NSURL.URLWithString(baseURL))
		request.setValueForHTTPHeaderField("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15", "User-Agent")
		const nav = view.loadRequest(request)
	} else {
		const nav = view.loadHTMLStringBaseURL(html, $.nil)
	}
    
	while (delegate.result.js == undefined) {
    	runLoop = $.NSRunLoop.currentRunLoop;
		today = $.NSDate.dateWithTimeIntervalSinceNow(0.1);
		runLoop.runUntilDate(today);
	}
		
	return delegate.result.js
}

There might be a way to use NSView.dataWithPDFInsideRect to do what you want without needing completion handlers, but I’m not sure. I had no luck when I attempted just now.

@HelloImSteven, Thanks for the reply.

Unfortunately, I’m not able to run your script.
Can you tell me how you run it in Script Debugger ?

My bad, I should’ve gave you this other version. Since it’s a JXA script you have to either run it via osascript in Terminal or, as I did in the script below, wrap it in a run script call so that it works in Script Debugger:

set JXAScript to "webTarget = 'https://www.macscripter.net';
outputLocation = '~/Desktop';
outputDimensions = { width: 1680, height: 1050 };

NSApp = null;
currentApplication = null;

function run(argv) {
	ObjC.import('AppKit');
	ObjC.import('ApplicationServices');
	ObjC.import('CoreGraphics');
	ObjC.import('PDFKit');
	ObjC.import('Quartz');
	ObjC.import('WebKit');
	ObjC.import('objc');

	currentApplication = Application.currentApplication();
	currentApplication.includeStandardAdditions = true;

	const targetURL = $.NSURL.URLWithString(webTarget);
	const fileName = targetURL.host.js;
	const outputContainer = outputLocation.replace('~', currentApplication.pathTo('home folder'));
	const outputPath = `${outputContainer}/${fileName}.pdf`;
	outputLocation = outputPath;
	const outputURL = $.NSURL.fileURLWithPath(outputPath);
		
	const frame = $.NSMakeRect(0, 0, outputDimensions.width, outputDimensions.height);
	
	const webViewConfig = buildWebViewConfig();
	const webView = $.WKWebView.alloc.initWithFrameConfiguration(frame, webViewConfig);
	const webViewDelegate = registerWebViewDelegate();
	webView.navigationDelegate = webViewDelegate;
	
	const request = $.NSMutableURLRequest.requestWithURL(targetURL);
	request.setTimeoutInterval(10);
	request.setAllowsExpensiveNetworkAccess(true);
	request.setValueForHTTPHeaderField('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15', 'User-Agent')
	webView.loadRequest(request)
	
	while (webViewDelegate.result.js == undefined) {
		runLoop = $.NSRunLoop.currentRunLoop;
		today = $.NSDate.dateWithTimeIntervalSinceNow(0.1);
		runLoop.runUntilDate(today);
	}
	
	const image = imageFromBase64String(webViewDelegate.result);
	return createPDFFromImage(image, outputURL);
}

function registerWebViewDelegate() {
	const WKNavigationDelegate = $.objc_getProtocol('WKNavigationDelegate')
	if (!$['WebViewDelegate']) {
		ObjC.registerSubclass({
			name: 'WebViewDelegate',
			superclass: 'NSObject',
			protocols: ['WKNavigationDelegate'],
			properties: {
				result: 'id',
			},
			methods: {
				'webView:didFinishNavigation:': {
					types: ['void', ['id', 'id']],
					implementation: function (webView, navigation) {
						while (webView.isLoading) {
							$.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.01));
						}
						
						runJavaScriptInWebView(webView, '[document.body.scrollWidth, Array.from(document.body.children).findLast((child) => child.clientHeight > 0 && child.offsetTop > 0)?.getBoundingClientRect()?.bottom ?? 0]', (result) => {
							const documentHeight = result.js[1].js;
							const finalHeight = documentHeight > outputDimensions.height ? documentHeight : outputDimensions.height;
							webView.setFrame($.NSMakeRect(0, 0, outputDimensions.width, finalHeight));
							takeSnapshot(webView, outputDimensions.width, finalHeight, this);
						});

					}
				},
        		},
		});
	}
	return $.WebViewDelegate.alloc.init;
}

function buildWebViewConfig() {
	const config = $.WKWebViewConfiguration.alloc.init;
	config.setSuppressesIncrementalRendering(true);
	config.setMediaTypesRequiringUserActionForPlayback($.WKAudiovisualMediaTypeNone);

	const preferences = $.WKPreferences.alloc.init;
	preferences.setShouldPrintBackgrounds(true);
	preferences.setInactiveSchedulingPolicy($.WKInactiveSchedulingPolicyNone);
	preferences._setAllowFileAccessFromFileURLs(true);
	preferences._setUniversalAccessFromFileURLsAllowed(true);
	config.setPreferences(preferences);
	return config;
}

function runJavaScriptInWebView(webView, jsScript, handleResult) {
	webView.evaluateJavaScriptCompletionHandler(jsScript, (result, error) => {
		if (error.localizedDescription) {
			$.NSLog(error.localizedDescription);
			this.result = $.NSString.stringWithString('Error');
			return;
		}
		
		handleResult?.(result)
	});
}
			
function takeSnapshot(webview, width, height, sender) {
	const snapConfig = $.WKSnapshotConfiguration.alloc.init;
	snapConfig.setRect($.NSMakeRect(0, 0, width, height));
	snapConfig.setSnapshotWidth(width);
	snapConfig.setAfterScreenUpdates(true);
	
	webview.takeSnapshotWithConfigurationCompletionHandler(snapConfig, (image, error) => {
		if (error.localizedDescription) {
			$.NSLog(error.localizedDescription);
			this.result = $.NSString.stringWithString('Error');
			return;
		}

		const CGImage = image.CGImageForProposedRectContextHints(null, $.NSGraphicsContext.currentContext, $.NSDictionary.alloc.init);
		const bitmap = $.NSBitmapImageRep.alloc.initWithCGImage(CGImage);
		const data = bitmap.representationUsingTypeProperties($.NSPNGFileType, $.NSDictionary.alloc.init);
		sender.result = data.base64EncodedStringWithOptions(null);
	})
}
			
function imageFromBase64String(base64String) {
	const imageData = $.NSData.alloc.initWithBase64EncodedStringOptions(base64String, 0);
	return $.NSImage.alloc.initWithData(imageData);
}
			
function createPDFFromImage(image, outputURL) {
	const pdfDoc = $.PDFDocument.alloc.init;
	const pdfPage = $.PDFPage.alloc.initWithImage(image);
	pdfDoc.insertPageAtIndex(pdfPage, 0);	
	pdfDoc.writeToURL(outputURL);

	const se = Application('System Events');
	se.includeStandardAdditions = true;
	se.openLocation(outputURL.absoluteString.js);
	return outputURL.path.js;
}"

run script JXAScript in "JavaScript"

This script is functionally very similar to the earlier one, but I cleaned it up a bit and made it export a PDF.

That’s what I was doing.
But they both freeze (or run endless) when they enter in the registerWebViewDelegate function.
Also, I had to modify the buildWebViewConfig like this:
[Update: I had to change method(value) to method = value]

function buildWebViewConfig() {
	const config = $.WKWebViewConfiguration.alloc.init;
	config.setSuppressesIncrementalRendering = true;
	config.setMediaTypesRequiringUserActionForPlayback = $.WKAudiovisualMediaTypeNone;

	const thePrefs = $.WKPreferences.alloc.init;
	thePrefs.setShouldPrintBackgrounds = true;
	thePrefs.setInactiveSchedulingPolicy = $.WKInactiveSchedulingPolicyNone;
	thePrefs._setAllowFileAccessFromFileURLs = true;
	thePrefs._setUniversalAccessFromFileURLsAllowed = true;
	config.setPreferences = thePrefs;
	return config;
}

registerWebViewDelegate is too complex for me. Can you help me on this?

Hm, I’m not seeing that on my end; the script I shared takes a little over 1.5 seconds (demo). Which macOS version are you on? Perhaps that has something to do with it.

Also, if you run it using osascript in the Terminal, do you see any errors that give more clues?

My guess is that takeSnapshot is failing to create an image for some reason, causing the result to never update and leading to an infinite loop. Not sure why you’re seeing an issue but I’m not, but that’s where I’d start with debugging.

The buildWebViewConfig you posted seems to be same as what I had (diff checker says no differences)—do you have another version?

registerWebViewDelegate is mostly just creating a delegate for detecting load changes in the webview, but it’s redundant since I added an additional wait while the webView is loading. Though not necessary in theory, you could remove that method entirely after moving everything from lines 64-73 (starting with while (webView.isLoading) and ending with }); ) into run(), right after webView.loadRequest(), and changing references to webViewDelegate.result or sender.result to some global variable.

I’ve amended my previous post.
Here is why I had to change the syntax:
capture 001
Do you think it’s Monterey the culprit?

macOS 12.7.6 (21H1320) French localization
Script Debugger 8.0.10 (8A88)

Same result: it runs endless.

I’ve already tried it but I’m not sure I’m doing it right…
May I ask for your contribution one more time?

With your changes to buildWebViewConfig, the script runs successfully on my Monterey 12.6 (21G115) VM, completing in about 8.5 seconds (due to VM overhead). So I’m really not sure why it’s behaving strangely on your end… :confused:

I didn’t need to make any changes apart from that one config function, but here’s a version with registerWebViewDelegate inlined into run, which still works on my VM:

set JXAScript to "webTarget = 'https://macscripter.net';
outputLocation = '~/Desktop';
outputDimensions = { width: 1680, height: 1050 };

NSApp = null;
currentApplication = null;
base64String = null;

function run(argv) {
	ObjC.import('AppKit');
	ObjC.import('ApplicationServices');
	ObjC.import('CoreGraphics');
	ObjC.import('PDFKit');
	ObjC.import('Quartz');
	ObjC.import('WebKit');
	ObjC.import('objc');

	currentApplication = Application.currentApplication();
	currentApplication.includeStandardAdditions = true;

	const targetURL = $.NSURL.URLWithString(webTarget);
	const fileName = targetURL.host.js;
	const outputContainer = outputLocation.replace('~', currentApplication.pathTo('home folder'));
	const outputPath = `${outputContainer}/${fileName}.pdf`;
	outputLocation = outputPath;
	const outputURL = $.NSURL.fileURLWithPath(outputPath);
		
	const frame = $.NSMakeRect(0, 0, outputDimensions.width, outputDimensions.height);
	
	const webViewConfig = buildWebViewConfig();
	const webView = $.WKWebView.alloc.initWithFrameConfiguration(frame, webViewConfig);
	
	const request = $.NSMutableURLRequest.requestWithURL(targetURL);
	request.setTimeoutInterval(10);
	request.setAllowsExpensiveNetworkAccess(true);
	request.setValueForHTTPHeaderField('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15', 'User-Agent')
	webView.loadRequest(request)
	
	while (webView.isLoading) {
		$.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.01));
	}
			
	runJavaScriptInWebView(webView, 'Array.from(document.body.children).findLast((child) => child.clientHeight > 0 && child.offsetTop > 0)?.getBoundingClientRect()?.bottom ?? 0', (result) => {
		const finalHeight = result > outputDimensions.height ? result : outputDimensions.height;
		webView.setFrame($.NSMakeRect(0, 0, outputDimensions.width, finalHeight));
		takeSnapshot(webView, outputDimensions.width, finalHeight, this);
	});
	
	while (base64String == null) {
		runLoop = $.NSRunLoop.currentRunLoop;
		today = $.NSDate.dateWithTimeIntervalSinceNow(0.1);
		runLoop.runUntilDate(today);
	}
	
	const image = imageFromBase64String(base64String);
	return createPDFFromImage(image, outputURL);
}

function buildWebViewConfig() {
	const config = $.WKWebViewConfiguration.alloc.init;
	config.setSuppressesIncrementalRendering(true);
	config.setMediaTypesRequiringUserActionForPlayback($.WKAudiovisualMediaTypeNone);

	const preferences = $.WKPreferences.alloc.init;
	preferences.shouldPrintBackgrounds = true;
	preferences.inactiveSchedulingPolicy = $.WKInactiveSchedulingPolicyNone;
	preferences._allowFileAccessFromFileURLs = true;
	preferences._universalAccessFromFileURLsAllowed = true;
	config.preferences = preferences;
	return config;
}

function runJavaScriptInWebView(webView, jsScript, handleResult) {
	webView.evaluateJavaScriptCompletionHandler(jsScript, (result, error) => {
		if (error.localizedDescription) {
			$.NSLog(error.localizedDescription);
			this.result = $.NSString.stringWithString('Error');
			return;
		}
		
		handleResult?.(result)
	});
}
			
function takeSnapshot(webview, width, height, sender) {
	const snapConfig = $.WKSnapshotConfiguration.alloc.init;
	snapConfig.setRect($.NSMakeRect(0, 0, width, height));
	snapConfig.setSnapshotWidth(width);
	snapConfig.setAfterScreenUpdates(true);
	
	webview.takeSnapshotWithConfigurationCompletionHandler(snapConfig, (image, error) => {
		if (error.localizedDescription) {
			$.NSLog(error.localizedDescription);
			this.result = $.NSString.stringWithString('Error');
			return;
		}

		const CGImage = image.CGImageForProposedRectContextHints(null, $.NSGraphicsContext.currentContext, $.NSDictionary.alloc.init);
		const bitmap = $.NSBitmapImageRep.alloc.initWithCGImage(CGImage);
		const data = bitmap.representationUsingTypeProperties($.NSPNGFileType, $.NSDictionary.alloc.init);
		base64String = data.base64EncodedStringWithOptions(null);
	})
}
			
function imageFromBase64String(base64String) {
	const imageData = $.NSData.alloc.initWithBase64EncodedStringOptions(base64String, 0);
	return $.NSImage.alloc.initWithData(imageData);
}
			
function createPDFFromImage(image, outputURL) {
	const pdfDoc = $.PDFDocument.alloc.init;
	const pdfPage = $.PDFPage.alloc.initWithImage(image);
	pdfDoc.insertPageAtIndex(pdfPage, 0);	
	pdfDoc.writeToURL(outputURL);

	const se = Application('System Events');
	se.includeStandardAdditions = true;
	se.openLocation(outputURL.absoluteString.js);
	return outputURL.path.js;
}"

run script JXAScript in "JavaScript"

Here’s also a compiled version you can download to compare against, perhaps something is going wrong at that stage.

I suppose it could be an Intel vs Apple Silicon issue, if that even applies, but I don’t see why that would be the case. Not sure what else to look into.

Hi Stephen,

Your last script is running well except that it does not save the entire page.
I thought that runJavaScriptInWebView was there to increase the height of the web view by reaching the bottom of the web page. Am I wrong?
The size of the PDF is always the one that is defined at the beginning of the script.

In the meantime, I’ve replaced the takeSnapshot function to another that uses the createPDFWithConfiguration:completionHandler: method.

It works fine except the above limitation.
Is there a way to get the entire web page with this PDF function?

set JXAScript to "webTarget = 'https://www.eurofins-biomnis.com/services/referentiel-des-examens/page/125D/?r=1#';
outputLocation = '~/Desktop';
outputDimensions = { width: 1200, height: 3600 };
 
NSApp = null;
currentApplication = null;

function run(argv) {
	ObjC.import('AppKit');
	ObjC.import('ApplicationServices');
	ObjC.import('CoreGraphics');
	ObjC.import('PDFKit');
	ObjC.import('Quartz');
	ObjC.import('WebKit');
	ObjC.import('objc');

	currentApplication = Application.currentApplication();
	currentApplication.includeStandardAdditions = true;

	const targetURL = $.NSURL.URLWithString(webTarget);
	const fileName = targetURL.host.js;
	const outputContainer = outputLocation.replace('~', currentApplication.pathTo('home folder'));
	const outputPath = `${outputContainer}/${fileName}.pdf`;
	outputLocation = outputPath;
	const outputURL = $.NSURL.fileURLWithPath(outputPath);
		
	const frame = $.NSMakeRect(0, 0, outputDimensions.width, outputDimensions.height);
	
	const webViewConfig = buildWebViewConfig();
	const webView = $.WKWebView.alloc.initWithFrameConfiguration(frame, webViewConfig);
	
	const request = $.NSMutableURLRequest.requestWithURL(targetURL);
	request.setTimeoutInterval(10);
	request.setAllowsExpensiveNetworkAccess(true);
	request.setValueForHTTPHeaderField('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15', 'User-Agent')
	webView.loadRequest(request)
	
	while (webView.isLoading) {
		$.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.01));
	}
			
	const jsScript =  'Array.from(document.body.children).findLast((child) => child.clientHeight > 0 && child.offsetTop > 0)?.getBoundingClientRect()?.bottom ?? 0' 
	webView.evaluateJavaScriptCompletionHandler(jsScript, (dims, error) => {
	if (error.localizedDescription) {
		$.NSLog(error.localizedDescription);
		this.result = $.NSString.stringWithString('Error');
		return;
	}
	
	webView.setFrame = $.NSMakeRect(0, 0, dims.width, dims.height);		
	createPDF(webView, outputDimensions.width, outputDimensions.height, outputURL);
	});	
}

function buildWebViewConfig() {
	const config = $.WKWebViewConfiguration.alloc.init;
	config.setSuppressesIncrementalRendering(true);
	config.setMediaTypesRequiringUserActionForPlayback($.WKAudiovisualMediaTypeNone);

	const preferences = $.WKPreferences.alloc.init;
	preferences.shouldPrintBackgrounds = true;
	preferences.inactiveSchedulingPolicy = $.WKInactiveSchedulingPolicyNone;
	preferences._allowFileAccessFromFileURLs = true;
	preferences._universalAccessFromFileURLsAllowed = true;
	config.preferences = preferences;
	return config;
}

function runJavaScriptInWebView(webView, jsScript, handleResult) {
	webView.evaluateJavaScriptCompletionHandler(jsScript, (result, error) => {
		if (error.localizedDescription) {
			$.NSLog(error.localizedDescription);
			this.result = $.NSString.stringWithString('Error');
			return;
		}
			handleResult?.(result)
	});
}
			
function createPDF(webview, width, height, outputURL) {
	const pdfConfig = $.WKPDFConfiguration.alloc.init;
	pdfConfig.setRect($.NSMakeRect(0, 0, width, height));

	webview.createPDFWithConfigurationCompletionHandler(pdfConfig, (pdfData, error) => {
		if (error.localizedDescription) {
			$.NSLog(error.localizedDescription);
			this.result = $.NSString.stringWithString('Error');
			return;
		}

		while (pdfData == null) {
		today = $.NSDate.dateWithTimeIntervalSinceNow(0.1);
		runLoop.runUntilDate(today);
		}

		const pdfDoc = $.PDFDocument.alloc.initWithData(pdfData);
		pdfDoc.writeToURL(outputURL);

		const se = Application('System Events');
		se.includeStandardAdditions = true;
		se.openLocation(outputURL.absoluteString.js);
		return outputURL.path.js;
		})
}		
"
run script JXAScript in "JavaScript"

Looks like finalHeight just needed to be unwrapped with .js. The script using takeSnapshot appears to work correctly once your replace lines 43-47 with the following:

runJavaScriptInWebView(webView, 'Array.from(document.body.children).findLast((child) => child.clientHeight > 0 && child.offsetTop > 0)?.getBoundingClientRect()?.bottom ?? 0', (result) => {
	const finalHeight = result.js > outputDimensions.height ? result.js : outputDimensions.height;
	webView.setFrame($.NSMakeRect(0, 0, outputDimensions.width, finalHeight));
	takeSnapshot(webView, outputDimensions.width, finalHeight, this);
});

And here’s the full script using createPDF instead:

set JXAScript to "webTarget = 'https://www.eurofins-biomnis.com/services/referentiel-des-examens/page/125D/?r=1#';
outputLocation = '~/Desktop';
outputDimensions = { width: 1200, minHeight: 900 };

NSApp = null;
currentApplication = null;
finalResult = null;

function run(argv) {
	ObjC.import('AppKit');
	ObjC.import('ApplicationServices');
	ObjC.import('CoreGraphics');
	ObjC.import('PDFKit');
	ObjC.import('Quartz');
	ObjC.import('WebKit');
	ObjC.import('objc');

	currentApplication = Application.currentApplication();
	currentApplication.includeStandardAdditions = true;

	const targetURL = $.NSURL.URLWithString(webTarget);
	const fileName = targetURL.host.js;
	const outputContainer = outputLocation.replace('~', currentApplication.pathTo('home folder'));
	const outputPath = `${outputContainer}/${fileName}.pdf`;
	outputLocation = outputPath;
	const outputURL = $.NSURL.fileURLWithPath(outputPath);
		
	const frame = $.NSMakeRect(0, 0, outputDimensions.width, outputDimensions.minHeight);
	
	const webViewConfig = buildWebViewConfig();
	const webView = $.WKWebView.alloc.initWithFrameConfiguration(frame, webViewConfig);
	
	const request = $.NSMutableURLRequest.requestWithURL(targetURL);
	request.setTimeoutInterval(10);
	request.setAllowsExpensiveNetworkAccess(true);
	request.setValueForHTTPHeaderField('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15', 'User-Agent')
	webView.loadRequest(request)
	
	while (webView.isLoading) {
		$.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.01));
	}
			
	runJavaScriptInWebView(webView, 'Array.from(document.body.children).findLast((child) => child.clientHeight > 0 && child.offsetTop > 0)?.getBoundingClientRect()?.bottom ?? 0', (result) => {
		const finalHeight = result.js > outputDimensions.minHeight ? result.js : outputDimensions.minHeight;
		webView.setFrame($.NSMakeRect(0, 0, outputDimensions.width, finalHeight));	
		createPDF(webView, outputDimensions.width, finalHeight, outputURL);
	});
	
	while (finalResult == null) {
		today = $.NSDate.dateWithTimeIntervalSinceNow(0.1);
		$.NSRunLoop.currentRunLoop.runUntilDate(today);
	}
	
	return finalResult;
}

function buildWebViewConfig() {
	const config = $.WKWebViewConfiguration.alloc.init;
	config.setSuppressesIncrementalRendering(true);
	config.setMediaTypesRequiringUserActionForPlayback($.WKAudiovisualMediaTypeNone);

	const preferences = $.WKPreferences.alloc.init;
	preferences.shouldPrintBackgrounds = true;
	preferences.inactiveSchedulingPolicy = $.WKInactiveSchedulingPolicyNone;
	preferences._allowFileAccessFromFileURLs = true;
	preferences._universalAccessFromFileURLsAllowed = true;
	config.preferences = preferences;
	return config;
}

function runJavaScriptInWebView(webView, jsScript, handleResult) {
	webView.evaluateJavaScriptCompletionHandler(jsScript, (result, error) => {
		if (error.localizedDescription) {
			$.NSLog(error.localizedDescription);
			this.result = $.NSString.stringWithString('Error');
			return;
		}
		
		handleResult?.(result)
	});
}

function createPDF(webview, width, height, outputURL) {
	const pdfConfig = $.WKPDFConfiguration.alloc.init;
	pdfConfig.setRect($.NSMakeRect(0, 0, width, height));

	webview.createPDFWithConfigurationCompletionHandler(pdfConfig, (pdfData, error) => {
		if (error.localizedDescription) {
			$.NSLog(error.localizedDescription);
			this.result = $.NSString.stringWithString('Error');
			return;
		}

		const pdfDoc = $.PDFDocument.alloc.initWithData(pdfData);
		pdfDoc.writeToURL(outputURL);

		const se = Application('System Events');
		se.includeStandardAdditions = true;
		se.openLocation(outputURL.absoluteString.js);
		finalResult = outputURL.path.js;
	})
}		
"
run script JXAScript in "JavaScript"

This is terrific. Thank you. Because I want to paste or type in a web address, I use these lines at the start of the script:

set theResponse to display dialog "Web address to save:" default answer "" buttons {"Cancel", "Continue"} default button "Continue" with title "Save web page as PDF"
set webPage to text returned of theResponse
-- fix address if user did not enter a valid url:
if webPage does not start with "http" then
	set webPage to "https://" & webPage
end if
set JXAScript to "webTarget = '" & webPage & "';
outputLocation = '~/Desktop';
outputDimensions = { width: 1200, minHeight: 900 };

etc., etc.

Awesome!
This version is perfect: the content matches what’s displayed in Safari and all links are clickable.
That’s exactly what I was trying to do since a long time.
Thanks to you, I’ve learned that JXA gives us access to objc methods that require completion handlers.
I’m still a bit confused with this, but I’m making progress.

Last thing I would like to know is: is it possible to transfert all the cookies for the current site from Safari to the current application?

And a question: is it possible to add (for example) 10 pixels to the height? Some web pages that I’ve saved show the last line on the page exactly at the lower margin of the PDF, and it would look better to have a few pixels between the last line and border of the PDF.

I’ve tried adding + 10 to the last finalHeight string, but I don’t know what I’m doing and it didn’t accomplish this.

Change line 28 to:
const frame = $.NSMakeRect(0, 10, outputDimensions.width, outputDimensions.minHeight);

and line 46 to:
createPDF(webView, outputDimensions.width, finalHeight+10, outputURL);

Perfect! Thank you - I ended up using 20 pixels, but 10 also worked.

Glad I could help! Now others can use this thread as a reference, too.

JXA is actually pretty powerful—you can even call C methods directly—but it’s definitely much more confusing than AppleScript. Doesn’t help that Apple never bothered to document it much, either, but alas. As an OSA language, JXA falls short (it simply cannot create correctly structured Apple Events for some commands), but it’s great to have as an additional tool.

Not really, at least not easily. You can get some cookies using tell application "Safari" to set someCookies to do JavaScript "document.cookie" in document 1, but that won’t include any HttpOnly cookies—which are generally the ones used for authentication. There might be some way to do it using the Safari Services framework, but I don’t see way to get a PDF of the whole page from that. Plus, at that point, you’re probably better off just scripting Safari via AppleScript.

BTW, one reason this script is so good to have is that it’s one of the fw methods I know that save a web page as one-page PDF, instead of dividing it into multiple pages. The only other one that I remember is an app called Papparazzi! (with the exclamation mark), but this one is simpler and more straightforward.

No problem. I can live without.

Again, I want to thank you for the help and patience.
Have a nice day!