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

I've successfully used the SPI -[WKWebView _printOperationWithPrintInfo:] passing the usual [NSPrintInfo sharedPrintInfo]. Note that you CAN'T use -runOperation on the returned NSPrintOperation. You must use -runOperationModalForWindow:.... which is quite similar. The problem resides in the WebKit internals that expects a running runloop and a preview to be made internally to know the number of pages.

It definitely works with offscreen content, if what you mean by offscreen is "not fully displayed on screen". I still have a WKWebView displayed in a window, but it's very tiny and only displays a very short fraction of the entire webview content (21 A4 pages!). Hope this helps!

PS: Tested on 10.12, 10.14 and 10.15. Code is like this:

     SEL printSelector = NSSelectorFromString(@"_printOperationWithPrintInfo:"); // This is SPI on WKWebView. Apparently existing since 10.11 ?
     
     NSMutableDictionary *printInfoDict = [[[NSPrintInfo sharedPrintInfo] dictionary] mutableCopy];
     printInfoDict[NSPrintJobDisposition] = NSPrintSaveJob; // means you want a PDF file, not printing to a real printer.
     printInfoDict[NSPrintJobSavingURL] = [NSURL fileURLWithPath:[@"~/Desktop/wkwebview_print_test.pdf" stringByExpandingTildeInPath]]; // path of the generated pdf file
     printInfoDict[NSPrintDetailedErrorReporting] = @YES; // not necessary         

     // customize the layout of the "printing"
     NSPrintInfo *customPrintInfo = [[NSPrintInfo alloc] initWithDictionary:printInfoDict]; 
     [customPrintInfo setHorizontalPagination: NSPrintingPaginationModeAutomatic];
     [customPrintInfo setVerticalPagination: NSPrintingPaginationModeAutomatic];
     [customPrintInfo setVerticallyCentered:NO];
     [customPrintInfo setHorizontallyCentered:NO];
     customPrintInfo.leftMargin = 0;
     customPrintInfo.rightMargin = 0;
     customPrintInfo.topMargin = 5;
     customPrintInfo.bottomMargin = 5;
  
     NSPrintOperation *printOperation = (NSPrintOperation*) [_webView performSelector:printSelector withObject:customPrintInfo];

     [printOperation setShowsPrintPanel:NO];
     [printOperation setShowsProgressPanel:NO];

//    BOOL printSuccess = [printOperation runOperation]; // THIS DOES NOT WORK WITH WKWEBVIEW! Use runOperationModalForWindow: instead (asynchronous)
     [printOperation runOperationModalForWindow:self.window delegate:self didRunSelector:@selector(printOperationDidRun:success:contextInfo:) contextInfo:nil]; // THIS WILL WORK, but is async

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: enter image description here