Click here to Skip to main content
14,036,296 members
Click here to Skip to main content
Add your own
alternative version


16 bookmarked
Posted 28 Jan 2016
Licenced CPOL

A Generic and Advanced PDF Data List Reporting Tool

, 28 Jan 2016
Rate this:
Please Sign up or sign in to vote.
Creating PDF reports for grouped or non-grouped data lists using a C# class library that automatically sets column width, paper selections, and page breaks in addition to configurable formatting and styling options.


Many business applications need to export the data lists to PDF files. Since the PDF components are physically rendered on document pages, the best practice of obtaining a data list in PDF format is to directly generate a PDF data list report with the help of a PDF rendering tool. Uzi Granot shared his excellent and lightweight base PDF rendering class library, PdfFileWriter, with the developer’s communities. Using this library with a little tweak for the internal code, I have built the PdfDataReport tool to create PDF reports from C# data lists with and without record grouping. It’s generic in that adding a new, or updating an existing, report needs just adding or updating an XML node in the report descriptor that matches the model class for the data source, and then calling the method with arguments of the data list and report descriptor. All major features of the tool will be demonstrated on the sample application and also discussed in this article.

Building and Running Sample Projects

The downloaded source can be opened in the Visual Studio 2010 - 2015. The solution consists of four projects.

  1. PdfDataReport: the PDF data list report processor. It’s the main focus of this article and code discussions.

  2. PdfDataReport.Test: the simple WPF program simulating the PdfDataReport tool consumer for testing the data report generations.

  3. PdfDataReport.Test.Model: containing data model classes for the sample application. The PdfDataReport tool itself doesn’t directly use any data model class. The base type of the data list, List<T>, is passed dynamically.

  4. PdfFileWriter: the PDF renderer class library (version 1.16.4). I have made some modifications in the PdfDocument.csPdfTable.csPdfTableCell.cs, and TextBox.cs class files for the PDF data list needs. The code changes inside the PdfFileWriter library are not the focus of this article. Audiences can search “SW:” labeled lines in these files for details if interested.

You should re-build the solution without any dependency problem. Make sure that the PdfDataReport.Test is the startup project and the default PDF display application exists on your machine (usually the Adobe Reader). You can then press F5 to run the application in the debugging mode to show the demo page.

Each link on the demo page will call this method in the PdfDataReport.ReportBuilder class to build the PDF byte array with which the PDF data report file is derived.

Byte[] pdfBytes = builder.GetPdfBytes(List<T> dataList, string xmlDescriptor);

The first argument is a Generic List object as the data source. The PdfDataReport.Test project contains a test data source class file, TestData.cs, for generating a desired number of data records for demonstrations. The data list needs to be ordered by an object property (a.k.a., data field) if the list is grouped by the property. The second argument is an XML string for report schema definitions (called as descriptor throughout the article - see the next section for details).

Try to click the first link, Product Orders Grouped by Order Status, on the demo page. The PDF generation process starts to run for the Product Order Activity report and the resulted PDF page is shown:


The XML descriptor document defines the report structures, components, and data field properties. It’s the critical part to make the report tool generic. The XML document file can be placed anywhere the PdfDataReport process can access. A report node can be added into the document for generating the report from a particular data source. Below is a typical report node chunk for the Product Order Activity report in the sample descriptor report_desc_sm.xml file.

<report id="SMStore302" model="ProductOrders">        
            <title>Product Order Activity</title>
            <grouptitle>Order Status: {propertyvalue}</grouptitle>                
            <col name="OrderId" display="Order Number" datatype="integer"/>        
            <col name="OrderDate" display="Order Date" datatype="datetime" />
            <col name="OrderStatus" display="Order Status" group="true" datatype="string" />            
            <col name="CustomerId" datatype="integer" visible="false" />
            <col name="CustomerName" display="Customer" datatype="string" nowrap="true" />        
            <col name="NumberOfItems" display="Number of Items" datatype="integer" total="true" alignment="right" />
            <col name="OrderAmount" display="Amount ($)" datatype="currency" total="true" />
            <col name="ShippedDate" display="Shipped Date" datatype="datetime" default-value="-" />                    

In the above structure, there are two section nodes, general and columns, under the view node.  The general node contains descriptor items for the report titles and data group info. Under the columns node, each col node defines attributes and values for what the column should be in the report display. During the starting phase of the report generation process, descriptor items and values will be transferred into these programming data caches: 

  • Local variables for individual elements from the general node.
  • The List<ColumnInfo> colInfoList for non-grouped columns.
  • The groupColumnInfo object for the group-by column.

The ReportBuiler.GetColumnInfo method is called to parse the XML nodes to populate the List<ColumnInfo> colInfoList and the groupColumnInfo object:

private List<ColumnInfo> GetColumnInfo(XmlDocument xmlDoc, ref ColumnInfo groupColumnInfo)
    var nodeList = xmlDoc.SelectNodes("/report/view/columns/col");
    var colInfoList = new List<ColumnInfo>();
    ColumnInfo colInfo = default(ColumnInfo);
    var idx = 0;

    //Check if the list contains only one grouped column. Multiple grouped-column list will be treated as non-grouped data.  
    var isOneGroup = false;
    foreach (XmlNode node in nodeList)
        if (Util.GetNodeValue(node, "@group", "false") == "true")
            if (isOneGroup)
                isOneGroup = false;
                isOneGroup = true;

    foreach (XmlNode node in nodeList)
        //Invisible is auto excluded.
        var isVisible = bool.Parse(Util.GetNodeValue(node, "@visible", "false"));
        //Include needed columns.
        if (isVisible)
            colInfo = new ColumnInfo();
            var colNameNode = node.Attributes["name"];
            if (colNameNode == null)
                throw new Exception("Column (" + idx.ToString() + ") name from XML is missing.");
                colInfo.ColumnName = colNameNode.InnerText;

            colInfo.DisplayName = Util.GetNodeValue(node, "@display");
            colInfo.DataType = Util.GetNodeValue(node, "@datatype", "string");
            colInfo.IsGrouped = bool.Parse(Util.GetNodeValue(node, "@group", "false")) || colInfo.ColumnName.ToLower() == groupByColumn;
            colInfo.IsTotaled = bool.Parse(Util.GetNodeValue(node, "@total", "false"));
            colInfo.IsAveraged = bool.Parse(Util.GetNodeValue(node, "@average", "false"));
            colInfo. DefaultValue = Util.GetNodeValue(node, "@default-value");            

            var align = node.Attributes["alignment"];
            if (align == null)
                //Default aligments based on type.
                switch (colInfo.DataType.ToLower())
                    case "string":
                        colInfo.Alignment = "left";
                    case "currency":
                        colInfo.Alignment = "right";
                    case "percent":
                        colInfo.Alignment = "right";
                    case "integer":
                        colInfo.Alignment = "right";
                    case "datetime":
                        colInfo.Alignment = "center";
                        colInfo.Alignment = "left";
                colInfo.Alignment = align.InnerText.ToLower();

            if (isOneGroup && colInfo.IsGrouped)
                //If it's one group list.
                groupColumnInfo = colInfo;
                //Non-grouped data list or the list having more than one group column.
    return colInfoList;

The XML node parser also sets a default value for any retrieved XML attribute of a col node except for the name. Thus, only the name attribute with the string data type in the descriptor is theoretically required when adding any new col node into the descriptor file. In addition, the col node has the default-value attribute with which we can specify any desired value to be displayed in the column if the delivered data value is 0 (for numeric types), null, or empty.

Setting Column Width

All data column width values need to be explicitly defined for the PDF table creation. The PdfDataReport tool supports manual or automatic column width settings. Any positive value exits for the fixed-width attribute of the col node in the descriptor will overwrite the default automatic width setting for the column. In this case, any text for which the width is longer than the fixed column width will be wrapped in the column. The below XML line example will set the Customer column to 1.8 inch (the unit of measure is set from the report config file - see the UnitOfMeasure key in the sample App.config file for details):

<col name="CustomerName" display="Customer" datatype="string" fixed-width="1.8" />

Most PDF data lists use the automatic column widths as shown in the Product Order Activity report. Setting automatic column widths needs to firstly calculate the total data character width of the column body and take the maximum value for all data rows in the column. In each loop of processing a data record, the code to detect the maximum width of the column body is like this:

currentTextWidth = bodyFont.TextWidth(BODY_FONT_SIZE, dataString.Trim());
if (textWidthForTotalCol > currentTextWidth)
    currentTextWidth = textWidthForTotalCol;
if (currentTextWidth > textWidth)
    textWidth = currentTextWidth;

The process then calculates the width of the longest word in the column header display and picks the larger number from those for the maximum total body character width and the longest header display word width:

var wordWidth = 0d;
List<string> dspWords = colInfoList[idx].DisplayName.Split(' ').ToList();
foreach (var dspWord in dspWords)
    //Check for some symbol column such as checkbox or star.                   
    currentTextWidth = headerFont.TextWidth(headerFontSizeForCalculation, dspWord == "" ? "*" : dspWord);
    if (currentTextWidth > wordWidth)
        wordWidth = currentTextWidth;
if (wordWidth > textWidth)
    textWidth = wordWidth;

The calculated width data of all columns is cached in the List<double> columnWidths that will be converted to an array for calling the PdfFileWriter.rptTable.SetColumnWidth method.


Automatic Paper Size Selection

A printable PDF document is bound to a particular size or type of paper. For a data list report, the width of the page depends upon the total column width. The PdfDataReport tool can automatically select the paper size or orientation through calculations of the page content width (total column width plus left/right margins) based on these facts and rules:

  1. Pre-defined paper size list needs to be provided from the PaperSizeList key in the configuration file. The sample application sets the paper sizes of 8.5x11, 8.5x14, 11x17, and 12x18 in inches by default. Most of these are commonly used paper sizes in the US.

  2. The portrait orientation of the first paper size (8.5x11 as in the sample application) will be picked up if the page content width doesn’t exceed the paper portrait width. Else, it will use the landscape orientation of the first paper size.

  3. If the first paper landscape size doesn’t fit, all next selections will be the landscape orientation with increased paper size, for example, the landscaping 8.5x14, 11x17, and so on.

  4. If the page content width exceeds the maximum width of the landscaping paper sizes pre-defined in the configuration list, the calculated real page width and the paper height of the last pre-defined page size will be set for the report display.

The code lines below show the implementation details.

//Automatic paper size and orientation selections.
var pageSizeOptionArray = PAGE_SIZE_OPTIONS.Split(',');
var pageSizeOptionList0 = new List<SizeD>();
foreach (var elem in pageSizeOptionArray)
    var elemArray = elem.Split('x');
    var item = new SizeD()
        //Set landscape orientations in data array by default.
        Height = double.Parse(elemArray[0].Trim()),
        Width = double.Parse(elemArray[1].Trim())
//Sort it in case input list is not in sequence of small to large size width.
var pageSizeOptionList = pageSizeOptionList0.OrderBy(o => o.Width).ToList();
//Now add portrait orientation for the first item.
pageSizeOptionList.Insert(0, new SizeD()
    Width = pageSizeOptionList[0].Height,
    Height = pageSizeOptionList[0].Width
//Pick up minimum page size based on maximum total column width plus margins and then update page size.
var leftMargin = MAXIMUM_LEFT_MARGIN;
var rightMargin = MAXIMUM_RIGHT_MARGIN;
var sizeMatched = false;
var totalColummWidth = columnWidths.Sum();
foreach (var item in pageSizeOptionList)
    //Left and right margins are dynamically set between minimum and maximum values.
    var spaceForMargins = item.Width - totalColummWidth;
    if (spaceForMargins > MINIMUM_WIDTH_MARGIN * 2)
        document.PageSize.Width = item.Width * document.ScaleFactor;
        document.PageSize.Height = item.Height * document.ScaleFactor;
        sizeMatched = true;

        //If space smaller than max config values, set remaining space proportionally for left/right margins.
        if (spaceForMargins < (leftMargin + rightMargin))
            leftMargin = spaceForMargins * leftMargin/(leftMargin + rightMargin);
            rightMargin = spaceForMargins - leftMargin;
if (!sizeMatched)
    leftMargin = MINIMUM_WIDTH_MARGIN;
    rightMargin = leftMargin;
    document.PageSize.Width = (totalColummWidth + leftMargin + rightMargin) * document.ScaleFactor;
    //Use height of the last pre-defined page size.
    document.PageSize.Height = pageSizeOptionList[(pageSizeOptionList.Count - 1)].Height;

When clicking the link Automatic Select Paper Size or Orientation on the demo page of the sample application, the page shows in the landscape letter size (11x8.5) since the page content width is larger than the paper size in the portrait orientation (8.5x11).

Column Alignment

With the default PdfFileWriter.PdfTable settings, columns on the page are rendered in justified alignment style, i.e., aligned to both left and right margins with extra spaces distributed inside columns. This is achieved by fractionally adjusting each column width and making the total column width equal to the table width. The PdfDataReport tool can also use the left-align style in which page contents are aligned only to the left margin and the remaining empty space is extended towards the right margin. All previous PDF page screenshots have shown the left-align style which is implemented by adding a dummy column that occupies all remaining empty spaces.

//If not uisng justify page-wide layout, all remaining space needs to be a dummy column.
    var dummyColWidth = rptTable.TableArea.Right - columnWidths.Sum();
    colInfoList.Add(new ColumnInfo() { ColumnName = "Dummy", ActualWidth = dummyColWidth });

The JUSTIFY_PAGEWIDE flag value can be set in the report configuration file. For reports created using the PdfDataReport tool, columns are left-aligned by default when the flag or its value doesn’t exist. Setting the flag to true will justify the columns to the page content width. The justified layout is demonstrated on the Test Automatic Page Selection report when clicking the Justify Display Columns to Page-Wide link on the sample application demo page.

Grouped Data Display and Page Breaks

The PdfDataReport tool can display grouped data rows and aggregated data items as shown on the first screenshot for the Product Order Activity report. To keep the report content clear and easy to read, the tool only supports grouping a data list by one field. This should meet most of the business data report needs.

To correctly display grouped data records on the PDF data report display, some design considerations and implementing approaches are important as illustrated below:

  1. The C# List data source must already be sorted for the group-by field/property before it is attached to the List<T> dataList argument of the builder.GetPdfBytes method. The PdfDataReport tool can then process the header, body, and footer rows for individual groups.

    object prevValue = default(object);
    object currValue = default(object);
    foreach (var item in dataList)
        currValue = item.GetType().GetProperty(groupColumnInfo.ColumnName).GetValue(item, null);
        //Start new group.
        if (!currValue.Equals(prevValue))
            //Footer row for last processed group (except first time).
            if (prevValue != null)
                //Draw footer row for previous group here...
            //Draw header row for current group here...
            prevValue = currValue;
        //Draw body rows for current group here...
  2. Adding the group-by column itself will be skipped in the processing loop since the column is excluded from the colInfoList when it’s populated based on the XML descriptor. As a result, no group-by column is displayed. Instead, the group-by column data value with possible custom static text will be shown on the group header. The general/group-title node in the XML descriptor defines the custom static text and data placeholder. If the group-title node and its value exist, the value will overwrite the default text and format.

    //Set overwritting group title.
    groupTitle = Util.GetNodeValue(objXMLDescriptor, "/report/view/general/group-title");
    . . .
    //Header row for current group.
    rptTable.Cell[0].Value = groupColumnInfo.DisplayName + " = " + currValue.ToString();
    if (!string.IsNullOrEmpty(groupTitle))
        rptTable.Cell[0].Value = groupTitle.Replace("{propertyvalue}", currValue.ToString());
  3. Any Total or Average value, if specified in the XML descriptor, and the number of total records for the group need to be shown on the group footer. The detailed code logic is in the ReportBuilder.DrawGroupFooter method but here is the example of how to draw the group footer row that includes the totaled column.

    //Group total row
    if (hasTotal)
        //This label at least occupies width space of first two columns that shouldn't be totaled columns.
        rptTable.Cell[0].Value = " Group Total";
        foreach (var colTotalIndex in colTotalIndexList)
            rptTable.Cell[colTotalIndex].Value = colInfoList[colTotalIndex].GroupTotal;
  4. When there is any totaled or averaged column in the list data source, it should leave enough width space for the Group Total or Group Average label before the starting position of the first totaled or averaged column. Usually, any totaled or averaged column should be placed after first two or three columns to guarantee the correct display of the totaled or averaged number value.

  5. Automatic page breaks need to occur smoothly between groups and after the last data row of the report. To pursue common rules for a well formatted data list document, particularly for the physically rendered PDF data list documents, the PdfDataReport tool never breaks such document pages when no data row is left for the next page. In another words, the next page should begin with at least one data row for the particular group or entire report. This rule avoids showing only the header and footer on a page.

    The code logic to implement this rule is a little complex. But basically, the tool calculates the remaining space when processing the last row in the data group. If the space is not enough for the data row plus the group footer rows, the page will immediately break, moving the last data rows to the next page. For the last row of the entire data list, the calculated space allowance includes the report footer rows. The main parts of the code are in the DrawGroupBreak method. See the comments for how code lines operate on the page breaks and also add the continue-page message display at the break point.

    private bool DrawGroupBreak(PdfTable rptTable, bool lastGroupItem = false, bool lastDataItem = false)
        //Exclude condition where there is only entire row on page.
        if (rptTable.RowTopPosition == rptTable.TableArea.Top - rptTable.Borders.TopBorder.HalfWidth)
            return false;
        var room = 0d;
        var bottomLimit = rptTable.TableArea.Bottom + rptTable.Borders.BottomBorder.HalfWidth;
        //If remaining space is not enough for
        // 2 rows: for mid-group-row.
        // 3 rows: for last-group-row (with total/average).
        // 4 rows: for last-group-row (with total and average).
        // 6 rows: for last-group/report-row and report footer (with total only).
        if (lastGroupItem)
            if (lastDataItem)
                room = rptTable.RowTopPosition - rptTable.RowHeight * 6;
                //For last-group-row scenario.
                if (hasTotal && hasAverage) room = rptTable.RowTopPosition - rptTable.RowHeight * 4;
                else if (hasTotal || hasAverage) room = rptTable.RowTopPosition - rptTable.RowHeight * 3;
            //For mid-group-row scenario.
            room = rptTable.RowTopPosition - rptTable.RowHeight * 2;
        if (room < bottomLimit)
            //Draw line.
            var pY = rptTable.RowTopPosition; // rptTable.BorderYPos[rptTable.BorderYPos.Count - 1];
            rptTable.Contents.DrawLine(rptTable.BorderLeftPos, pY, rptTable.BorderRightPos, pY, rptTable.Borders.CellHorBorder);
            //Draw message.
            var temp = "";
            if (lastGroupItem)
                temp = "(A group record continues on next page...)";
                temp = "(Group records continue on next page...)";
            //Backup style.
            var cellStyle = new PdfTableStyle();
            //Set value and styles, and then draw continue row.
            rptTable.Cell[0].Value = temp;
            rptTable.Cell[0].Style.Font = bodyFontItalic;
            rptTable.Cell[0].Style.Alignment = ContentAlignment.BottomLeft;
            //Set style back
            return true;
        return false;

When clicking the link Product Orders Grouped by Order Status with End-group Page Break on the Demo page of the sample application, the Product Order Activity report will be shown with pages that break before the last row in a group.

Clicking the next link, Product Orders Grouped by Order Status with End-report Page Break, on the Demo page will show the example of page break before the last row of the entire data list. In this case, there are the last data row, group footer rows, and report footer rows on the last PDF page.

Setting Background and Border Line Colors

The PdfDataReport tool provides limited pre-defined but most probably used background and border color selections for the column header, group headers, alternative rows, column header and footer lines, and group header and footer lines. The report generation process will always use the default settings if any configuration item or its value for these color settings doesn’t exist. You can easily change these color settings from the report configuration file. You can even add any new color setting into the tool for your needs with these steps:

  1. By searching the selColor variable in the PdfDataReport.ReportBuilder class, you can find the places for coding the existing color selection items. For example, the code for the group header background colors is like this:

    //Group header background color.
    Color selColor;
        case "FaintLightYellow":
            selColor = Color.FromArgb(255, 255, 238);
        case "VeryLightYellow":
            selColor = Color.FromArgb(255, 255, 226);
        case "LightYellow":
            selColor = Color.FromArgb(255, 255, 214);
        case "FaintLightGray":
            selColor = Color.FromArgb(230, 230, 230);
        case "VeryLightGray":
            selColor = Color.FromArgb(220, 220, 220);
        case "LightGray":
            selColor = Color.FromArgb(207, 210, 210);
        default: //VeryLightYellow
            selColor = Color.FromArgb(255, 255, 226);
    DrawBackgroundColor(rptTable, selColor, rptTable.BorderLeftPos, cell.ClientBottom, rptTable.BorderRightPos - rptTable.BorderLeftPos, rptTable.RowHeight);
  2. Add your own color item as a new case into the switch block.

  3.  Find the corresponding key in the appSettings section in your configuration file. For example, the key for the group header background colors is like this:

    <!--Available GroupHeaderBackgroudColor settings: "FaintLightYellow", "VeryLightYellow" (default), "LightYellow", "FaintLightGray", "VeryLightGray", "LightGray"-->
    <add key="GroupHeaderBackgroudColor" value=""/>
  4.  Update the value with the string representation of your new color setting.

Using PdfDataReport Tool in Your Own Projects

To incorporate the Visual Studio PdfDataReport project into your development environment, following these steps:

  1. Copy the physical folders of PdfDataReport and PdfFileWriter projects to your solution root folder. Then open your solution in the Visual Studio and add those two projects as existing ones into your solution.

  2. Set the reference to the PdfDataReport project from your executing project/assembly that calls the PDF report generation processes.

  3. Create your PDF report descriptor file that consists of definitions for resulted data list reports. Place the file to the location that your executing project/assembly can access. You may make the descriptor file path configurable using your executing project/assembly configuration file.

  4. Add or update any key and value, if you would not like to use the defaults, for report format and style settings in your executing project/assembly configuration file. Refer to the App.config file in the PdfDataReport.Test project for details.

  5. In your executing project/assembly, call the ReportBuilder.GetPdfBytes method with the dataList and xmlDescriptor auguments. You can then use the returned PDF byte array for creating a PDF file or directly pass it in the HTTP response to web clients for automatically opening the PDF report on browsers.

The downloaded runtime version of the tool contains the PdfDataReport.dll, PdfFileWriter.dll, and two sample files, App.config and report_desc_sm.xml. If you don’t need to modify the internal code of the PdfDataReport tool, you may simply add the PdfDataReport.dll and PdfFileWriter.dll into, and reference the PdfDataReport.dll from, your projects. You can then set up the XML descriptor file and desired configuration items from your executing project/assembly before writing the code to call the ReportBuilder.GetPdfBytes method.


The PdfDataReport tool presented here can be used to create PDF reports from a single-field grouped or non-grouped data lists. It’s a generic and developer-friendly report build engine. In addition to the full source code and sample demo application, the article emphasizes tool’s most important features and code pieces to help developers understand the design patterns, processing logic, and workflow for effectively and efficiently using the tool in business data applications.


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


About the Author

Shenwei Liu
United States United States
Shenwei is a software developer and architect, and has been working on business applications using Microsoft and Oracle technologies since 1996. He obtained Microsoft Certified Systems Engineer (MCSE) in 1998 and Microsoft Certified Solution Developer (MCSD) in 1999. He has experience in ASP.NET, C#, Visual Basic, Windows and Web Services, Silverlight, WPF, JavaScript/AJAX, HTML, SQL Server, and Oracle.

You may also be interested in...


Comments and Discussions

QuestionGet data to wrap Pin
cdegruccio20-Mar-19 7:22
membercdegruccio20-Mar-19 7:22 
PraiseIt looks great. Thanks for share. Pin
wps8848@hotmail.com11-Feb-19 0:41
memberwps8848@hotmail.com11-Feb-19 0:41 
QuestionChinese disorderly code Pin
Member 116050851-Jun-17 20:47
groupMember 116050851-Jun-17 20:47 
QuestionRTL languages support Pin
taminha5-Dec-16 19:35
membertaminha5-Dec-16 19:35 
QuestionUnhandled Exception when trying to run Pin
AlexGeorg27-Oct-16 2:22
memberAlexGeorg27-Oct-16 2:22 
PraiseGreat post! Pin
Member 1027385629-Jan-16 12:35
memberMember 1027385629-Jan-16 12:35 
GeneralRe: Great post! Pin
Shenwei Liu29-Jan-16 17:15
memberShenwei Liu29-Jan-16 17:15 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Permalink | Advertise | Privacy | Cookies | Terms of Use | Mobile
Web02 | 2.8.190424.1 | Last Updated 28 Jan 2016
Article Copyright 2016 by Shenwei Liu
Everything else Copyright © CodeProject, 1999-2019
Layout: fixed | fluid