Creating a simple PDF viewer for OS X using FMX and the Core Graphics API

Having needed to use OS X’s ‘high level’ PDF support to create print-quality PDFs (no prizes for guessing what this was for exactly), I wondered what the PDF APIs were like at the developer level. Soon finding a simple PDF viewer example in the Apple documentation, I thought I’d knock up a FireMonkey port of it – this is the result.

To give a bit of background, the PDF API on OS X is part of the ‘Core Graphics’ layer. Similar to Core Foundation (which it uses), this is a straight C API that nevertheless works in terms of reference counted ‘objects’. The reference counting is all manual, though the effort required doesn’t depend on the language used to program it. Furthermore, XE2 provides all the needed header translations in the box – the main unit to use is Macapi.CoreGraphics, with relevant types also in Macapi.CoreFoundation and Macapi.CocoaTypes.

To get going, create a new FireMonkey HD application in the IDE, and do the following:

  • Add a TPanel to the form and set its Align property to alTop. Then, put some TSpeedButton objects on the panel for the previous page, next page, rotate left and rotate right commands. Set the Enabled property of each button to False.
  • Add a TPaintBox, name it pbxPDF, and set its Align property to alClient.
  • Add TMainMenu; double click it, and add a File menu with a Open… command.
  • Add TOpenDialog component, and call it dlgOpen.
  • Give the form a white background by expanding its Fill property in the Object Inspector and setting Kind to bkSolid.

With a little bit of fit and finishing, you should end up with something like the following:

The button glyphs were added by dropping a TImage onto each button before setting the TImages’ HitTest properties to False; to get the text aligned nicely, I then added TLabel controls in the same fashion and cleared the buttons’ own Text properties. (NB: due to a silly decision made in XE2 update 4, you will need to use the structure pane to actually parent an image or label to a button.) If you look carefully, you will also see a divider between the ‘Next Page’ and ‘Rotate Left’ buttons – this is a TLine with a TBevelEffect added to it. Finally, the Margins and Paddings properties are used to space everything out nicely.

User interface done, we now need to write the code to load and display a PDF. With respect to the former, the API function to call is CGPDFDocumentCreateWithURL, which returns a CGPDFDocument ‘object’ typed to CGPDFDocumentRef. To put it to work, first add Macapi.CoreFoundation, Macapi.CocoaTypes and Macapi.CoreGraphics to the ‘interface’ section uses clause. Then, insert the following helper function to the top of the unit’s ‘implementation’ section, just after the {$R*.fmx}:

function OpenCGPDFDocument(const AFileName: string): CGPDFDocumentRef;
var
  Path: CFStringRef;
  URL: CFURLRef;
begin
  Path := CFStringCreateWithCharacters(nil, PChar(AFileName), Length(AFileName));
  URL := CFURLCreateWithFileSystemPath(nil, Path, kCFURLPOSIXPathStyle, False);
  Result := CGPDFDocumentCreateWithURL(URL);
  CFRelease(URL);
  CFRelease(Path);
end;

This routine first converts the Delphi to a Core Foundation string, secondly converts the path string to a CFURL object, before finally calling CGPDFDocumentCreateWithURL itself; lastly, the CFURL and CFString objects we just created are released.

Next, amend the ‘private’ section of the form class’ declaration to look like this:

  private
    FDocRef: CGPDFDocumentRef;
    FMainCaption: string;
    FPageRef: CGPDFPageRef;
    FPageCount, FPageIndex, FRotation: Integer;
    procedure FreeDocRef;
    procedure SetPageIndex(NewIndex: Integer);

This just declares a few fields and a couple of helper methods. These can be implemented like this:

procedure TfrmPDFViewer.FreeDocRef;
begin
  if FDocRef = nil then Exit;
  CGPDFDocumentRelease(FDocRef);
  FDocRef := nil;
end;

procedure TfrmPDFViewer.SetPageIndex(NewIndex: Integer);
begin
  if FDocRef = nil then
    Caption := FMainCaption
  else
  begin
    Caption := Format('%s (%d of %d)', [FMainCaption, NewIndex, FPageCount]);
    FPageRef := CGPDFDocumentGetPage(FDocRef, NewIndex);
  end;
  FPageIndex := NewIndex;
  btnPrevPage.Enabled := (NewIndex > 1);
  btnNextPage.Enabled := (NewIndex < FPageCount);
  pbxPDF.Repaint;
end;

To draw a page of a PDF, you need a CGPDFPage object – this is the reason for calling CGPDFDocumentGetPage above. You may notice that FPageRef is not then ‘released’ in FreeDocRef however. This is because the reference was received via what Apple calls the ‘get rule’ – in other words, calling CGPDFDocumentGetPage does not increment the reference count of the returned object, with the consequence that releasing it without explicitly ‘retaining’ it first would be an error.

The next thing to do is to handle relevant events: specifically, the form’s OnCreate and OnDestroy events, the File|Open menu’s OnClick event, and the four buttons’ OnClick events. The handlers are all quite straightforward:

procedure TfrmPDFViewer.FormCreate(Sender: TObject);
begin
  FMainCaption := Caption;
  itmFileOpen.ShortCut := scCommand or Ord('O');
end;

procedure TfrmPDFViewer.FormDestroy(Sender: TObject);
begin
  FreeDocRef;
end;

procedure TfrmPDFViewer.itmFileOpenClick(Sender: TObject);
begin
  if not dlgOpen.Execute then Exit;
  FreeDocRef;
  FDocRef := OpenCGPDFDocument(dlgOpen.FileName);
  if FDocRef <> nil then
    FPageCount := CGPDFDocumentGetNumberOfPages(FDocRef)
  else
    FPageCount := 0;
  FRotation := 0;
  btnRotateLeft.Enabled := (FPageCount > 0);
  btnRotateRight.Enabled := (FPageCount > 0);
  SetPageIndex(1);
end;

procedure TfrmPDFViewer.btnPrevPageClick(Sender: TObject);
begin
  SetPageIndex(FPageIndex - 1);
end;

procedure TfrmPDFViewer.btnNextPageClick(Sender: TObject);
begin
  SetPageIndex(FPageIndex + 1);
end;

procedure TfrmPDFViewer.btnRotateLeftClick(Sender: TObject);
begin
  Dec(FRotation, 90);
  if FRotation = -360 then FRotation := 0;
  pbxPDF.Repaint;
end;

procedure TfrmPDFViewer.btnRotateRightClick(Sender: TObject);
begin
  Inc(FRotation, 90);
  if FRotation = 360 then FRotation := 0;
  pbxPDF.Repaint;
end;

Last but certainly not least, we need to implement an OnPaint handler for the paint box. The first problem is that the main API function to call (CGContextDrawPDFPage) works on an API-level CGContext object, not a FMX TCanvas. Nonetheless, similar to how a VCL TCanvas wraps an API-level HDC, so a FMX TCanvas on OS X wraps an API-level CGContextRef. Unfortunately, FMX, unlike the VCL, does not expose the native API handles it uses though. This means we need to do a bit of digging:

uses System.Rtti;

function GetCGContextFromCanvas(ACanvas: TCanvas): CGContextRef;
var
  Context: TRttiContext;
  Field: TRttiField;
begin
  Field := Context.GetType(ACanvas.ClassType).GetField('FContext');
  Assert(Field <> nil);
  Result := PPointer(Field.GetValue(ACanvas).GetReferenceToRawData)^;
end;

This uses RTTI to get the value of a private field. With this, we can finally implement the OnPaint handler:

procedure TfrmPDFViewer.pbxPDFPaint(Sender: TObject; Canvas: TCanvas);
var
  Context: CGContextRef;
  R: NSRect;
begin
  if FPageRef = nil then Exit;
  Context := GetCGContextFromCanvas(Canvas);
  if Context = nil then Exit;
  R.origin.x := 0; R.origin.y := 0;
  R.size.width := pbxPDF.Width;
  R.size.height := pbxPDF.Height;
  CGContextSaveGState(Context);
  try
    CGContextTranslateCTM(Context, 0, R.size.height);
    CGContextScaleCTM(Context, 1, -1);
    CGContextConcatCTM(Context, CGPDFPageGetDrawingTransform(FPageRef,
      kCGPDFCropBox, R, FRotation, Ord(True)));
    CGContextDrawPDFPage(Context, FPageRef);
  finally
    CGContextRestoreGState(Context);
  end;
end;

If you’ve dabbled in GDI programming on Windows, the CGContextSaveGState and CGContextRestoreGState routines are directly equivalent to SaveDC and RestoreDC – in each case, they perfectly save and restore the state of the canvas. The xxxCTM routines are then necessary to firstly translate the PDF coordinate system to the CGContext display one, secondly scale everything in view, and thirdly apply any rotation the user has asked for.

Finally, add OS X as a target platform for your project if you haven’t already, make sure PAServer is running on the host, and hit F9:

(The eagle-eyed may spot the page number in the title bar here does not correspond to the page number in the page footer. This is because the PDF is for a book that starts on page i, not page 1.)

You can browse the complete source for the example here; the Subversion URL, e.g. for using in the IDE via File|Open from Version Control…, is http://delphi-foundations.googlecode.com/svn/trunk/XE2 book/13. Native APIs/Mac specific/PDF viewer).

Advertisements

20 thoughts on “Creating a simple PDF viewer for OS X using FMX and the Core Graphics API

  1. There’s a typo or I don’t understand ?

    01 procedure TfrmPDFViewer.FreeDocRef;
    02 begin
    03 if FDocRef nil then Exit; // <=== if FDocRef = nil
    04 CGPDFDocumentRelease(FDocRef);
    05 FDocRef := nil;
    06 end;

  2. Hello Chris
    Thanks. Very cool demo.
    If this logic can be reimplemented for Windows also, it would be a great Firemonkey addon.

    • Well, it’s a demo of an using OS X API, so there would need to be some sort of equivalent on Windows, which doesn’t exist. The reason OS X has a PDF API is because the PDF format (or more exactly, an Apple subset of it) acts a bit like the WMF/EMF format on Windows – it’s the native graphics metafile format in other words.

  3. Hello Chris
    I am working on generating PDF with Firemonkey both for Windows and Mac side. CoreGraphics api has a PDFContext that can make a pdf file with any drawing on the context. Can it be possible to inherit a firemonkey TCanvas that directs all calls to mac pdfcontext? What do you think about it?

    • A basic problem is an old one – the FMX sources in XE2 at least are actively hostile to native interop. If this were a Windows API working with the VCL TCanvas, you could just assign the Handle property of latter, but not so with FMX.

      That said, having looked at the source, things might be doable by hijacking a form, assigning its ContextHandle property then instantiating a canvas specially for the task from the metaclass assigned to DefaultCanvasClass.

  4. Pingback: PDF-Viewer in Firemonkey - Delphi-PRAXiS

  5. Hello. It is possible to adapted your method to display WMF image in FM (load in TBitmap object)?

  6. Some hope / roadmap, in your very expert opinion, for solve it? I have to load and manage some images (from old VCL project) WMF and EMF in FM TBitmap object, but I don’t find any tool to “translate” it. My company is reasy to purchase this tool… Thank’you for every information.

    • What are you looking for exactly – an FMX TMetafile class, or merely a way to load a WMF or EMF file at a given size into a TBitmap? Also, are your requirements Windows only, or OS X and/or iOS as well?

  7. In our project, it is only necessary to LOAD WMF image (stored in a Hex string) in FMX.TBitmap for watch it. In WIN, if I insert HEX header with imagesize, Image is loaded in VCL.Metafile object. But in FM, obvously, method is not applicable.
    For now, our problem conser OSX, in future we schedule analysis for iOS.
    Any idea?
    Thank you very very much!

    • For Windows, you can use a VCL TMetafile to load, draw it to a VCL TBitmap, then copy the bitmap bits to a FMX TBitmap (there’s no problem linking Vcl.Graphics into a FMX application). Apple platforms are much harder, because by their very nature, WMF and EMF are Windows formats (a WMF file is composed of Win 3.x GDI commands, a EMF composed of Win32 GDI commands).

  8. Pingback: Cocosistemas.com. Diego Muñoz. Informático Freelance. » Blog Archive » Semana 16 a 22 diciembre 2013. Más Delphi, más Objective C…

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s