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

7

40

This question is about printing ALL content (including off screen content) of WKWebView. Currently (still, as of iOS 17 or macOS 14.4) there is NO working solution and none of the supposed solutions on Stackoverflow work. Only provide an answer here if you have verified for yourself that you can print OFF SCREEN CONTENT, and if you did then provide the working example code.

I'm trying to print ALL the content of a WKWebView or WebView on macOS (Currently running on 14). For example, a wide html table where columns are out of view and off to the right. Earlier versions of OSX would automatically paginate and correctly print all the html.

I've tried using the solutions available here on Stackoverflow and elsewhere. All essentially say the same thing which is to print the documentView like so:

[[NSPrintOperation printOperationWithView:_webView.mainFrame.frameView.documentView printInfo:pInfo] runOperation];

This stopped working for both WKWebView or WebView in 10.10. If you do this:

[[NSPrintOperation printOperationWithView:_wkWebView printInfo:pInfo] runOperation];

You get pagination but the printout includes scroll bars WebView, and the other WKWebView gives you blank pages.

I can't find any mention whatsoever in Apple documentation about printing for WKWebView on OSX. Nor can I find any answer that is OSX specific and not iOS.

Does anyone have ANY idea how to print these on OSX?

UPDATE: This is a bug in WebView [Radar:23159060] (still open 2/2018) and WKWebView does not even appear to address printing on OSX. After examining the Open Source for this class on the net, I see that all of the classes that have anything to do with printing are in a conditional compilation block that only supports platform: iOS.

UPDATE Part Deux: Amazingly this ridiculous bug exists in ALL implementations of this class including those on iOS! I find it ridiculous that this is still not fixed at this late date despite the documentation's statement to use this (and only this class) in Apps that support iOS 8 or above. It is now IMPOSSIBLE to print all the on screen and off screen content of a WebView on either iOS or OSX. Fail Apple. Time to FIX THIS! We all know what Steve would've said about it....

UPDATE Part THREE :)- Yet still more amazing is that this issue is NOT resolved as of 10.15.2 and coming up on 4+ YEARS!!! this issue has been floating around (Apple waaaaaake up up up up....). It's kind of amazing considering they're getting very pushy about using WKWebView and over in iOS land even rejecting Apps that don't (unless you're trying to support iOS 7).

UPDATE Part FOUR (2020... can you believe it?!?): As of Big Sur, this is still an issue.

UPDATE Part FIVE (2024) NONE of the recently exposed asynchronous methods can do this out of the box (as of macOS 14, not sure about iOS 17 yet).

I solved it by writing a work around see accepted answer below:

printOperationWithPrintInfo:

DOES NOT print all content which is off screen or scrolled out of view in either the Horizontal or vertical direction. It does however use your Print CSS which is a slight advantage over:

- (void)takeSnapshotWithConfiguration:(WKSnapshotConfiguration *)snapshotConfiguration 
                completionHandler:(void (^)(NSImage *snapshotImage, NSError *error))completionHandler;

To get it to work I did:

NSPrintInfo *pInfo = [[NSPrintInfo alloc] initWithDictionary:printInfoDict];
pInfo.horizontalPagination = NSPrintingPaginationModeAutomatic;
pInfo.verticalPagination = NSPrintingPaginationModeAutomatic;
pInfo.verticallyCentered = YES;
pInfo.horizontallyCentered = YES;
pInfo.orientation = NSPaperOrientationLandscape;
pInfo.leftMargin = 30;
pInfo.rightMargin = 30;
pInfo.topMargin = 30;
pInfo.bottomMargin = 30;

NSPrintOperation *po = [_webView printOperationWithPrintInfo:pInfo];
po.showsPrintPanel = YES;
po.showsProgressPanel = YES;

// Without the next line you get an exception. Also it seems to
// completely ignore the values in the rect. I tried changing them
// in both x and y direction to include content scrolled off screen.
// It had no effect whatsoever in either direction. 
po.view.frame = _webView.bounds; 

// [printOperation runOperation] DOES NOT WORK WITH WKWEBVIEW, use 
[po runOperationModalForWindow:self.view.window delegate:self didRunSelector:@selector(printOperationDidRun:success:contextInfo:) contextInfo:nil];

**Also there is something going on I don't fully understand. It doesn't seem to matter what size your wkWebView is. If I size the App to hide some of the content it still seems to grab as much that IS off screen as will fit on the page specified, but it doesn't seem to know how to paginate content that will not fit on the page size onto other pages. So that appears to be where the issue is. There may be some way around this and if anyone has a clue post it here!!

Overlay answered 24/10, 2015 at 14:30 Comment(4)
Yeah, it's a bug for sure. I've not really found any workaround and have gotten absolutely no response from anyone, anywhere ;)Overlay
Radar link for anyone interested openradar.me/23649229Arrive
I've read that Apple will stop accepting apps that use UIWebView on iOS, but does this also affect WebView in MacOS?Ardithardme
@Ardithardme not yet but probably will soon.Overlay
O
3

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.

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

One gets this print dialog with proper pagination: enter image description here

Overlay answered 29/12, 2020 at 16:25 Comment(1)
1) There's no particular reason this post came under scrutiny, it's just a result of one of the ongoing site-wide curation efforts. 2) I can share some Meta Stack Overflow links if you'd like, but the general principle is that redundant text is removed from posts. e.g. "thanks" is unnecessary, since it is assumed that users are thankful. Similarly, "problem solved" is basically implied by the fact that the answer has been accepted. The fact that you solved your own problem is certainly not redundant of course. Thank you for editing the answer to make that clearer by leading with that statement.Pilloff
R
3

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
Raney answered 3/3, 2020 at 16:19 Comment(15)
This sounds promising. By offscreen content (in my specific use case) I was talking about data in columns of an html table that is off to the right of the visible portion of the webview (scrollable content). Not exactly following what you mean can you describe a little more what specifically you are doing?Overlay
Yes "my" technique definitely supports what you're asking for: offscreen content in my case was vertical out of view frame. Your offscreen content is horizontal out of view frame, which is not different. By using the printOperation/PrintInfo you can give a "paper size" which is what's important for the result. WebKit will layout the printed WKWebView based on paper size, not based on NSView size!Raney
Also attempted to use this approach via this answer: #27128419 and which also crashes with the following ERROR: The NSPrintOperation view's frame was not initialized properly before knowsPageRange: returned. This will fail in a future release! (WKPrintingView)Overlay
No this is not deprecated, it's available since 10.11 and still there. I found it in WebKit source code. But yes it's private API. Seems pretty robust though so I'm confident. for the future. Please read NSPrintOperation documentation, yes you have to implement the -printOperationDidRun:success:contextInfo: callback! but this is normal print code on OS X since... 2001 :) The "The NSPrintOperation view's frame was not initialized properly before knowsPageRange: returned. This will fail in a future release! (WKPrintingView)" also appears in WebKit test app. This is new to 10.15 Not harmfulRaney
I tried again implementing the didRun method and what I got was one page that was missing the content off to the right. When I enabled the showPrintPanel to see if there was any other content it crashes again with that second error message I mentioned above. If you think this is a solution (Im not sure at this point) edit your answer to show the full working example including the didRun method and how one would get a the print dialog.... unless you are saying this ONLY works printing to a file.Overlay
It definitely works for a printer or a PDF file (configuring _ NSPrintJobDisposition_ key). Please read the Print API documentation from Apple as it's pretty big. You need to configure paper size of course. If your webview content is too large for a paper size, either use the auto-fit option or widen your paper size. Your problem is not WebView printing now. It's just standard print.Raney
After refreshing my memory re Printing I don't understand what you are talking about anymore or why you think this should work?! I am not exactly a noob. The only real difference between your code and the code that worked in the past is that you use that private method and add the didRun method and launch modal. However I've never gotten past the ERROR: The NSPrintOperation view's frame was not initialized properly before knowsPageRange:. The only way I could see to get past it is to subclass the WKWebView and override that method.. which I am not going to do.Overlay
Sorry but you're asking pretty basic questions and do not seem to understand usual Cocoa idioms such as common callbacks implementation. The knowsPageRange: error is not very important. That's just an internal Apple log that does not affect the printing. I've successfully PDFified hundreds of HMTL pages rendered using WKWebView using this code.Raney
Yeah ok dude, whatever. It might work in the vertical direction with paper in portrait mode. The original question was how to do it in the Horizontal direction. Apple broke this in WebView and then WKWebView and there we have sat for the last 4 years.Overlay
Notes from you should be added as comments, not edits into the answer @CliffRibaudoBournemouth
DOESN'T WORK - See answer below. This solution has the same issue that has existed for quite some time. If one uses the unpublished method and ignores the errors thrown and sets margins, paper size, and the following flags on NSPrintInfo: orientation = NSPaperOrientationLandscape and horizontalPagination = NSPrintingPaginationModeAutomatic, the result is the same as it has been for quite some time and which is a print dialog displays and the print preview has ONLY 1 page and which is missing the content off to the right. WKWebView does not understand how to paginate content off to the right.Overlay
Hello from 2022. The methods are no longer private from 11.0 upwards, so I tried replacing my legacy WebViews, which I use to create PDF files in the background, using custom paper size and settings. It turns out that runOperation still doesn't work with WKWebView unless you display a dialog — or yes, it works, but creates blank white pages. It also still doesn't support vertical pagination. I have contacted Apple about this, but haven't heard back.Brentbrenton
@Brentbrenton yup, pretty typical Apple nonsense really -> Create Incomplete/Broken New Widget -> Deprecate Old Widget -> Get distracted with other shiny new things and leave Devs stranded at Sea :) AmmazzzzingOverlay
@CliffRibaudo I was actually wrong. The documentation is just a bit strange, because you have to use asynchronous operations, which isn't mentioned anywhere. With async operation, the content is rendered correctly.Brentbrenton
@tritonal Ok interesting. Can you respond to this question with an answer showing what you did?Overlay
O
3

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.

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

One gets this print dialog with proper pagination: enter image description here

Overlay answered 29/12, 2020 at 16:25 Comment(1)
1) There's no particular reason this post came under scrutiny, it's just a result of one of the ongoing site-wide curation efforts. 2) I can share some Meta Stack Overflow links if you'd like, but the general principle is that redundant text is removed from posts. e.g. "thanks" is unnecessary, since it is assumed that users are thankful. Similarly, "problem solved" is basically implied by the fact that the answer has been accepted. The fact that you solved your own problem is certainly not redundant of course. Thank you for editing the answer to make that clearer by leading with that statement.Pilloff
A
0

This isn't the correct answer because Apple needs to supply the correct answer with a working print method or .evaluateJavaScript("window.print()", completionHandler: nil)

But I have a stupid solution that "works" for me and perhaps it will help other people with a work around until then.

Step 1: Grab the HTML and fixup the <body> tag with <body onload='window.print()'>. If you are fetching the html from someplace and not loading your own, you'll want to use some regular expressions. I won't go into that.

Step 2: Save the html in a file someplace and keep the full path in a variable. In my example: filename

Step 3: Wire your print button to this code:

RunCommand(command: "/usr/bin/open \(filename)")

See code for RunCommand below. This leaves a dumb safari window laying around, but it makes it possible to get something printed without saving to a file and then remembering where you stuck it so you can open it with Safari on your own to do the printing.

func RunCommand(command: String)  -> (success: Bool, result: String) {
        let cc = command.components(separatedBy: " ")
        let process = Process()
        process.launchPath = cc[0]
        var cp: [String] = []
        for i in (1..<cc.count) {
            cp.append(cc[i])
        }
        process.arguments = cp
        let pipe = Pipe()
        process.standardOutput = pipe
        process.launch()
        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        if (data.count > 0) {
            let output = String(data: data, encoding: String.Encoding.utf8)
            // let output = NSString(data: data, encoding: String.Encoding.utf8.rawValue)
            return (success: true, result: output!)
        }
        return (success: true, result: "")

    }

It would be really nice if Apple could fix this. I have an in-house app that does all its reporting in HTML and reports are something you kind of want to print.

Art answered 29/12, 2018 at 18:7 Comment(0)
B
-1

In iOS, I pass UIView.viewPrintFormatter() to UIActivityViewController to allow users to print everything

let myShare = [webView.viewPrintFormatter()]
let avc = UIActivityViewController(activityItems: myShare, applicationActivities: nil)

present(avc, animated: true, completion: nil)
Bute answered 8/7, 2017 at 3:55 Comment(0)
L
-1

I have exhausted every possible way to print the WKWebView directly with no success. The only workaround I can think of would be to convert the web page to a PDF object and then print said object. I will update with code if I get the workaround to work.

Libelant answered 26/12, 2017 at 23:19 Comment(0)
K
-1

this is my swift version (updated for OSX 11) of @Altimac's solution.

@available(OSX 11.0, *)
extension BrowserViewController
{
    func print(pdf: Bool)
    {
        guard var printInfoDictionary = NSPrintInfo.shared.dictionary().mutableCopy() as? [NSPrintInfo.AttributeKey: Any] else { return }
        
        printInfoDictionary[NSPrintInfo.AttributeKey.jobDisposition] = pdf ? NSPrintInfo.JobDisposition.preview : NSPrintInfo.JobDisposition.spool
        printInfoDictionary[NSPrintInfo.AttributeKey.detailedErrorReporting] = true
        
        let printInfo = NSPrintInfo(dictionary: printInfoDictionary)
        printInfo.horizontalPagination = .automatic
        printInfo.verticalPagination = .automatic
        printInfo.isVerticallyCentered = false
        printInfo.isHorizontallyCentered = false
        printInfo.leftMargin = 10
        printInfo.rightMargin = 10
        printInfo.topMargin = 10
        printInfo.bottomMargin = 10
        
        let printOperation = theWebView.printOperation(with: printInfo)
        
        printOperation.showsPrintPanel = false
        printOperation.showsProgressPanel = false
        
        printOperation.runModal(for: self.view.window!, delegate: self, didRun: nil, contextInfo: nil)
    }
}

seems to work for me at least.

Kus answered 29/1, 2021 at 10:55 Comment(1)
Altimac's solution DOES NOT solve the original question. And neither does your code solve the original questions which was PRINT ALL CONTENT OFF SCREEN IN BOTH HORIZONTAL and VERTICAL DIRECTION and Properly paginate. All you are doing here is essentially posting usage of Apples PrintOperation method added to MacOS 11. If that worked (obviously I tried it first thing) and solution was this simple I would have used it instead of writing all the code in the now accepted answer.Overlay
B
-1

This is a basic implementation of printing content from WKWebView in macOS 11+. You will need a class which is retained in memory, because you'll have to use asynchronous calls. Make your class conform to WKNavigationDelegate to catch when the content has been loaded.

Before exporting the PDF you'll need to both make sure that the web page has loaded (didFinishNavigation) and display the view.

- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
    [webView display];

    // Somehow tell your class that we are ready to print
}

After that, you can use this method to create the PDF and save it to given URL. Because this is an asynchronous method, you'll need to have a property where to store the url. In this case, it's called pdfURL.

- (void)exportPDFtoURL:(NSURL*)url forPreview:(bool)preview {
    // Save the url for asynchronous callback.
    self.pdfURL = url;
    
    NSPrintInfo *printInfo = NSprintInfo.sharedPrintInfo;
    [printInfo.dictionary addEntriesFromDictionary:@{
        NSPrintJobDisposition: NSPrintSaveJob,
        NSPrintJobSavingURL: url
    }];
    
    NSPrintOperation *printOperation [(WKWebView*)_webView printOperationWithPrintInfo:printInfo];
    printOperation.view.frame = NSMakeRect(0,0, printInfo.paperSize.width, printInfo.paperSize.height);

    printOperation.showsPrintPanel = NO;
    printOperation.showsProgressPanel = YES;
    
    // Print the content and call a selector afterwards
    [printOperation runOperationModalForWindow:self.window delegate:self didRunSelector:@selector(printOperationDidRun:success:contextInfo:) contextInfo:nil];
}

- (void)printOperationDidRun:(id)operation success:(bool)success contextInfo:(nullable void *)contextInfo {
    // Remove WKWebView from memory
    self.webView.navigationDelegate = nil;
    [webView.configuration.userContentController removeAllUserScripts];

    // Do something with the self.pdfURL here

}
Brentbrenton answered 4/2, 2023 at 20:21 Comment(5)
This answer appears to be incomplete. Where does tempURL come from? It also appears that exportPDFtoURL will be called for every URL visitedSculptress
I clarified the answer.Brentbrenton
Ok dude, thanks for trying but I had to vote it down because I just got around to trying this and STILL Apple does not seem to understand how to paginate anything in the horizontal direction. The code I tried was a variation of what you did here but the bottom line is it does not work. I tried several variations of exporting to NSData and using the printOp off the WKWebView. nada, NOTHING works. All I get is 1 page and NOTHING off to the right. So far my code is the only thing I have seen that does it. I will have to convert to Swift I guess.Overlay
In fact I tried ALL of the current Asynchronous Methods on WKWebView and NONE of them will include the content off to the right of the visible area in any object they create. Not the NSData or the PDF. Ammmmaaaazzzing that this is STILL an issue after all these years?!Overlay
Sorry, I wasn't here to reply to you, but yes – creating PDF using the WKWebView printing methods is actually pretty difficult. Paper size is strictly defined by installed printeres (and without one installed it's a predefined, weird size) so it's pretty impossible to print horizontal pagination. You can try taking screenshots and providing them as page views using the native print methods, but you'll end up with rasterized content.Brentbrenton

© 2022 - 2024 — McMap. All rights reserved.