65.9K
CodeProject 正在变化。 阅读更多。
Home

通过 SSRS Report Viewer 控件实现高保真打印

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.78/5 (15投票s)

2010年7月22日

CPOL

11分钟阅读

viewsIcon

101507

讨论了本地 WinForms Report Viewer 控件在 DPI 较高的打印时的限制和未公开的功能。

引言

自 SQL Server 2005 起,已提供功能齐全的 WinForms 控件,可集成到任何项目中,提供出色的报表功能,包括导出为 PDF 以及不同级别的打印支持。本文讨论了如何利用较新的 SQL Server 2008 控件提供精确的高分辨率打印。

背景

Report Viewer WinForms 控件的基本设计包括一个交互式呈现表面和一系列渲染器,这些渲染器可以被调用以导出为 PDF、XLS 以及(在 2008 R2 中为本地使用新增的)DOC 格式。

还有一个 Image Renderer,可以配置为生成 TIFF、JPG、PNG、BMP 以及一种称为 EMF 的特殊格式。

EMF 是一种矢量类格式,源于 Windows 的早期版本,它包含一系列图形命令,在回放时可以以不同的分辨率重现图形,而不会损失保真度。直到 Windows Vista/7 使用 XPS 之前,这也是 Windows 打印工作的主要方式。

为了创建图形,必须将其与实际的设备上下文关联。例如,在正常的 Form 上进行自定义绘图是在 Paint 事件中完成的,该事件提供了一个 Graphics 对象,其设备上下文是屏幕。即使创建 Bitmap 然后在其上绘图,隐式地也会将屏幕作为设备上下文。

打印控件中的报表所涉及的复杂性在于 Image Renderer 在绘制报表到隐藏的 Bitmap(屏幕设备上下文)时记录 EMF 所需的各种分辨率和 DPI 差异,然后尝试将其正确地回放到 Print 事件提供的 Graphics(打印设备上下文)中。

分辨率和 DPI

在本讨论中,我将使用“分辨率”来描述给定位图或显示模式的像素尺寸。例如,我正在一台 Windows XP 机器上输入此内容,该机器有两个相同的 19" LCD 显示器。每个 LCD 都设置为 1280x1024 像素的分辨率,我的桌面跨越这两个显示器(因此是 2360x1024)。

DPI 是“每英寸点数”,对于我的设置,我将桌面设置为使用 120 的 DPI 而不是默认的 96。但是,这是 Windows 中使用的逻辑 DPI;物理 LCD 会根据显示面板本身的物理尺寸具有其自己的隐含 DPI。

例如,虽然我的 LCD 被描述为 19"(48.26cm),但实际的显示面板尺寸为 37.5cm x 30cm,给出物理纵横比(宽/高)为 1.25 或 5:4。我当前 1280x1024 的分辨率暗示了完全相同的比例,并且考虑到此分辨率是此显示器的原生分辨率,这意味着物理 LCD 像素是正方形,垂直和水平 DPI 相同:1024像素 / (30cm/2.54cm) = 1280像素 / (37.5cm/2.54cm) = 86.7 DPI。

这种信息对于 CAD 或出版软件至关重要,它们需要精确地在监视器和打印机上绘制一条 1 厘米的线条——也就是说,用户用物理尺子测量时,屏幕和纸张上的这条线的长度都正好是 1 厘米。

较旧的显示器不会向 Windows 报告此信息;此外,日益增长的终端服务会话(例如,使用远程桌面在家中工作)意味着越来越多的程序会遇到这种物理信息缺失的问题,并常常产生意外的结果。

通用导出

Report Viewer 控件支持的最简单的导出是非图像格式,如 PDF。

假设 TheReportReportViewer 实例的名称,并且其中已显示报表,则以下例程将以指定的 type 将报表导出到给定的 fileName

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 是可用渲染器之一,可以通过调用以下方法获取

TheReport.LocalReport.ListRenderingExtensions();

在本地模式下,2008 R2 的结果为 PDF、Image、Excel 和 Word。原始的 2008(和 2005)版本将 Word 输出限制为仅服务器模式。

xml 参数被称为 DeviceInfo 字符串,并提供可选的名称/值对,用于根据所选渲染器自定义输出。

示例

以下内容将使用默认设置呈现为 PDF

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

DeviceInfo 字符串

此字符串特定于所选渲染器;可能的设置的官方列表可在 MSDN 上找到:http://msdn.microsoft.com/EN-US/library/FE718939-7EFE-4C7F-87CB-5F5B09CAEFF4.aspx

适用于所有这些渲染器的最重要的值是

StartPage 要呈现的第一页
EndPage 要呈现的最后一页
DpiX 要渲染的 DPI
DpiY 要渲染的 DPI

其他常用值允许覆盖报表本身中指定的页面大小和边距。

示例

以下内容将仅呈现第 2 页为 PDF,DPI 较低,为 72,可能用于预览目的

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);

Image Renderer

此渲染器负责为打印生成输出,因此值得特别提及。

使用的关键 DeviceInfo 选项是 OutputFormat,它可以设置为无损(精确)类型(BMP、PNG、TIFF),或具有更好压缩的更不精确类型(GIF、JPEG)。它还支持 EMF 选项,该选项用于生成可缩放的矢量类输出,通常用于打印。

示例

以下内容将以 120 DPI 生成 PNG 文件,以匹配我的桌面设置

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);

生成和收集 EMF

我们需要 EMF 才能实现快速、高保真的打印,但 Image Renderer 在选择此 OutputFormat 时使用一种截然不同的方法:它是基于页面的,因此我们需要提供一个回调和一个机制来单独存储每一页以供以后使用。

以下未公开的选项需要代替 DpiXDpiY 使用

PrintDpiX 要渲染的 DPI
PrintDpiY 要渲染的 DPI

请注意,这实际上与打印无关。就像其他输出的 DpiX/Y 值一样,PrintDpiX/Y 仅控制用于在 EMF 记录过程中渲染报表的 Bitmap 的 DPI。

首先,定义几个变量供回调系统操作

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

例程本身需要一个 PrinterSettings 实例来检测打印机的物理特性

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();

考虑到在普通激光打印机上打印超过 300 DPI 对输出质量几乎没有影响,并且用户可能希望在提交之前看到样本页面的草稿,因此允许选择分辨率而不是仅仅使用打印机分辨率是明智的。

请注意如何指定未公开的 PrintDpiX/Y 选项,以及所选 DPI 如何存储在 _PrintingDPI 中以供以后使用

//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());

其余的是通过 PrintDocument 进行的标准 WinForms 打印。唯一需要注意的是使用 Render() 方法的回调版本,传入回调 lr_CreateStream,以及之后需要将流的读取位置重置为零

    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();
}

回调本身负责提供 Stream 以供 Report Viewer 存储 EMF 输出,并以页面顺序保留其副本。在这个简单的例子中,正在使用内存流

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;
}

打印 EMF

到目前为止,EMF 已在具有所需 DPI 的 Bitmap 上使用 PrintDpiX/Y 选项进行渲染。这隐式地使用物理屏幕作为设备上下文,这意味着 EMF 将显示物理屏幕的物理 DPI,尽管使用的单位系统是来自 PrintDpiX/Y 选项的原始 DPI。

因此,Metafile.GetMetafileHeader().DpiX 实际上将等于显示器的物理 DPI(例如 86.7)。

精确匹配源

以下假设报表具有与目标打印纸完全相同的页面尺寸,以及比打印机最小边距更大的边距

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);
}

现在打印已完成,清理流并重置变量以备下次使用

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;
}

使用打印机边距

以下展示了如何在打印页面上定位报表的左上角。在这种情况下,它只是盲目地使用 PageSettings 包含的任何边距

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);
}

缩放到适合页面

最后一个示例展示了如何应用第二个变换以复制文字处理应用程序中常见的“适合页面”选项

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);
}

请注意,尽管 VS2010 关于 e.PageSettings.PrintableArea 的文档称它对 e.PageSettings.Landscape 设置敏感,但我注意到这似乎并非如此,至少在 .NET 2.0 SP2(.NET 3.5 SP1 的一部分)中是这样。

这意味着如果检测到 Landscape,则可能需要在上述代码中交换 PrintableArea.WidthPrintableArea.Height

无法打印时

问题在于 Windows 需要显示器准确报告其物理尺寸;否则,结果将完全失真。例如,当我通过 mstsc /span 在机器之间进行远程桌面连接时,运行代码的远程会话不仅无法确定远程显示器的物理尺寸,而且还将其分辨率视为一个大的跨越。

由于 ReportViewer 控件内置的打印预览和打印模式使用了与此处使用的相同的标准打印方法,您将清楚地看到重叠的字体、丢失的线条和其他伪影。

检测

以下例程依赖于这样一个事实:当无法确定显示器的物理特性时,Windows 将默认使用一个大的旧 4:3 显示器,物理显示区域为 320 毫米 x 240 毫米。

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);
}

它依赖于古老的 GetDeviceCaps 例程

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);

服务器模式呢?

相同的程序集也在 SSRS 服务器本身上用于处理打印。在我调查这个问题时,我发现了很多关于使用服务器时出现相同损坏打印的报告。

问题似乎是相同的,但我不清楚托管 ReportViewer 程序集的 NT 服务将拾取什么桌面分辨率。

例如,创建一个本地帐户并登录,将其分辨率设置为匹配连接的主监视器(或如下所示的 1024x768),然后注销并设置托管 SSRS 的 NT 服务以使用该帐户登录。所有 EMF 渲染请求(或打印布局显示)现在都会使用该分辨率吗?

这意味着将主监视器从传统的 4:3 纵横比更改为 16:9 宽屏 LCD 会再次导致问题,直到本地帐户的分辨率设置为 16:9 的为止。

建议的解决方法

只要选择的桌面分辨率(无论是直接选择还是通过远程桌面)与 GetDeviceCaps 返回的默认分辨率具有相同的比例,打印就可以正常工作。

例如,给定 320x240 的比例为 4:3,那么 1024x768 的分辨率就可以正常工作。但是,这不仅分辨率低且难以处理,而且下一个最高选项是 2048x1536,并且市面上支持该分辨率的显示器非常少。

在桌面上,我建议使用前面概述的 CanPrint 例程,如果它返回 false,则直接导出为 PDF 以这种方式打印,因为现在 4:3 的显示器已经很少见了。

然而,桌面分辨率方法在服务器上是可行的,如果它可以“固定”的话。

解决方法详情

最近,一位用户在 MSDN 错误报告中添加了一条评论,证实了终端服务 (TS) 与 SSRS 服务之间的关系。

Matt Dodds 于 2010 年 10 月 7 日 05:01 发布:“我们刚在服务器上通过 Web 应用程序以 LocalReport 模式运行的 ReportViewer 中遇到了这个问题。控制台分辨率与 TS 分辨率的理论是准确的——在我们的案例中,有人通过终端服务管理器使用远程桌面会话“接管”了控制台会话。用户以 1400 x 900(比例 1.6)的本机分辨率运行屏幕,而不是标准的 1024 x 768(比例 1.333)的控制台分辨率。因此,渲染器会错误地应用一些缩放,导致所有打印件看起来都太宽。

最终解决方案:登录到控制台,在控制台上并检查您的屏幕分辨率。这似乎能将一切恢复正常。”

关注点

理解 EMF 模式下的 Image Renderer 与其所在显示器之间关系的关键是使用 .NET Reflector。这也是用来查找未公开的 PrintDpiXPrintDpiY 参数以及它们如何与 DpiXDpiY 参数相关的。对于感兴趣的读者,这里重现了计算 Metafile 边界矩形从而确定输出准确性的关键 ReportViewer 例程。

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));
}

为了弄清楚发生了什么,我在纸上画了很多矩形,但除了这个对宽度计算的简要剖析之外,我将避免让大家感到乏味。

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

而在打印事件中,应用的缩放变换导致

width * DeviceDpiX / PrintDpiX = mmTargetWidth

因为所有其他项都已抵消(参见前面 DPI 的定义),从而恢复了原始值。

参考文献

以下链接有助于创建本文

历史

  • 1.0 - 初稿。
  • 1.1 - 增加了关于 PrintableAreaLandscape 模式的警告,并扩展了关注点。
  • 1.2 - 更新了服务器模式的解决方法。
© . All rights reserved.