How Does One Print All Wkwebview on and Offscreen Content Osx and iOS

How does one Print all WKWebView On AND Offscreen content OSX and iOS

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:

  1. 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.
  2. Create a subclass of NSView or UIView that draws all the tiles in their proper relation.
  3. Call printWithPrintInfo on the detached view.

It works well on MacOS 10.14+ iOS 13+

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: WKWebView with content Out of View

One gets this print dialog with proper pagination:
Sample Image

WebView vs WKWebView on OSX

Its not a false alarm. Apple moved Safari off of UIWebView/WebView in 6.0, so security fixes simply aren't happening in the old class as much. For that reason alone you should not use it for new stuff. Apple has been incrementally improving it with every OS release so much is now do-able with private extensions (file:// access, downloads, etc)

That being said, it won't be fully equivalent to old WebView. You can't directly access the DOM anymore because the Network/Rendering/UI processes were split up and you create locks by making element references like that. Use the postMessage() message handler and wkwebview.evaluateJavaScript() and callback-ish/promisy JS code between those two pillars to deal with web<>native interaction asynchronously.



Related Topics



Leave a reply



Submit