How to display PDF in a WPF app and stay responsive

1/30/2014 By Hans 0 comments

Rendering a PDF page may take long (>100 ms). In order to keep the UI responsive, it should not be performed on the UI thread.

BackgroundWorker

At first glance WPF seems to have a nice solution for this: The BackgroundWorker is the recommended way to run time-consuming tasks on a separate, dedicated thread, leaving the UI responsive. A background worker is created as follows:

1 BackgroundWorker worker = new BackgroundWorker(); 2 worker.DoWork += new DoWorkEventHandler(doWork); 3 worker.RunWorkerAsync();

Event handler doWork would do the actual rendering to WPF graphics. It turns out that it is not possible to create even a simple rectangle from the worker thread, let alone all possible elements of a PDF page. If we use the following doWork event handler:

1 void doWork(object sender, DoWorkEventArgs e) 2 { 3 Rectangle rectangle = new Rectangle();

An InvalidOperationException is thrown: "The calling thread must be STA, because many UI components require this." It is not possible to change the background worker thread to STA. It uses a threadpool thread and these are always MTA and cannot be changed.

STA Thread

So let's create our own background thread and set the apartment state to STA and render the PDF pages to WPF graphical element from this.

1 Thread thread = new Thread(new ThreadStart(doWork)); 2 thread.SetApartmentState(ApartmentState.STA); 3 thread.Start(); 4 5 private void doWork() 6 { 7 Rectangle rectangle = new Rectangle();

This code runs nicely. But now we need to display our rectangle in the UI.

Dispatcher

We need to pass the result to the UI thread. This is what the dispatcher is designed to do. Each thread in a WPF application has a dispatcher which queues a piece of code along with some data to the user interface thread. The user interface thread will schedule and run the code, which uses the data to update the screen.

1 private void doWork() 2 { 3 Rectangle rectangle = new Rectangle(); 4 5 this.Dispatcher.Invoke( (Action) delegate () 6 { 7 drawResult(rectangle); 8 }); 9 } 10 11 private void drawResult(Rectangle rectangle) 12 { 13 canvas1.Children.Add(rectangle); 14 }

Now we get the exception "The calling thread cannot access this object because a different thread owns it". What you see here is a protection against a typical multi-threading problem: one thread can change data at the same time that another thread is reading it, which may give unpredictable results.

The usual locking using semaphores could be useful here but apparently this is not the WPF way of working. Instead a "freeze" must be performed to make objects unchangeable. The user interface thread will know then that a graphical element is not going to change anymore and can draw it safely on the screen. Unfortunately, not all graphical objects are freezable. E.g. a Brush is, but a Rectangle isn't.

Take an XPS or XAML detour

We could take a detour: the background thread creates visual elements and converts these to XPS which the user interface thread can read. It would have been convenient to be able to write XPS to a memory stream but this is not possible; Microsoft insists that XPS is written to a file first.

A bit less awkward is to use XAML instead of XPS which can use memory stream. Here is the code:

1 public void StartTextAndRectangleDrawingThread() 2 { 3 var t = new Thread 4 (new ThreadStart(this.TextAndRectanglesCreatingThread)) 5 { 6 IsBackground = true 7 }; 8 t.SetApartmentState(ApartmentState.STA); 9 t.Start(); 10 } 11 12 private void TextAndRectanglesCreatingThread() 13 { 14 var canvas = new Canvas{ Width = 300, Height = 300 }; 15 var text = new TextBlock { Text = "Hello", FontSize = 32 }; 16 canvas.Children.Add(text); 17 Canvas.SetLeft(text, 100); 18 Canvas.SetTop(text, 100); 19 var brush = new SolidColorBrush(Color.FromRgb(200, 20, 50)); 20 var rectangle = new System.Windows.Shapes.Rectangle 21 { 22 Width = 40, 23 Height = 50, 24 Fill = brush, 25 }; 26 canvas.Children.Add(rectangle); 27 Canvas.SetLeft(rectangle, 50); 28 Canvas.SetTop(rectangle, 50); 29 30 var stream = new MemoryStream(); 31 System.Windows.Markup.XamlWriter.Save(canvas, stream); 32 Application.Current.Dispatcher.BeginInvoke( 33 DispatcherPriority.Normal, 34 (Action<MemoryStream>) 35 this.TextAndRectangleDrawingThreadHasData, 36 stream);} 37 38 void TextAndRectangleDrawingThreadHasData(MemoryStream stream) 39 { 40 stream.Seek(0, SeekOrigin.Begin); 41 var textAndRextangle = 42 (Canvas)System.Windows.Markup.XamlReader.Load(stream); 43 this.PdfLayer.Children.Add(textAndRextangle); 44 }

For code that draws some text and rectangles it works fine, however, converting a PDF first to WPF and then to XAML gives many problems which may be solvable, but ends up in a long and inefficient chain of conversions. Especially in the context of PDF documents with large amounts of complex content this is not the way to go.

Solution: Render to a bitmap in the background

The best solution that we found was to move as much as possible to the background thread, including the rendering to a bitmap for display on screen. We use RenderTargetBitmap for this purpose (which is "freezable").

The order of events is now:

1. The user interface thread starts a background thread with a file name and the dimensions of the bitmap.

2. This background thread opens a PDF document and:

  • parses its content
  • performs TALLcomponents -> ConvertToWpf
  • creates a transformation matrix for scaling the PDF page into the bitmap,
  • render it into a bitmap using WPF -> RenderTargetBitmap -> Render,
  • Freeze the bitmap.

3. The background thread dispatches a piece of code and the frozen bitmap to the user interface thread.

4. This code is scheduled in the user interface thread and it draws the bitmap on a canvas.

A simple sample app is made that renders a PDF document while user interface handles the drawing on a ink canvas, which works smoothly whatever the size and complexity of the PDF document.

PDF-display-in-wpf-app.PNG

The code for the background thread is:

1 // Create bitmap 2 private void OpenPdf() 3 { 4 var openFileDialog = new Microsoft.Win32.OpenFileDialog 5 { 6 DefaultExt = ".pdf", 7 Filter = "PDF files (*.pdf)|*.pdf|All files (*.*)|*.*" 8 }; 9 bool? fileOpenResult = openFileDialog.ShowDialog(); 10 if (fileOpenResult == true) 11 { 12 _threadThatRendersThePdf = new Thread(() => RenderPdfDocument( 13 pdfFileName: openFileDialog.FileName, 14 imageWidth: this.CanvasWidth, 15 imageHeight: this.CanvasHeight)); 16 _threadThatRendersThePdf.SetApartmentState(ApartmentState.STA); 17 _threadThatRendersThePdf.Start(); 18 } 19 } 20 21 void RenderPdfDocument(string pdfFileName, 22 double imageWidth, 23 double imageHeight) 24 { 25 const double WpfDpi = 96.00; // WPF measures in 96 DPI 26 const double PdfDpi = 72.00; // PDF measures in 72 DPI 27 using (var pdfStream = new FileStream(pdfFileName, 28 FileMode.Open, 29 FileAccess.Read)) 30 { 31 var pdfDocument = new TallComponents.PDF.Rasterizer.Document(pdfStream); 32 var pdfPage = pdfDocument.Pages[0]; 33 34 // Get the scale to let it fit into the image size, 35 // WPF measures things in 96th of an inch, not in pixels 36 // PDF width and height are measured in points 37 double scaleX = (imageWidth / WpfDpi) / (pdfPage.Width / PdfDpi); 38 double scaleY = (imageHeight / WpfDpi) / (pdfPage.Height / PdfDpi); 39 double scale = Math.Min(scaleX, scaleY); 40 41 // Resize the bitmap area so that is has the same width/height ratio 42 // as the PDF page, this possibly makes either the width or the height 43 // of the bitmap smaller. 44 // The rendered PDF will fit exactly in the image this way. 45 double ratioPdf = pdfPage.Height / pdfPage.Width; 46 double ratioImage = imageHeight / imageWidth; 47 double bitmapWidth = ratioPdf > ratioImage 48 ? imageHeight / ratioPdf 49 : imageWidth; 50 double bitmapHeight = ratioPdf > ratioImage 51 ? imageHeight 52 : imageHeight * ratioPdf; 53 54 // render the page to WPF into the resized image 55 var renderSettings = new RenderSettings(); 56 var convertToWpfOptions = new ConvertToWpfOptions(); 57 var summary = new TallComponents.PDF.Rasterizer.Diagnostics.Summary(); 58 convertToWpfOptions.ConvertToImages = false; 59 var wpfPage = pdfPage.ConvertToWpf(renderSettings, 60 convertToWpfOptions, 61 summary); 62 63 // make a bitmap renderer for the WPF page (but dont render it yet) 64 // and wrap a container around the wpfPage to that we can scale it 65 // to dimensions of the bitmap then let WPF render it and finally 66 // freeze it so it can be passed to the UI thread. 67 var wpfPageAsBitmap = 68 new System.Windows.Media.Imaging.RenderTargetBitmap( 69 pixelWidth: (int)bitmapWidth, 70 pixelHeight: (int)bitmapHeight, 71 dpiX: WpfDpi, 72 dpiY: WpfDpi, 73 pixelFormat: System.Windows.Media.PixelFormats.Default); 74 var container = new System.Windows.Media.ContainerVisual 75 { Transform = new ScaleTransform(scale, scale) }; 76 container.Children.Add(wpfPage); 77 wpfPageAsBitmap.Render(container); 78 wpfPageAsBitmap.Freeze(); 79 80 // Transport the frozen bitmap of the page to the UI Tread 81 BitmapThreadDelegate delegateToDrawInUiThread = BitmapUIThreadTask; 82 Application.Current.Dispatcher.BeginInvoke( 83 DispatcherPriority.Background, 84 delegateToDrawInUiThread, 85 wpfPageAsBitmap); 86 } 87 } 88 89 public void BitmapUIThreadTask( 90 System.Windows.Media.Imaging.RenderTargetBitmap bitmap) 91 { 92 var image = new Image() { Source = bitmap, Stretch = Stretch.None }; 93 this.PdfLayer.Children.Add(image); 94 } 95 96 private delegate void BitmapThreadDelegate( 97 System.Windows.Media.Imaging.RenderTargetBitmap bitmap); 98 99 private Thread _threadThatRendersThePdf;

The canvas that contains the PDF may be placed below a a ink panel so that a user can draw on top of a PDF page:

1 <inkcanvas 2 height="350" 3 horizontalalignment="Left" 4 margin="10,10,0,0" 5 name="ViewboxInkAndPDF" 6 strokes="{Binding Path=InkStrokes}" 7 verticalalignment="Top" width="300"> 8 <canvas> 9 <contentpresenter content="{Binding PdfLayer}"> 10 </contentpresenter></canvas> 11 </inkcanvas>