Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WinForms

High fidelity printing through the SSRS Report Viewer control

4.78/5 (16 votes)
19 May 2011CPOL11 min read 100.1K  
Discusses the limitations and undocumented high DPI printing capabilities of the local WinForms Report Viewer control.

Introduction

Since SQL Server 2005, a fully featured WinForms control has been available for incorporation into any project to provide excellent report functionality, including exporting to PDFs and different levels of printing support. This article discusses how to take advantage of the newer SQL Server 2008 control to provide precise high resolution printing.

Background

The basic design of the Report Viewer WinForms control consists of an interactive rendering surface and a series of renderers that can be called upon to export into PDF, XLS, and (recently added in 2008 R2 for local use) DOC formats.

There is also an Image Renderer that can be configured to generate TIFF, JPG, PNG, BMP, and a special format called EMF.

EMF is a vector-like format, dating back to the first versions of Windows, that consists of a series of recorded graphics commands that, when played back, can recreate the graphics at a different resolution without losing fidelity. It is also the primary way that Windows printing worked until Windows Vista/7, which uses XPS.

In order to create graphics, a real device context must be associated with it. For example, custom drawing onto a normal Form is done within the Paint event, which supplies a Graphics object that has the screen as the device context. Even creating a Bitmap and then drawing onto it will implicitly have the screen as the device context.

The complexities associated with printing a report from the control are down to the myriad of resolutions and DPI differences associated with the Image Renderer recording EMF as it draws the report onto a hidden Bitmap (screen device context) and then trying to correctly replay the EMF directly to the Graphics provided by the Print event (print device context).

Resolution and DPI

For this discussion, I will use "resolution" to describe the dimensions of a given bitmap or display mode in pixels. For example, I'm typing this on a Windows XP machine with two 19" LCDs setup identically. Each LCD is setup with a resolution of 1280x1024 pixels, and my desktop spans both (so 2360x1024).

DPI is "Dots per Inch" and, for my setup, I have my desktop set to use a DPI of 120 instead of the default of 96. However, this is the logical DPI that is used throughout Windows; the physical LCD will have its own implied DPI based on the physical dimensions of the flat panel itself.

For example, although my LCDs are described as 19" (48.26cm), the actual flat panel size is 37.5cm x 30cm, giving a physical aspect ratio (width/height) of 1.25 or 5:4. My current resolution of 1280x1024 implies the exact same ratio and, given this resolution is the native one of this monitor, it means that the physical LCD pixels are square and the vertical and horizontal DPI are the same: 1024pixels / (30cm/2.54cm) = 1280pixels / (37.5cm/2.54cm) = 86.7 DPI.

This kind of information is important for CAD or publishing software which need to draw a line of precisely 1cm on the monitor and on the printer - i.e., a user with a physical ruler will measure precisely 1cm for that line both on the screen and on the paper.

Older monitors did not report this information to Windows; moreover, the increasing use of Terminal Services sessions (e.g., using Remote Desktop to work from home on your machine at work) means more and more programs will encounter this lack of physical information, with often unexpected results.

General Exporting

The simplest exporting that the Report Viewer control supports is to non-image formats like PDF.

Assuming that TheReport is the name of the ReportViewer instance and it has a report already displayed within it, then the following routine will export to the given fileName the requested type:

C#
void ExportReport(string type, string xml, string fileName)
{
    string mimeType, encoding, fileNameExtension;
    Warning[] warnings;
    string[] streams;
    var res = TheReport.LocalReport.Render(type, xml,
                                            out mimeType, out encoding,
                                            out fileNameExtension, out streams, out warnings);
    //
    using (var f = new FileStream(fileName, FileMode.OpenOrCreate,
                       FileAccess.Write, FileShare.None))
    {
        f.Write(res, 0, res.Length);
    }
}

type is one of the available renderers, which can be obtained by calling:

C#
TheReport.LocalReport.ListRenderingExtensions();

In local mode, the results for 2008 R2 are PDF, Image, Excel, and Word. The original 2008 (and 2005) versions restricted Word output to server mode only.

The xml argument is referred to as the DeviceInfo string, and provides optional name/value pairs to customise the output depending on the chosen renderer.

Example

The following will render to PDF using the default settings:

C#
ExportReport("PDF", "<DeviceInfo/>", "c:\temp\myfile.pdf");

The DeviceInfo String

This string is specific to the chosen renderer; the official list of possible settings are available in MSDN at: http://msdn.microsoft.com/EN-US/library/FE718939-7EFE-4C7F-87CB-5F5B09CAEFF4.aspx.

The most important values that apply to all these renderers are:

StartPageFirst page to render
EndPageLast page to render
DpiXDPI to render at
DpiYDPI to render at

Other common values allow the page size and margins specified within the report itself to be overridden.

Example

The following will render to PDF only page 2, at quite a low DPI of 72, perhaps for preview purposes:

C#
var sb = new StringBuilder(1024);
var xr = XmlWriter.Create(sb);
xr.WriteStartElement("DeviceInfo");
xr.WriteElementString("StartPage", "2");
xr.WriteElementString("EndPage", "2");
xr.WriteElementString("DpiX", "72");
xr.WriteElementString("DpiY", "72");
//Add other options here
xr.Close();
ExportReport("PDF", sb.ToString(), fileName);

The Image Renderer

This renderer is the one responsible for producing the output for printing, so it deserves special mention.

The key DeviceInfo option used is OutputFormat, which can be set to a lossless (exact) type (BMP, PNG, TIFF), or to a more inexact type but with better compression (GIF, JPEG). It also supports the EMF option that is used to produce scalable vector-like output, typically used for printing.

Example

The following will produce a PNG file at 120 DPI to match my desktop settings:

C#
var sb = new StringBuilder(1024);
var xr = XmlWriter.Create(sb);
xr.WriteStartElement("DeviceInfo");
xr.WriteElementString("OutputFormat", "PNG");
xr.WriteElementString("DpiX", "120");
xr.WriteElementString("DpiY", "120");
//Add other options here
xr.Close();
ExportReport("IMAGE", sb.ToString(), fileName);

Producing and Collecting EMF

We are going to need EMF in order to print rapidly and with high fidelity, but the Image Renderer uses a very different method when this OutputFormat is chosen: it is page based, so we will need to provide a callback and a means to store each page individually for later use.

The following undocumented options need to be used in place of the DpiX and DpiY ones:

PrintDpiXDPI to render at
PrintDpiYDPI to render at

Note carefully that this is not actually related to printing at all. Just like the DpiX/Y values for the other outputs, PrintDpiX/Y simply control the DPI of the Bitmap used to render the report onto while the recording of the EMF takes place.

Firstly, define a few variables for the callback system to operate on:

C#
private Point _PrintingDPI;
private int _PrintingIndex, _PrintingPageCount;
private List<Stream> _PrintingStreams; 

The routine itself needs a PrinterSettings instance in order to detect the physical characteristics of the printer:

C#
void Print() {
    //Assume that the user has been shown the dialog and chosen a printer
    //otherwise, these can be obtained programmatically given a known
    //printer name.
    var ps = printDialog1.PrinterSettings;
    //
    var sb = new StringBuilder(1024);
    var xr = XmlWriter.Create(sb);
    xr.WriteStartElement("DeviceInfo");
    xr.WriteElementString("OutputFormat", "EMF");
    //This is only an estimate
    _PrintingPageCount = TheReport.LocalReport.GetTotalPages();

Given that printing over 300 DPI on normal laser printers rarely makes any difference to the quality of the output, and that users may want to see a draft of a sample page before committing, it would be wise to allow a choice of resolution rather than just taking the printer one.

Notice how the undocumented PrintDpiX/Y options are specified, and that the chosen DPI is stored in _PrintingDPI for later use:

C#
//Ensure EMF is recorded on a bitmap with the same resolution as the printer
_PrintingDPI.X = ps.DefaultPageSettings.PrinterResolution.X;
_PrintingDPI.Y = ps.DefaultPageSettings.PrinterResolution.Y;
xr.WriteElementString("PrintDpiX", _PrintingDPI.X.ToString());
xr.WriteElementString("PrintDpiY", _PrintingDPI.Y.ToString());

The rest is standard WinForms printing via a PrintDocument. The only things to be aware of are the use of the callback version of the Render() method, passing in the callback lr_CreateStream, and the need to reset the stream reading positions to zero afterwards:

C#
    xr.Close();
    //Estimate list capacity
    _PrintingStreams = new List<stream>(_PrintingPageCount);
    //Render using the callback API
    Warning[] warnings;
    TheReport.LocalReport.Render("Image",
              sb.ToString(), lr_CreateStream, out warnings);
    //Reset all streams to the beginning
    foreach (var s in _PrintingStreams) s.Position = 0;
    //And print the document as normal
    var pd = new PrintDocument();
    pd.PrinterSettings = ps;
    pd.PrintPage += pd_PrintPage;
    pd.EndPrint += pd_EndPrint;
    //Synchronous
    pd.Print();
}

The callback itself is responsible for providing the Stream for the Report Viewer to store the EMF output into and keeping a copy of it in page order. In this simple example, an in-memory stream is being used:

C#
Stream lr_CreateStream(string name, string extension,
                       Encoding encoding, string mimeType, bool willSeek)
{
    //FileStreams could be used here to relieve memory pressure for big print jobs
    var stream = new MemoryStream();
    _PrintingStreams.Add(stream);
    return stream;
}

Printing the EMF

So far the EMF has been rendered on a Bitmap with the desired DPI using the PrintDpiX/Y options. This implicitly uses the physical screen as the device context, meaning the EMF will be showing the underlying physical DPI of the physical screen even though the unit system in use is the original DPI from the PrintDpiX/Y options.

Because of this, Metafile.GetMetafileHeader().DpiX will actually be equal to the physical DPI of the monitor (e.g., 86.7).

Matching the Source Exactly

The following assumes the report has the exact same page dimensions as the target printer paper, as well as margins that are bigger than the printer minimum:

C#
void pd_PrintPage(object sender, PrintPageEventArgs e)
{
    //1. Avoid unintended rounding issues
    //   by specifying units directly as 100ths of a mm (GDI)
    //   This can be done by reading directly as a stream with no HDC
    var mf = new Metafile(_PrintingStreams[_PrintingIndex]);

    //2. Apply scaling to correct for dpi differences
    //   E.g. the mf.Width needs the PrintDpiX
    // in order to be translated to a real width (in mm)
    var mfh = mf.GetMetafileHeader();
    e.Graphics.ScaleTransform(mfh.DpiX / _PrintingDPI.X, mfh.DpiY /
                              _PrintingDPI.Y, MatrixOrder.Prepend);

    //3. Draw the image assuming margins are sufficient
    //
    e.Graphics.DrawImageUnscaled(mf, new Point(0,0));

    _PrintingIndex++;
    e.HasMorePages = (_PrintingIndex < _PrintingStreams.Count);
}

Now that printing is complete, clean up the streams and reset the variables for next time:

C#
void pd_EndPrint(object sender, PrintEventArgs e)
{
    //Cleanup: may get called multiple times
    foreach (var s in _PrintingStreams) s.Dispose();
    _PrintingStreams.Clear();
    //And reset for next time
    _PrintingIndex = 0;
}

Using Printer Margins

The following shows how to position the top left of the report on the printed page. In this case, it is blindly using whatever margins PageSettings contains:

C#
void pd_PrintPage(object sender, PrintPageEventArgs e)
{
    //1. Avoid unintended rounding issues
    //   by specifying units directly as 100ths of a mm (GDI)
    //   This can be done by reading directly as a stream with no HDC
    var mf = new Metafile(_PrintingStreams[_PrintingIndex]);

    //2. Apply scaling to correct for dpi differences
    //   E.g. the mf.Width needs the PrintDpiX
    //   in order to be translated to a real width (in mm)
    var mfh = mf.GetMetafileHeader();
    e.Graphics.ScaleTransform(mfh.DpiX / _PrintingDPI.X,
            mfh.DpiY / _PrintingDPI.Y, MatrixOrder.Prepend);

    //3. Draw the image at the adjusted coordinates
    //   i.e. these are real coordinates so need to reverse the transform that is about
    //        to be applied so that when it is, these real coordinates will be the result.
    var points = new[] { new Point(e.PageSettings.Margins.Left, e.PageSettings.Margins.Top) };
    var matrix = e.Graphics.Transform;
    matrix.Invert();
    matrix.TransformPoints(points);
    //
    e.Graphics.DrawImageUnscaled(mf, points[0]);

    _PrintingIndex++;
    e.HasMorePages = (_PrintingIndex < _PrintingStreams.Count);
}

Scaling to Fit the Page

The final example shows how to apply a second transform to replicate the Fit to Page option common in Word Processing applications:

C#
void pd_PrintPage(object sender, PrintPageEventArgs e)
{
    //1. Avoid unintended rounding issues
    //   by specifying units directly as 100ths of a mm (GDI)
    //   This can be done by reading directly as a stream with no HDC
    var mf = new Metafile(_PrintingStreams[_PrintingIndex]);

    //2. Apply scaling to correct for dpi differences
    //   E.g. the mf.Width needs the PrintDpiX in order
    //        to be translated to a real width (in mm)
    var mfh = mf.GetMetafileHeader();
    e.Graphics.ScaleTransform(mfh.DpiX / _PrintingDPI.X,
               mfh.DpiY / _PrintingDPI.Y, MatrixOrder.Prepend);

    //3. Apply scaling to fit to current page by shortest difference
    //   by comparing real page size with available printable area in 100ths of an inch
    var size = new PointF((float)mf.Size.Width / _PrintingDPI.X * 100.0f,
                          (float)mf.Size.Height / _PrintingDPI.Y * 100.0f);
    size.X = (float)Math.Floor(e.PageSettings.PrintableArea.Width) / size.X;
    size.Y = (float)Math.Floor(e.PageSettings.PrintableArea.Height) / size.Y;
    var scale = Math.Min(size.X, size.Y);
    e.Graphics.ScaleTransform(scale, scale, MatrixOrder.Append);

    //4. Draw the image at the adjusted coordinates
    //   i.e. these are real coordinates so need to reverse the transform that is about
    //        to be applied so that when it is, these real coordinates will be the result.
    var points = new[] { new Point(e.PageSettings.Margins.Left, e.PageSettings.Margins.Top) };
    var matrix = e.Graphics.Transform;
    matrix.Invert();
    matrix.TransformPoints(points);
    //
    e.Graphics.DrawImageUnscaled(mf, points[0]);

    _PrintingIndex++;
    e.HasMorePages = (_PrintingIndex < _PrintingStreams.Count);
}

Be warned that even though the VS2010 documentation for e.PageSettings.PrintableArea says that it is sensitive to the e.PageSettings.Landscape setting, I have noticed this appears not to be the case, at least in .NET 2.0 SP2 (part of .NET 3.5 SP1).

This means that PrintableArea.Width and PrintableArea.Height may need to be switched in the above code if Landscape is detected.

When Printing is not Possible

The problem is that Windows needs to have the monitor report its physical size accurately; otherwise, the results will be totally skewed. For example, if I remote desktop between machines and I use mstsc /span, then the remote session where my code is running not only cannot determine the physical size of the remote monitor but sees the resolution as one big span.

Because the inbuilt Print Preview and Print mode of the ReportViewer control uses the standard printing methods just like those used here, you will clearly see overlapping fonts, missing lines, and other artifacts.

Detection

The following routine relies on the fact that Windows will default to a large old 4:3 monitor 320mm x 240mm in physical display area whenever the physical characteristics of the display cannot be determined:

C#
bool CanPrint()
{
    if (SystemInformation.TerminalServerSession)
    {
        return false;
    }
    var dim = GetPhysicalScreenDimensions();
    //Windows will return these hard-coded defaults if the monitor
    //does not support reporting its physical dimensions
    return !(dim.Width == 320 && dim.Height == 240);
}

It relies on the age old GetDeviceCaps routine:

C#
private static Size GetPhysicalScreenDimensions()
{
    Size res = new Size();
    using (Bitmap bitmap = new Bitmap(2, 2))
    {
        using (Graphics graphics = Graphics.FromImage(bitmap))
        {
            IntPtr hdc = graphics.GetHdc();
            res.Width = GetDeviceCaps(hdc, 88);
            res.Height = GetDeviceCaps(hdc, 90);
            graphics.ReleaseHdc(hdc);
        }
    }
    return res;
}

[DllImport("gdi32.dll", SetLastError = true)]
private static extern int GetDeviceCaps(IntPtr hdc, int nIndex);

What About Server Mode?

The same assemblies are used on the SSRS server itself to handle printing. When I was investigating this issue, I found numerous reports of the same corrupt printing when using the server.

The problem appears to be the same, but it is unclear to me what desktop resolution will be picked up by the NT Service(s) hosting the ReportViewer assemblies.

For example, create a local account and login with it, setting the resolution to match the primary monitor attached (or 1024x768 as per below), then log out and setup the NT Service that hosts SSRS to login with that account. Will all EMF rendering requests (or Print Layout displays) now use that resolution?

The implication would be that then changing the primary monitor from a traditional 4:3 aspect ratio to a 16:9 widescreen LCD would cause the problem again until the local account's resolution was set to a 16:9 one.

Suggested Workaround

As long as the desktop resolution chosen (either directly or via Remote Desktop) is of the same ratio as the default returned by GetDeviceCaps, then printing will in fact work.

For example, given 320x240 is a ratio of 4:3, then a resolution of 1024x768 would work perfectly well. However, not only is this low and hard to work with, but the next highest candidate is 2048x1536, and there are very few monitors out there that support that.

On the desktop, I would suggest using the CanPrint routine outlined earlier, and if it returns false, just export to PDF to print that way since 4:3 monitors are quite rare these days.

However, the desktop resolution method is fine on the server if it can be made to "stick".

Details of a Workaround

Recently a user added a comment to the MSDN bug reference which confirmed how Terminal Services (TS) relates to the SSRS service:

Posted by Matt Dodds on 07/10/2010 at 05:01: "We just hit upon this with our ReportViewer operating in LocalReport mode from a web app on one of our servers. The theory about console resolution vs TS resolution is accurate - in our case, someone had "taken over" the console session from a Remote Desktop Session using Terminal Services Manager. The user was operating their screen native at 1400 x 900 (ratio 1.6) as opposed to the standard console resolution of 1024 x 768 (ratio 1.333). The renderer therefore erroneously applies some scaling and all your prints look too wide.

Ultimate solution: log on to the console, at the console and double-check your screen resolution. This seems to put everything back into place."

Points of Interest

The key to understanding the relationship between the Image Renderer in EMF mode and the monitor it is on is the use of .NET Reflector. This was also the method used to find the undocumented PrintDpiX and PrintDpiY parameters and how they related to the DpiX and DpiY ones. For those interested, the key ReportViewer routine that calculates the Metafile bounding rectangle and thus determines the accuracy of the output is recreated here:

C#
protected Graphics m_graphicsBase;
protected Bitmap m_imageBase;
private RectangleF MetafileRectangle;
//PrintDpiX and PrintDpiY map to these arguments: e.g. (300, 300)
private void GraphicsBase(float dpiX, float dpiY)
{
    this.m_imageBase = new Bitmap(2, 2);
    this.m_imageBase.SetResolution(dpiX, dpiY);
    this.m_graphicsBase = Graphics.FromImage(this.m_imageBase);
    //this.m_graphicsBase.CompositingMode = CompositingMode.SourceOver;
    this.m_graphicsBase.PageUnit = GraphicsUnit.Millimeter;
    //this.m_graphicsBase.PixelOffsetMode = PixelOffsetMode.Default;
    //this.m_graphicsBase.SmoothingMode = SmoothingMode.Default;
    //this.m_graphicsBase.TextRenderingHint = TextRenderingHint.SystemDefault;
}

//The arguments are the Report.PageWidth and Report.PageHeight expressed
//in 100ths of a mm (GDI units)
private void CalculateMetafileRectangle(float pageWidth, float pageHeight)
{
    //if (this.IsEmf)
    //{
    IntPtr hdc = this.m_graphicsBase.GetHdc();
    //NOTE: The values in the comments reflect a remoting scenario
    //      where RDP Desktop was set to span two monitors to
    //      give a single resolution of 2560x1024 and Windows
    //      returned the default 320x240 dpi
    int deviceCaps;//320 / 2 = 160      ==>  4:3
    int num2;//240 / 2 = 120
    int num3;//2560                     ==>  2.5 = 5:2
    int num4;//1024
    try
    {
        deviceCaps = GetDeviceCaps(hdc, 4);/* Horizontal size in millimeters  */
        num2 = GetDeviceCaps(hdc, 6);/* Vertical size in millimeters          */
        num3 = GetDeviceCaps(hdc, 8);/* Horizontal width in pixels            */
        num4 = GetDeviceCaps(hdc, 10);/* Vertical height in pixels            */
    }
    finally
    {
        this.m_graphicsBase.ReleaseHdc();
    }
    double num5 = ConvertToPixels(pageWidth,
      (float)this.m_graphicsBase.DpiX);//991 pixels for 209.8mm
    double num6 = ConvertToPixels(pageHeight,
      (float)this.m_graphicsBase.DpiY);//1318 pixels for 278.9mm
    //
    float width = ((float)((num5 * deviceCaps) * 100.0)) / ((float)num3);
    float height = ((float)((num6 * num2) * 100.0)) / ((float)num4);
    this.MetafileRectangle = new RectangleF(0f, 0f, width, height);
    //128.9mm, 308.9mm
    //}
}

internal static int ConvertToPixels(float mm, float dpi)
{
    return Convert.ToInt32((double)((dpi * 0.03937007874) * mm));
}

A lot of rectangles were drawn on paper when working out what was going on there, but I will avoid boring everyone with all the gory details other than this brief dissection of the width calculation:

C#
num5 = PixelTargetWidthAtPrintDpiX = (PrintDpiX/(2.54cm * 10mm) * mmTargetWidth);
width = mmTargetWidthAtDeviceDpiX =
  (PixelTargetWidthAtPrintDpiX/PixelHorizontalWidthOfDevice) *
   mmHorizontalWidthOfDevice

and in the printing event, the scaling transform applied results in:

C#
width * DeviceDpiX / PrintDpiX = mmTargetWidth

because all other terms cancel out (see definition of DPI earlier on), thereby restoring the original value.

References

The following links helped with the creation of this article:

History

  • 1.0 - Initial write up.
  • 1.1 - Added warning about PrintableArea and Landscape mode, and expanded Points of Interest.
  • 1.2 - Updated server mode workaround.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)