Print Production with Quartz and Cocoa

I wrote a post on the Newspaper Club blog the other day about ARTHR & ERNIE, our systems for making a newspaper in your browser.

One of the things I touched on was Quartz, part of Cocoa's Core Graphics stack, and how we use it to generate fast previews and high-quality PDFs on the fly, as a user designs their paper.

If you need to do something similar, even if it's not in real-time, Quartz is a great option. Unlike the PDF specific generation libraries, such as Prawn, it's fast and flexible, with a great quality typography engine (Core Text). And unlike the lower-level rasterisation libraries, like Cairo and Skia, it supports complex colour management with CMYK support. The major downside is that you need to run it on Mac OS X, for which hosting is less available and slightly arcane.

It took a lot of fiddling to understand exactly how to best use all the various APIs, so I thought it might be useful for someone if I just wrote down a bit of what I learnt along the way.

I'm going to assume you know something about Cocoa and Objective-C. All these examples run on Mac, but apart from the higher level Core Text Layout System, the same APIs should be available on iOS too. They assume ARC support.

Generating Preview Images

Let's say we have an NSView hierarchy, containing things like an NSImageView or an NSTextView.

Generating an NSImage is pretty easy - you render the NSView into an NSGraphicsContext backed by an NSBitmapImageRep, like so:

- (NSImage *)imageForView:(NSView *)view width:(float)width { 
float scale = width / view.bounds.size.width;

float height = round(scale * view.bounds.size.height);


NSString *colorSpace = NSCalibratedRGBColorSpace;

NSBitmapImageRep *bitmapRep;

bitmapRep = [[NSBitmapImageRep alloc] initWithBitmapDataPlanes:nil pixelsWide:width pixelsHigh:height bitsPerSample:8 samplesPerPixel:4 hasAlpha:YES isPlanar:NO colorSpaceName:colorSpace bitmapFormat:0 bytesPerRow:(4 * width) bitsPerPixel:32];


NSGraphicsContext *graphicsContext;
graphicsContext = [NSGraphicsContext graphicsContextWithBitmapImageRep:bitmapRep];
[graphicsContext setImageInterpolation:NSImageInterpolationHigh];
CGContextScaleCTM(graphicsContext.graphicsPort, scale, scale);

[pageView displayRectIgnoringOpacity:view.bounds inContext:graphicsContext];

NSImage *image = [[NSImage alloc] initWithSize:bitmapRep.size];
[image addRepresentation:bitmapRep];
return image;

}

You can then convert this to a JPEG or similar for previewing.

NSBitmapImageRep *imageRep = [[image representations] objectAtIndex:0];
NSData *bitmapData = [imageRep representationUsingType:NSJPEGFileType properties:nil];

Generating PDFs

Generating a PDF is easy too, given an NSArray of views in page order.

- (NSData *)pdfDataForViews:(NSArray *)viewsArray { 
NSMutableData *data = [NSMutableData data];
CGDataConsumerRef consumer;
consumer = CGDataConsumerCreateWithCFData((__bridge CFMutableDataRef)data);

// Assume the first view is the same size as the rest of them
CGRect mediaBox = [[views objectAtIndex:0] bounds];
CGContextRef ctx = CGPDFContextCreate(consumer, &mediaBox, nil);
CFRelease(consumer);

NSGraphicsContext *gc = [NSGraphicsContext graphicsContextWithGraphicsPort:ctx flipped:NO];

[viewsArray enumerateObjectsUsingBlock: ^(NSView *pageView, NSUInteger idx, BOOL *stop) {
CGContextBeginPage(ctx, &mediaBox);
CGContextSaveGState(ctx);
[pageView displayRectIgnoringOpacity:mediaBox inContext:gc];
CGContextRestoreGState(ctx);
CGContextEndPage(ctx);
}];


CGPDFContextClose(ctx);
CGContextRelease(ctx);
return data;
}

Quartz maps very closely to the PDF format, making the PDF rendering effectively a linear transformation from Quartz's underpinnings. But Apple's interpretation of the PDF spec is odd in ways I don't quite understand, and can cause some problems with less flexible PDF parsers, such as old printing industry hardware.

To fix this, we post process the PDF in Ghostscript, taking the opportunity to reprocess the images into a sensible maximum resolution for printing (150 DPI in our case). We end up with a file in PDF/X-3 format, a subset of the PDF spec recommended for printing.

- (NSData *)postProcessPdfData:(NSData *)data {
NSTask *ghostscriptTask = [[NSTask alloc] init];
NSPipe *inputPipe = [[NSPipe alloc] init];
NSPipe *outputPipe = [[NSPipe alloc] init];

[ghostscriptTask setLaunchPath:@"/usr/local/bin/gs"];
[ghostscriptTask setCurrentDirectoryPath:[[NSBundle mainBundle] resourcePath]];
NSArray *arguments = @[ @"-sDEVICE=pdfwrite", @"-dPDFX", @"-dSAFER", @"-sProcessColorModel=DeviceCMYK", @"-dColorConversionStrategy=/LeaveColorUnchanged", @"-dPDFSETTINGS=/prepress", @"-dDownsampleColorImages=true", @"-dDownsampleGrayImages=true", @"-dDownsampleMonoImages=true", @"-dColorImageResolution=150", @"-dGrayImageResolution=150", @"-dMonoImageResolution=150", @"-dNOPAUSE", @"-dQUIET", @"-dBATCH", @"-P", // look in current dir for the ICC profile referenced in PDFX_def.ps @"-sOutputFile=-", @"PDFX_def.ps", @"-"];

[ghostscriptTask setArguments:arguments];
[ghostscriptTask setStandardInput:inputPipe];
[ghostscriptTask setStandardOutput:outputPipe];
[ghostscriptTask launch];

NSFileHandle *writingHandle = [inputPipe fileHandleForWriting];
[writingHandle writeData:data];
[writingHandle closeFile];
NSFileHandle *readingHandle = [outputPipe fileHandleForReading];
NSData *outputData = [readingHandle readDataToEndOfFile];
[readingHandle closeFile];
return outputData;
}

PDFX_def.ps is a Postscript file, used by Ghostscript to ensure the file is X/3 compatible.

CMYK Images

Because Quartz maps so closely to the PDF format, it won't do any conversion of your images at render time. If you have RGB images and CMYK text you'll end up with a mixed PDF.

Converting an NSImage from RGB to CMYK is easy though:

NSColorSpace *targetColorSpace = [NSColorSpace genericCMYKColorSpace];
NSBitmapImageRep *targetImageRep;
if ([sourceImageRep colorSpace] == targetColorSpace) {
targetImageRep = sourceImageRep;
} else {
targetImageRep = [sourceImageRep bitmapImageRepByConvertingToColorSpace:targetColorSpace renderingIntent:NSColorRenderingIntentPerceptual];
}

NSData *targetImageData = [targetImageRep representationUsingType:NSJPEGFileType properties:nil];
NSImage *targetImage = [[NSImage alloc] initWithData:targetImageData];

Multi-Core Performance

Normally in Cocoa, all operations that affect UI should happen on main thread. However, we have some exceptional circumstances which means we can parallelise some of the slower bits of our code if we want to, for performance.

Firstly, our NSViews stand alone, they don't appear on screen, they're not part of an NSWindow, and they're not going to be affected by any other part of the operating system. This means we don't need to specifically use main thread for our UI operations - nothing else will be touching them.

The method that actually performs the rasterisation of an NSView is thread-safe, assuming the NSGraphicsContext is owned by the thread, but there are often shared objects behind the scenes, such as Core Text controllers. You can either take private copies of these (which seems like an opportunity to introduce some nasty and complex bugs), or you can single-thread the rasterisation process, but multi-thread everything either side of it, such as the loading and conversion of any resources beforehand and the JPEG conversion afterwards.

We use a per-document serial dispatch queue, and put the rasterisation through that, which still gives us multi-core image conversion (the slowest portion of the code).

- (NSArray *)jpegsPreviewsForWidth:(float)width quality:(float)quality { 
NSUInteger pageCount = document.pages.count;
NSMutableArray *pagePreviewsArray = [NSMutableArray arrayWithCapacity:pageCount];
// Set all the elements to null, so we can replace them later.
// TODO: I'm not sure if this is necessary.
for (int i = 0; i < pageCount; i++) {
[pagePreviewsArray addObject:[NSNull null]];
}

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_queue_t serialQueue;
serialQueue = dispatch_queue_create("com.newspaperclub.PreviewsSynchronizationQueue", NULL);
dispatch_apply(pageCount, queue, ^(size_t pageIndex) {
NSData *jpegData = [self jpegForPageIndex:pageIndex width:width quality:quality];
// Synchronize access to pagePreviewsArray through a serial dispatch queue
dispatch_sync(serialQueue, ^{
[pagePreviewsArray replaceObjectAtIndex:pageIndex withObject:jpegData];
});
});

return pagePreviewsArray;
}

- (NSData *)jpegForPageIndex:(NSInteger)pageIndex width:(float)width quality:(float)quality {
// Perform rasterization on render queue
__block NSImage *image;

dispatch_sync(renderDispatchQueue, ^{
image = [self imageForPageIndex:pageIndex width:width];
});

NSBitmapImageRep *imageRep = [[image representations] objectAtIndex:0];
NSNumber *qualityNumber = [NSNumber numberWithFloat:quality];
NSDictionary *properties = @{ NSImageCompressionFactor: qualityNumber };
NSData *bitmapData = [imageRep representationUsingType:NSJPEGFileType properties:properties];
return bitmapData;
}

The End

Plumping for Quartz + Cocoa to do something like invoice generation is likely to be overkill - you're probably better off with a higher level PDF library. But if you need to have very fine control over a document, to have quality typography, and to render it with near real-time performance, it's a great bet and we're very happy with it.