After 5 years I've managed to solve the original problem and which was forced by the fact that the MacOS 11 implementation of WKWebView
printOperationWithPrintInfo
still doesn't properly handle content scrolled out of view and off to the right.
The root issue seems to be that content outside the bounds of the clipping region (especially to the right) is not properly handled. This may be a WKWebView
bug, because it seems to handle some content below the visible rect in the vertical direction.
After much digging, and seeing that others had been able to get the entire content of an NSView
to print and properly paginate by having:
- The view detached (not on screen).
- Setting the frame to the size of the entire content.
- Then calling printWithPrintInfo on the detached view.
I had an idea for a solution:
- Extend
WKWebView
via a Category with functions that get all the content as image tiles. It does this on MacOS via JavaScript and on iOS by manipulating the UIScrollView
associated with the WKWebView
to get the full content size and then scrolling the various parts of the content into the visible area and snapshotting it as a grid of image tiles.
- Create a subclass of
NSView or UIView
that draws all the tiles in their proper relation.
- Call
printWithPrintInfo
on the detached view.
This works well and is tested thru macOS 14.x and iOS 17.x (2024)
On both platforms all the output is properly paginated (iOS requires use of UIPrintPageRenderer which is included in the associated GitHub project) and you can use the open as PDF in Preview and save it as a file, etc.
The only drawback I've encountered is that Print CSS is NOT used, not that that matters much given that Apple's support for Print CSS is currently minimal.
All the working code is on GitHub here: Full working source for iOS and MacOS
THIS Source is Out of Date See Github
The Header
//
// WKWebView+UtilityFunctions.h
// Created by Clifford Ribaudo on 12/24/20.
//
#import <WebKit/WebKit.h>
#ifdef _MAC_OS_ // Up to user to determine how they know this
#define IMAGE_OBJ NSImage
#define VIEW_OBJ NSView
#else
#define IMAGE_OBJ UIImage
#define VIEW_OBJ UIView
#endif
@interface TiledImageView : VIEW_OBJ
{
NSArray *_imageTiles;
}
-(void)printWithPrintInfo:(NSPrintInfo *)pi;
-(instancetype)initWithFrame:(CGRect)frame imageTiles:(NSArray<NSArray *> *)imageTiles;
@end
@interface WKWebView (UtilityFunctions)
-(void)HTMLPageMetrics:(void (^)(CGSize htmlDocSize, CGSize visibleSize, NSError *error))completionHandler;
-(void)currentScrollXY:(void (^)(float x, float y, NSError *error))completionHandler;
-(void)scrollHTMLTo:(float)x topY:(float)y completionHandler:(void (^)(NSError *error))completionHandler;
-(void)imageTilesForHTMLPage:(CGSize)pageSize visbleRect:(CGSize)visibleRect imgData:(NSMutableArray<NSArray *> *)tileData completionHandler:(void (^)(NSError *error))completionHandler;
-(void)imageTile:(CGRect)imgRect fromPageOfSize:(CGSize)pageSize inViewOfSize:(CGSize)viewSize completionHandler:(void (^)(IMAGE_OBJ *tileImage, NSError *error))completionHandler;
@end
The Implementation
//
// WKWebView+UtilityFunctions.m
// Created by Clifford Ribaudo on 12/24/20.
//
// Works with MacOS v10.14+ and ??iOS 13+
//
#import "WKWebView+UtilityFunctions.h"
@implementation TiledImageView
-(instancetype)initWithFrame:(CGRect)frame imageTiles:(NSArray<NSArray *> *)imageTiles
{
self = [super initWithFrame:NSRectFromCGRect(frame)];
if(self) {
_imageTiles = imageTiles;
}
return self;
}
-(BOOL)isFlipped {return YES;}
-(void)printWithPrintInfo:(NSPrintInfo *)pi
{
NSPrintOperation *po = [NSPrintOperation printOperationWithView:self];
po.printInfo = pi;
[po runOperation];
}
- (void)drawRect:(NSRect)rect
{
for(NSArray *imgData in _imageTiles)
{
NSRect drawRect = ((NSValue *)imgData[0]).rectValue;
IMAGE_OBJ *img = imgData[1];
[img drawInRect:drawRect];
}
}
@end
@implementation WKWebView (UtilityFunctions)
//
// Returns via Completion Handler:
// htmlDocSize - The size of the entire <HTML> element, visible or not
// visibleSize - The visible dimensions of the page, essentially WKWebView bounds minus HTML scroll bar dimensions
//
-(void)HTMLPageMetrics:(void (^)(CGSize htmlDocSize, CGSize visibleSize, NSError *error))completionHandler
{
//
// Anonymous Function - gets Size of entire HTML element and visible size.
// Result String = Full X, Full Y, Visible X, Visible Y
//
NSString *jsGetPageMetrics = @"(function(){return document.documentElement.scrollWidth + ',' + document.documentElement.scrollHeight + ',' + document.documentElement.clientWidth + ',' +document.documentElement.clientHeight;})();";
// Execute JS in WKWebView
[self evaluateJavaScript:jsGetPageMetrics completionHandler:^(id result, NSError *error)
{
CGSize htmlSize = CGSizeMake(0, 0);
CGSize visibleSize = CGSizeMake(0, 0);
if(!error && result)
{
NSArray<NSString *> *data = [[NSString stringWithFormat:@"%@", result] componentsSeparatedByString:@","];
htmlSize = CGSizeMake([data[0] floatValue], [data[1] floatValue]);
visibleSize = CGSizeMake([data[2] floatValue], [data[3] floatValue]);
}
else
NSLog(@"JS error getting page metrics: %@", error.description);
completionHandler(htmlSize, visibleSize, error);
}];
}
//
// Get <HTML> element current scroll position (x,y) and return to completeion handler:
// x = document.documentElement.scrollLeft
// y = document.documentElement.scrollTop
//
-(void)currentScrollXY:(void (^)(float X, float Y, NSError *error))completionHandler
{
NSString *jsGetPageMetrics = @"(function(){return document.documentElement.scrollLeft + ',' + document.documentElement.scrollTop;})();";
// Execute JS in WKWebView
[self evaluateJavaScript:jsGetPageMetrics completionHandler:^(id result, NSError *error) {
if(!error && result)
{
NSArray<NSString *> *data = [[NSString stringWithFormat:@"%@", result] componentsSeparatedByString:@","];
completionHandler([data[0] floatValue], [data[1] floatValue], error);
}
else {
NSLog(@"JS error getting page metrics: %@", error.localizedDescription);
completionHandler(0, 0, error);
}
}];
}
//
// Scroll the current HTML page to x, y using scrollTo(x,y) on the <HTML> element
// Optional Completion Handler to do something when scroll finished
//
-(void)scrollHTMLTo:(float)x topY:(float)y completionHandler:(void (^)(NSError *error))completionHandler
{
NSString *js = [NSString stringWithFormat:@"document.documentElement.scrollTo(%0.f, %0.f);", x, y];
// Execute JS in WKWebView
[self evaluateJavaScript:js completionHandler:^(id result, NSError *error)
{
dispatch_time_t delay = dispatch_time(DISPATCH_TIME_NOW, .25 * NSEC_PER_SEC);
dispatch_after(delay, dispatch_get_main_queue(), ^{
if(completionHandler) completionHandler(error);
});
if(error) NSLog(@"JS error scrollTo %@", error.localizedDescription);
}];
}
//
// Called Recursively until tiles are obtained for the entire pageRect.
// Tiles are the size of visibleRect (WKWebView.bounts) but can be smaller.
// tileData - Array of arrays holding CGRect & Img.
//
-(void)imageTilesForHTMLPage:(CGSize)pageSize visbleRect:(CGSize)visibleSize imgData:(NSMutableArray<NSArray *> *)tileData completionHandler:(void (^)(NSError *error))completionHandler
{
__block CGRect currentRect; // In coordinates of pageSize (full).
if(tileData.count == 0) { // No image tiles yet. Start at top left of html page for visible WKWebView bounds
currentRect.origin.x = currentRect.origin.y = 0.0;
currentRect.size = visibleSize;
}
else {
NSArray *lastTile = [tileData lastObject]; // Calculate what the next tile rect is or call handler if done.
CGRect lastTileRect;
#ifdef _MAC_OS_
lastTileRect = ((NSValue *)lastTile[0]).rectValue;
#else
lastTileRect = ((NSValue *)lastTile[0]).CGRectValue;
#endif
// Check if anything more to get to right of last tile
if((lastTileRect.origin.x + lastTileRect.size.width) < pageSize.width)
{
currentRect.origin.x = lastTileRect.origin.x + lastTileRect.size.width + 1; // Next x to right of last tile
currentRect.origin.y = lastTileRect.origin.y; // Works on all rows
currentRect.size.height = lastTileRect.size.height;
currentRect.size.width = pageSize.width - currentRect.origin.x; // Get width of next tile to right of last
if(currentRect.size.width > visibleSize.width) // If more tiles to right use visible width
currentRect.size.width = visibleSize.width;
}
else if((lastTileRect.origin.y + lastTileRect.size.height) < pageSize.height) // New Row
{
currentRect.origin.x = 0; // Reset x back to left side of hmtl
currentRect.size.width = visibleSize.width; // Reset width back to view width
currentRect.origin.y = lastTileRect.origin.y + lastTileRect.size.height + 1; // Get y below last row
currentRect.size.height = pageSize.height - currentRect.origin.y;
if(currentRect.size.height > visibleSize.height) // If more rows below use row height
currentRect.size.height = visibleSize.height;
}
else {
completionHandler(nil);
return;
}
}
[self imageTile:currentRect fromPageOfSize:pageSize inViewOfSize:visibleSize completionHandler:^(NSImage *tileImage, NSError *error)
{
if(error || !tileImage) {
NSLog(@"Error getting image tiles %@", error.description);
completionHandler(error);
return;
}
#ifdef _MAC_OS_
[tileData addObject:@[[NSValue valueWithRect:NSRectFromCGRect(currentRect)], tileImage]];
#else
[tileData addObject:@[[NSValue valueWithCGRect:currentRect], tileImage]];
#endif
[self imageTilesForHTMLPage:(CGSize)pageSize visbleRect:(CGSize)visibleSize imgData:(NSMutableArray<NSArray *> *)tileData completionHandler:completionHandler];
}];
}
//
// ImgRect = location of rect in full page size. Has to be translated into what is visible and where.
// pageSize = Full size of HTML page, visible or not.
// viewSize = essentially the wkwebview.bounds.size - HTML scroll bars.
//
-(void)imageTile:(CGRect)imgRect fromPageOfSize:(CGSize)pageSize inViewOfSize:(CGSize)viewSize completionHandler:(void (^)(IMAGE_OBJ *tileImage, NSError *error))completionHandler
{
float x = imgRect.origin.x; // Always do this to make the desired rect visible in the rect of viewSize
float y = imgRect.origin.y;
CGRect rectToGetFromView;
rectToGetFromView.origin.x = 0;
rectToGetFromView.origin.y = 0;
rectToGetFromView.size = imgRect.size;
// If img is smaller than the viewport, determine where it is after scroll
if(imgRect.size.width < viewSize.width)
rectToGetFromView.origin.x = viewSize.width - imgRect.size.width;
if(imgRect.size.height < viewSize.height)
rectToGetFromView.origin.y = viewSize.height - imgRect.size.height;
[self scrollHTMLTo:x topY:y completionHandler:^(NSError *error)
{
if(!error) {
WKSnapshotConfiguration *sc = [WKSnapshotConfiguration new];
sc.rect = rectToGetFromView;
[self takeSnapshotWithConfiguration:sc completionHandler:^(IMAGE_OBJ *img, NSError *error)
{
if(error) NSLog(@"Error snapshotting image tile: %@", error.description);
completionHandler(img, error);
}];
}
else {
NSLog(@"Error scrolling for next image tile %@", error.description);
completionHandler(nil, error);
}
}];
}
@end
Usage
Use the Category in whatever handles printing for your WKWebView
like so:
-(void)print:(id)sender
{
// Set this as per your needs
NSPrintInfo *pInfo = [NSPrintInfo sharedPrintInfo];
pInfo.verticallyCentered = YES;
pInfo.horizontallyCentered = NO;
pInfo.horizontalPagination = NSAutoPagination;
pInfo.verticalPagination = NSAutoPagination;
pInfo.orientation = NSPaperOrientationLandscape;
pInfo.bottomMargin = 30;
pInfo.topMargin = 30;
pInfo.leftMargin = 30;
pInfo.rightMargin = 30;
pInfo.scalingFactor = .60;
[_webView HTMLPageMetrics:^(CGSize htmlSize, CGSize visibleSize, NSError *error)
{
self->_imgTileData = [NSMutableArray new];
[self->_webView imageTilesForHTMLPage:htmlSize visbleRect:visibleSize imgData:self->_imgTileData completionHandler:^(NSError *error) {
if(!error) {
TiledImageView *tiv = [[TiledImageView alloc] initWithFrame:CGRectMake(0,0,htmlSize.width,htmlSize.height) imageTiles:self->_imgTileData];
[tiv printWithPrintInfo:pInfo];
}
}];
}
}
Here is the code as a Github Gist: Above code
And from this WKWebView
with content below and also scrolled off to the right:
One gets this print dialog with proper pagination: