Introduction
The article shows how to extend the ReportViewer
control that comes with Visual Studio 2008. The most important extension is adding export to Microsoft Word.
Article Highlights
- Integration into WPF application
- Object data source usage
- Export to Microsoft Word
- Localization
- Customization
Background
When the time came to chose report engine, I chose Microsoft Reporting Services. It is based on open RDL format and mature enough. ReportViewer
that comes with the Studio can be used for free (no need for SQL Server license). It doesn't require any connection to SQL Server either. And it can export to Microsoft Excel and Adobe PDF.
Over time, new requirement arises - Export to Microsoft Word. New version of ReportViewer
that is included in SQL Server 2008 and will be included in Visual Studio 2010 can do that. The version that comes with Visual Studio 2008 SP1 can't. But we need that export functionality now.
Export to Microsoft Word, Approach
The simplest solution - export to *.pdf and then from *.pdf to *.doc. But since my reports are in Russian, it can't be done. There is some issue with encoding that makes the resulting *.doc unreadable. Of course there are some third-party RDL renders to Microsoft Word. But they are not for free. After some research, an insight came:
- Export to HTML can be enabled
- If you save *.html as *.doc, Microsoft Word will open it
Export to HTML is included in local ReportViewer
's code but it's switched off. The article was found how to re-enable it (see links section). Resulting *.do? is not truly a Microsoft Word document. But it can be edited with Microsoft Word and looks like *.doc. If it is not enough one can use Microsoft Word COM Interop library and save it in the native *.doc format. The other issue with this approach - there won't be page header and footer on each page.
NOTE: You may be wondering why can't I just take new ReportViewer
from SSRS 2008. The answer is - there is no local ReportViewer
control there. Only the server-side one. I learned it the hard way. Downloaded SQL Server 2008 Reporting Services Report Builder 2.0. Searched for ReportViewer
DLL and tried to instantiate it. Exception arose - CreateLocalReport
method is not implemented.
Integration into WPF Application
We need a simple way of communicating with the control. To use it in XAML, we need two dependency properties:
- Source of the RDLC report (RDLC stands for client RDL)
- Source of data for the report
<controls:ReportViewerUserControl
EmbeddedReport="AdvancedReportViewer.Reports.SampleReport.rdlc"
DataSource="{StaticResource samplePerson}" />
Where DataSource
is an object that contains data for the report and EmbeddedReport
is a string
that tells where to search for the RDLC report.
EmbeddedReport
is a simple one. It sets reportViewer.LocalReport.ReportEmbeddedResource
property. RDLC reports are added to the project as embedded resources. So to use them, one must specify the fully qualified path - default namespace.reports directory.report
file name.
Object Data Source Usage
DataSource
is a tricky one. ReportViewer
awaits data in a special format.
- It works only with
IListSource
(DataTables
) and IEnumerable
(Collections
) - Name of the data source must match its type
ReportDataSource
wrapper must be used
After some time of trial-and-errors, the code was written:
private static ReportDataSource CreateReportDataSource( object originalDataObject )
{
string name = originalDataObject.GetType( ).ToReportName( );
object value = originalDataObject;
if( originalDataObject is IListSource )
{
}
if( originalDataObject is IEnumerable )
{
name = GetCollectionElementType( originalDataObject ).ToReportName( );
}
else
{
value = new ArrayList { originalDataObject };
}
Debug.Assert( !string.IsNullOrEmpty( name ),
"Data source's name must be defined " );
Debug.Assert( value != null, "Data source must be defined" );
return new ReportDataSource( name, value );
}
Where Type.ToReportName( )
is:
public static string ToReportName( this Type type )
{
var isTypedDataTable =
type.IsNested &&
type.BaseType.FullName.StartsWith( "System.Data.TypedTableBase" );
if( isTypedDataTable )
{
var match = Regex.Match( type.FullName, @"^.+\.(\w+\+\w+)DataTable$" );
return match.Groups[ 1 ].Value.Replace( "+", "_" );
}
else
{
return type.FullName.Replace( ".", "_" );
}
}
It may seem strange to use a report that shows data of a single object. But it was in fact the main use case for my project. Anyway, the code is flexible enough to accept collections, DataSet
s and DataTable
s as data as well.
Export to Microsoft Word
Articles [3] and [4] tell us how to extend render capabilities of a ReportViwer
. In short - there is render to HTML but it is off and it is hard-coded. To change the situation, one must change RenderingExtensions
.
RenderingExtensions =
reportViewer.LocalReport.m_previewService.ListRenderingExtensions( )
Each extension contains self explanatory fields:
Name
- internal name of a rendering extension m_localizedName
- localized name that is shown in Export dropdown m_isVisible
, m_isExposedExternally
- visibility and availability to the end user
But all mentioned code is not public
. In fact, we have to use reflection to modify it.
private IList RenderingExtensions
{
get
{
var service = reportViewer.LocalReport
.GetType( )
.GetField( "m_previewService",
BindingFlags.NonPublic | BindingFlags.Instance )
.GetValue( reportViewer.LocalReport );
var extensions = service
.GetType( )
.GetMethod( "ListRenderingExtensions" )
.Invoke( service, null );
return (IList) extensions;
}
}
private void EnableRenderExtension( string extensionName, string localizedExtensionName )
{
foreach( var extension in RenderingExtensions )
{
var name = extension
.GetType( )
.GetProperty( "Name" )
.GetValue( extension, null )
.ToString( );
if( name == extensionName )
{
extension
.GetType( )
.GetField( "m_isVisible",
BindingFlags.NonPublic | BindingFlags.Instance )
.SetValue( extension, true );
extension
.GetType( )
.GetField( "m_isExposedExternally",
BindingFlags.NonPublic | BindingFlags.Instance )
.SetValue( extension, true );
extension
.GetType( )
.GetField( "m_localizedName",
BindingFlags.NonPublic | BindingFlags.Instance )
.SetValue( extension, localizedExtensionName );
}
}
}
HTML rendering extension has internal name HTML4.0
. So enabling export to *.html will be:
EnableRenderExtension( "HTML4.0", "MS Word" );
Now we need to change "Export to Microsoft Word" handler. Otherwise we will get ".html" file. First let's switch off ReportViewer
's export dialog that appears when "Export to Microsoft Word" clicked:
reportViewer.ReportExport += ( sender, args ) =>
args.Cancel = args.Extension.LocalizedName == "MS Word";
Now we need to modify behaviour of a control when the Export to Microsoft Word button is clicked. Finding that button is an interesting story. Did you know that ReportViewer
's ToolStrip
can be accessed with public
interface?
private T FindControl<t>( System.Windows.Forms.Control control )
where T: System.Windows.Forms.Control
{
if( control == null ) return null;
if( control is T )
{
return (T) control;
}
foreach( System.Windows.Forms.Control subControl in control.Controls )
{
var result = FindControl<t>( subControl );
if( result != null ) return result;
}
return null;
}
And ToolStrip
is:
ToolStrip = FindControl<system.windows.forms.toolstrip>( reportViewer );
All controls on that ToolStrip
have friendly names (see yourself with reflector =). Export button has name "export
". On DropDown
its sub-buttons are created. One of those buttons is Export to Microsoft Word that we are looking for.
var exportButton = ToolStrip.Items[ "export" ] as
System.Windows.Forms.ToolStripDropDownButton;
exportButton.DropDownOpened += delegate( object sender, EventArgs e )
{
var button = sender as System.Windows.Forms.ToolStripDropDownButton;
if( button == null ) return;
foreach( System.Windows.Forms.ToolStripItem item in button.DropDownItems )
{
var extension = (RenderingExtension) item.Tag;
if( extension.LocalizedName == "MS Word" )
{
item.Click += MSWordExport_Handler;
}
}
};
When the user clicks on it, MSWordExport_Handler
will be called.
private void MSWordExport_Handler( object sender, EventArgs args )
{
var saveDialog = new SaveFileDialog
{
FileName = ReflectionHelper.GetPropertyValue
( reportViewer.LocalReport, "DisplayNameForUse" ) + ".doc",
DefaultExt = "doc",
Filter = "MS Word (*.doc)|*.doc|All files (*.*)|*.*",
FilterIndex = 0,
};
if( saveDialog.ShowDialog( ) != true ) return;
Warning[] warnings;
using( var stream = File.Create( saveDialog.FileName ) )
{
reportViewer.LocalReport.Render(
"HTML4.0",
@"<DeviceInfo><ExpandContent>True</ExpandContent></DeviceInfo>",
(CreateStreamCallback) delegate { return stream; },
out warnings );
}
if( warnings.Length > 0 )
{
var builder = new StringBuilder( );
builder.AppendLine( "Please take notice that:" );
warnings.Action( warning => builder.AppendLine( "- " + warning.Message ) );
MessageBox.Show(
builder.ToString( ),
"Warnings",
MessageBoxButton.OK,
MessageBoxImage.Warning );
}
}
ReflectionHelper
is a class that gets the private
property value for default report name. ExpandContent
instructs renderer to use tables with width of a page. Otherwise content will be squeezed. Render
method accepts a delegate
that returns a stream
. Render
is performed on that stream
. The stream
is not closed on Render
's exit so we must to ensure it ourselves. Also, default export handlers don't do that, but I think it's helpful to show warnings of a report.
NOTE: There is one more issue to highlight. Using reflection, one can add custom renderer to ReportViewer
. Why not take Word renderer from SSRS 2008? The answer is - the interfaces are incompatible. And yes, I learned it the hard way.
Localization
To localize ReportViewer
GUI interface one must implement IReportViewerMessages
and assign an instance to Messages
property. But let's make something more useful. Let the user decide what language he wants. To do that, we need some control that will list all languages available. And we need to host that control somewhere. Somewhere on the ReportViewer
will be perfect.
private void InitializeLocalization( )
{
var separator = new System.Windows.Forms.ToolStripSeparator( );
ToolStrip.Items.Add( separator );
var language = new System.Windows.Forms.ToolStripComboBox( );
language.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
language.Items.Add( "English" );
language.Items.Add( "Русский" );
language.SelectedIndex = 0;
language.SelectedIndexChanged += delegate
{
switch( (string) language.SelectedItem )
{
case "English":
reportViewer.Messages = null;
break;
case "Русский":
reportViewer.Messages = new RussianReportViewerMessages( );
break;
default:
Debug.Assert( false, "Unknown language: " +
(string) language.SelectedItem );
break;
}
};
ToolStrip.Items.Add( language );
}
Customization
As a dessert, let's change some icons of ReportViewer
to make it more attractive.
private void InitializeIcons( )
{
ToolStrip.Items[ "find" ].Image = Properties.Resources.Report_Find;
ToolStrip.Items[ "findNext" ].Image = Properties.Resources.Report_FindNext;
var exportButton = ToolStrip.Items[ "export" ] as
System.Windows.Forms.ToolStripDropDownButton;
exportButton.DropDownOpened += delegate( object sender, EventArgs e )
{
var button = sender as System.Windows.Forms.ToolStripDropDownButton;
if( button == null ) return;
foreach( System.Windows.Forms.ToolStripItem item in button.DropDownItems )
{
var extension = (RenderingExtension) item.Tag;
switch( extension.LocalizedName )
{
case "MS Word":
item.Image = Properties.Resources.Report_Word;
break;
case "MS Excel":
item.Image = Properties.Resources.Report_Excel;
break;
case "Adobe PDF":
item.Image = Properties.Resources.Report_PDF;
break;
}
}
};
}
Using the Code
You can use whole ReportViewerUserControl
that comes with the code.
<controls:ReportViewerUserControl
EmbeddedReport="AdvancedReportViewer.Reports.SampleReport.rdlc"
DataSource="{StaticResource samplePerson}" />
Or you can use just the aspect you need. I'd suggest exploring aspects from ReportViewerUserControl.InitializeReportViewer
.
Links
The article would not have been created without:
- http://www.codeproject.com/KB/cs/custreport.aspx - way to customize
- http://www.codeproject.com/KB/printing/LocalizingReportViewer.aspx - way to localize
- http://www.codeproject.com/KB/reporting-services/report-viewer-reflection.aspx - way to explore
- http://beaucrawford.net/post/Enable-HTML-in-ReportViewer-LocalReport.aspx - way to render
History
- 09 April 2009 - First publication
- 10 April 2009 - Fixed bug in
CreateReportDataSource
Thoughts on Version 2
If this article will be successful, I'd highlight the following aspects in version 2:
- Subreports with object data source
- Report codegeneration
- Using custom code in reports
- Using Microsoft Word COM interop to convert to true *.doc