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
:
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:
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:
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:
StartPage | First page to render |
EndPage | Last page to render |
DpiX | DPI to render at |
DpiY | DPI 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:
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");
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:
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");
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:
PrintDpiX | DPI to render at |
PrintDpiY | DPI 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:
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:
void Print() {
var ps = printDialog1.PrinterSettings;
var sb = new StringBuilder(1024);
var xr = XmlWriter.Create(sb);
xr.WriteStartElement("DeviceInfo");
xr.WriteElementString("OutputFormat", "EMF");
_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:
_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:
xr.Close();
_PrintingStreams = new List<stream>(_PrintingPageCount);
Warning[] warnings;
TheReport.LocalReport.Render("Image",
sb.ToString(), lr_CreateStream, out warnings);
foreach (var s in _PrintingStreams) s.Position = 0;
var pd = new PrintDocument();
pd.PrinterSettings = ps;
pd.PrintPage += pd_PrintPage;
pd.EndPrint += pd_EndPrint;
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:
Stream lr_CreateStream(string name, string extension,
Encoding encoding, string mimeType, bool willSeek)
{
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:
void pd_PrintPage(object sender, PrintPageEventArgs e)
{
var mf = new Metafile(_PrintingStreams[_PrintingIndex]);
var mfh = mf.GetMetafileHeader();
e.Graphics.ScaleTransform(mfh.DpiX / _PrintingDPI.X, mfh.DpiY /
_PrintingDPI.Y, MatrixOrder.Prepend);
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:
void pd_EndPrint(object sender, PrintEventArgs e)
{
foreach (var s in _PrintingStreams) s.Dispose();
_PrintingStreams.Clear();
_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:
void pd_PrintPage(object sender, PrintPageEventArgs e)
{
var mf = new Metafile(_PrintingStreams[_PrintingIndex]);
var mfh = mf.GetMetafileHeader();
e.Graphics.ScaleTransform(mfh.DpiX / _PrintingDPI.X,
mfh.DpiY / _PrintingDPI.Y, MatrixOrder.Prepend);
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:
void pd_PrintPage(object sender, PrintPageEventArgs e)
{
var mf = new Metafile(_PrintingStreams[_PrintingIndex]);
var mfh = mf.GetMetafileHeader();
e.Graphics.ScaleTransform(mfh.DpiX / _PrintingDPI.X,
mfh.DpiY / _PrintingDPI.Y, MatrixOrder.Prepend);
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);
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:
bool CanPrint()
{
if (SystemInformation.TerminalServerSession)
{
return false;
}
var dim = GetPhysicalScreenDimensions();
return !(dim.Width == 320 && dim.Height == 240);
}
It relies on the age old GetDeviceCaps
routine:
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:
protected Graphics m_graphicsBase;
protected Bitmap m_imageBase;
private RectangleF MetafileRectangle;
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.PageUnit = GraphicsUnit.Millimeter;
}
private void CalculateMetafileRectangle(float pageWidth, float pageHeight)
{
IntPtr hdc = this.m_graphicsBase.GetHdc();
int deviceCaps;
int num2;
int num3;
int num4;
try
{
deviceCaps = GetDeviceCaps(hdc, 4);
num2 = GetDeviceCaps(hdc, 6);
num3 = GetDeviceCaps(hdc, 8);
num4 = GetDeviceCaps(hdc, 10);
}
finally
{
this.m_graphicsBase.ReleaseHdc();
}
double num5 = ConvertToPixels(pageWidth,
(float)this.m_graphicsBase.DpiX);
double num6 = ConvertToPixels(pageHeight,
(float)this.m_graphicsBase.DpiY);
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);
}
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:
num5 = PixelTargetWidthAtPrintDpiX = (PrintDpiX/(2.54cm * 10mm) * mmTargetWidth);
width = mmTargetWidthAtDeviceDpiX =
(PixelTargetWidthAtPrintDpiX/PixelHorizontalWidthOfDevice) *
mmHorizontalWidthOfDevice
and in the printing event, the scaling transform applied results in:
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.