Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Advanced Report Viewer

4.79/5 (32 votes)
10 Apr 2009CPOL6 min read 507.1K   13.9K  
The article shows how to extend ReportViewer control that comes with Visual Studio 2008. The most important extension is adding export to Microsoft Word

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.

AdvancedReportViewer.png

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
XML
<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:

C#
private static ReportDataSource CreateReportDataSource( object originalDataObject )
{
    string name = originalDataObject.GetType( ).ToReportName( );
    object value = originalDataObject;

    // DataTable
    if( originalDataObject is IListSource )
    {
    }
    // Collection
    if( originalDataObject is IEnumerable )
    {
        name = GetCollectionElementType( originalDataObject ).ToReportName( );
    }
    // Just an object
    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:

C#
public static string ToReportName( this Type type )
{
    var isTypedDataTable =
        type.IsNested &&
        type.BaseType.FullName.StartsWith( "System.Data.TypedTableBase" );

    if( isTypedDataTable )
    {
        // in:  Some.Namespace.CategoryDataSet+CategoryDataTable
        // out: CategoryDataSet_Category
        var match = Regex.Match( type.FullName, @"^.+\.(\w+\+\w+)DataTable$" );
        return match.Groups[ 1 ].Value.Replace( "+", "_" );
    }
    else
    {
        // in:  Some.Namespace.TypeName
        // out: Some_Namespace_TypeName
        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, DataSets and DataTables 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.

C#
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.

C#
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;
    }
}
C#
private void EnableRenderExtension( string extensionName, string localizedExtensionName )
{
    foreach( var extension in RenderingExtensions )
    {
        // name = extension.Name;
        var name = extension
            .GetType( )
            .GetProperty( "Name" )
            .GetValue( extension, null )
            .ToString( );

        if( name == extensionName )
        {
            // extension.m_isVisible = true;
            extension
                .GetType( )
                .GetField( "m_isVisible",
			BindingFlags.NonPublic | BindingFlags.Instance )
                .SetValue( extension, true );

            // extension.m_isExposedExternally = true;
            extension
                .GetType( )
                .GetField( "m_isExposedExternally",
			BindingFlags.NonPublic | BindingFlags.Instance )
                .SetValue( extension, true );

            // extension.m_localizedName = localizedExtensionName;
            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:

C#
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:

C#
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?

C#
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:

C#
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.

C#
// Change export button handler
var exportButton = ToolStrip.Items[ "export" ] as
			System.Windows.Forms.ToolStripDropDownButton;

// Buttons are created on DropDownOpened so we can't assign handler before it
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.

C#
private void MSWordExport_Handler( object sender, EventArgs args )
{
    // Ask user where to save
    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;

    // Create a report
    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 );
    }

    // Show user all warnings
    // NOTE: Default export handler doesn't do that
    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 );
    }

    // Open created report
    // Process.Start( saveDialog.FileName );
}

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.

C#
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.

C#
private void InitializeIcons( )
{
    // Fix find icons
    ToolStrip.Items[ "find" ].Image = Properties.Resources.Report_Find;
    ToolStrip.Items[ "findNext" ].Image = Properties.Resources.Report_FindNext;

    // Fix export icons
    var exportButton = ToolStrip.Items[ "export" ] as
			System.Windows.Forms.ToolStripDropDownButton;

    // Buttons are created on DropDownOpened so we can't assign Icon before it
    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.

XML
<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:

  1. http://www.codeproject.com/KB/cs/custreport.aspx - way to customize
  2. http://www.codeproject.com/KB/printing/LocalizingReportViewer.aspx - way to localize
  3. http://www.codeproject.com/KB/reporting-services/report-viewer-reflection.aspx - way to explore
  4. 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

License

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