ابزار ساخت گزارش PDF در WPF

در این مقاله گزارشاتی که در یک برنامه WPF وجود دارد را به صورت PDF نمایش می دهیم .تنظیماتی هم بر روی سایز صفحات و سایر گزینه ها مانند رنگ پس زمینه ، Border ها و غیره هم انجام خواهیم داد.

ابزار ساخت گزارش PDF در WPF

بسیاری از برنامه ها نیاز دارند که لیست ها و گزارشات خود را به صورت pdf نمایش دهند .بهترین حالت برای نمایش داده ها به صورت PDF این است که از ابزارهای خود pdf برای این کار استفاده کنیم .برای مثال کتابخانه Uzi Granot یک کتابخانه سبک و راحت برای استفاده از pdf است .

برای درک بهتر یک پروژه عملی را با هم انجام می دهیم .برنامه ای که ساخته ایم چهار پروژه درون خود دارد .

PdfDataReport : این پروژه پردازش لیست ها به داده های pdf را انجام می دهد .

PdfDataReport.Test :  یک برنامه WPF ساده است که کار تست را برای ما انجام می دهد .

PdfDataReport.Test.Model : حاوی کلاس های دیتامدل است .و همچنین نوع داده List<T> به آن به صورت داینامیک پاس داده می شود

PdfFileWriter : کتابخانه کلاس render Pdf در این قسمت قرار دارد .

قبل از شروع بحث اگر پروژه ای که ضمیمه مقاله است را اجرا کنید شکل زیر را خواهید دید .

هر لینکی که در داخل این صفحه می بینید یک متدی را در PdfDataReport فراخوانی می کند .و بعد از آن هم ReportBuilder شروع به ساخت pdf می کند .

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

اولین آرگومان یک لیست جنریکی از اشیا می باشد .پروژه PdfDataReport.Test حاوی یک منبع داده ای به نام TestData.cs است .این تابع یک لیست عددی برای نمایش به ما بر می گرداند .دومین آرگومان یک رشته XML است .که برای ساخت شمای گزارش از آن استفاده خواهد شد .

اگر بعد از اجرای برنامه بر روی لینک اول کلیک کنید PDF generation شروع به ایجاد Product Order خواهد کرد که شکل آن در زیر نشان داده شده است .

توصیف کننده ها

توصیف کننده XML در واقع ساختار گزارش ، ساختار کامپوننت ها ، و ویژگی دیتافیلدها را شرح می دهد .این بخش برای ساخت گزارش از نوع pdf ضروری است .در زیر یک توصیف کننده گزارش  به نام report_desc_sm.xml را می بینید .

<report id="SMStore302" model="ProductOrders">        
    <view>
        <general>
            <title>Product Order Activity</title>
            <subtitle></subtitle>            
            <group>OrderStatus</group>
            <grouptitle>Order Status: {propertyvalue}</grouptitle>                
        </general>
        <columns>
            <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="-" />                    
        </columns>
    </view>
</report> 

در ساختار بالا دو عدد Node وجود دارد . general و columns. نود general حاوی توصیف گر برای عنوان و دیتای گزارش  می باشد .در زیر نود columns هر نودی ویژگی ها و مقدار آنچه که باید نمایش داده شود را توصیف می کند .در طول فاز تهیه گزارش آیتم های داده و توصیف گر آنها به داخل برنامه منتقل می شوند تا گزارش مد نظر ساخته شود .

متد ReportBuiler.GetColumnInfo برای تبدیل XML به لیستی از List<ColumnInfo> colInfoList و شی groupColumnInfo فراخوانی خواهد شد .این متد در زیر آورده شده است

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;
                break;
            }
            else
            {
                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.");
            }
            else
            {
                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";
                        break;
                    case "currency":
                        colInfo.Alignment = "right";
                        break;
                    case "percent":
                        colInfo.Alignment = "right";
                        break;
                    case "integer":
                        colInfo.Alignment = "right";
                        break;
                    case "datetime":
                        colInfo.Alignment = "center";
                        break;
                    default:
                        colInfo.Alignment = "left";
                        break;
                }
            }
            else
            {
                colInfo.Alignment = align.InnerText.ToLower();
            }

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

تنظیم عرض ستون ها

تمام عرض ستون ها باید مشخص شوند .ابزار PdfDataReport به صورت دستی و یا به صورت اتوماتیک شروع به تنظیم عرض ستونها می کند .هر مقدار مثبتی که به fixed-width داده شود بر روی مقداری که به صورت اتوماتیک داده شده overwrite خواهد شد .در حالتی که طول دیتای ما از طول ثابتی که برای ستون ها در نظر گرفته ایم بیشتر شود به صورت داینامیک طول ستونها تعیین خواهد شد .

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

بیشتر سازنده های pdf از طول اتوماتیک برای این کار استفاده می کنند .برای این کار ابتدا حساب می کنند که کل کاراکتر های دیتاهایی که قرار است نمایش داده شود چند تا است و بعد طبق آن طول را تنظیم کنیم .

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

بعد از آن طول بزرگترین کلمه اندازه گیری می شود .

var wordWidth = 0d;
var headerFontSizeForCalculation = HEADER_FONT_BOLD ? HEADER_FONT_SIZE * BOLD_SIZE_FACTOR : HEADER_FONT_SIZE;
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;
}

سپس طول کل بدنه تعیین خواهد شد

rptTable.SetColumnWidth(columnWidths.ToArray());

انتخاب خودکار سایز صفحه

به صورت خودکار می توانید طول و عرض صفحه را تعیین کنید .اندازه های پیش فرض که در داخل برنامه تعیین شده است به صورت اندازه های 8.5x11, 8.5x14, 11x17, و  12x18 است .که این اندازه ها توسط  PaperSizeList تعیین می شود .

کدهای زیر نحوه انتخاب سایز صفحه را نشان می دهد

//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())
    };
    pageSizeOptionList0.Add(item);
}
//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;
        }
        break;
    }
}
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;
}

زمانی که بر روی لینک Automatic Select Paper Size or Orientation کلیک کنید شکل زیر نمایش داده خواهد شد

صفحه در نمای LandScape نمایش داده می شود .

تراز ستون ها

در تنظیمات پیش فرض PdfFileWriter.PdfTable تنظیمات ستون ها در حالتی که حاشیه از چپ و راست به درستی تعیین شده است قرار دارد .در کدهای زیر نحوه ای که این کار انجام می شود نمایش داده شده است

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

اگر بر روی لینک Justify Display Columns to Page-Wide در صفحه اول کلیک کنید شکل زیر را خواهید دید .

نمایش داده ها و Page Break

ابزار PDF همچنین می تواند برای گروه بندی داده های گزارش هم به کار بیاید .همان طور که گفته شد این ابزار بسیاری از نیاز های ما برای ساخت pdf را بر آورده می کند .متد builder.GetPdfBytes قبل از اینکه داده ای برای گروه بندی به آن ارسال شود آنها را به صورت مرتب شده در می آورد .کد این تابع در زیر نشان داده شده است

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

Groupby کردن داده ها ممکن است در زمان پردازش داده ها برای نمایش به صورت pdf در نظر گرفته نشود .برای اولین بار هیچ Groupby ایی انجام نخواهد شد .زمانی که ستون مورد نظر برای گروه بندی اطلاعات را مشخص کردیم در آن زمان اطلاعات ما به صورت گروه بندی شده نمایش داده خواهد شد.

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

اگر هر نوع داده ای برای معدل گیری و جمع کل گیری در داخل توصیف گر xml مشخص شده باشد این تنظیمات توسط متد ReportBuilder.DrawGroupFooter به فایل pdf ما اعمال خواهد شد .

//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;
    }
    rptTable.DrawRow();
}

زمانی که داده ما شامل مجموع و یا میانگین هم باشد باید عرض ستون ما به اندازه کافی باشد تا هم اطلاعات داده ها را نمایش دهد و هم میانگین و مجموع را .

رفتن به صفحه بعد به صورت خودکار بعد از اینکه آخرین سطر در آخرین جای باقی مانده از صفحه نمایش داده شد صورت می گیرد .وقتی صفحه پر شود و باز هم دیتایی برای نمایش داشته باشیم به صورت خودکار یک صفحه دیگر ساخته شده و ادامه گزارش در آن صفحه ایجاد خواهد شد .

البته این کار با احتساب سربرگ و پاورقی برای هر صفحه صورت می گیرد .متدی که به وسیله آن عمل رفتن به صفحه بعدی صورت می گیرد در زیر آورده شده است

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;
        }
        else
        {
            //For last-group-row scenario.
            if (hasTotal && hasAverage) room = rptTable.RowTopPosition - rptTable.RowHeight * 4;
            else if (hasTotal || hasAverage) room = rptTable.RowTopPosition - rptTable.RowHeight * 3;
        }
    }
    else
    {
        //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...)";
        else
            temp = "(Group records continue on next page...)";

        //Backup style.
        var cellStyle = new PdfTableStyle();
        cellStyle.Copy(rptTable.Cell[0].Style);

        //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;
        rptTable.DrawRow();
        //Set style back
        rptTable.Cell[0].Style.Copy(cellStyle);
        return true;
    }
    return false;
}

زمانی که در صفحه اول و بر روی لینک Product Orders Grouped by Order Status with End-group Page Break کلیک می کنید شکل زیر را خواهید دید .

در همان صفحه اول با کلیک بر روی گزینه Product Orders Grouped by Order Status with End-report Page Break شکل  زیر را خواهید دید


قرار دادن Background  و Border
می توانید برای عناوین ستون ها یا برای سرصفحه و پاورقی خود رنگ پس زمینه قرار دهید .مانند هر نرم افزار دیگری اگر تنظیماتی برای آن انجام ندهید تنظیمات پیش فرض خود نرم افزار اعمال خواهد شد .اما اگر تنظیماتی مد نظر دارید مانند قرار دادن پس زمینه و border برای صفحات خود می توانید این کار را انجام دهید و دیگر تنظیمات پیش فرض اعمال نخواهد شد .

//Group header background color.
Color selColor;
switch (GROUP_HEADER_BK_COLOR)
{
    case "FaintLightYellow":
        selColor = Color.FromArgb(255, 255, 238);
        break;
    case "VeryLightYellow":
        selColor = Color.FromArgb(255, 255, 226);
        break;
    case "LightYellow":
        selColor = Color.FromArgb(255, 255, 214);
        break;
    case "FaintLightGray":
        selColor = Color.FromArgb(230, 230, 230);
        break;
    case "VeryLightGray":
        selColor = Color.FromArgb(220, 220, 220);
        break;
    case "LightGray":
        selColor = Color.FromArgb(207, 210, 210);
        break;
    default: //VeryLightYellow
        selColor = Color.FromArgb(255, 255, 226);
        break;
}
DrawBackgroundColor(rptTable, selColor, rptTable.BorderLeftPos, cell.ClientBottom, rptTable.BorderRightPos - rptTable.BorderLeftPos, rptTable.RowHeight);


در داخل بلاک switch می توانید رنگ دلخواه خود را تعیین کنید .و در داخل AppSetting  با افزودن کلیدی می توانید تنظیمات دلخواه خود را انجام دهید

<!--Available GroupHeaderBackgroudColor settings: "FaintLightYellow", "VeryLightYellow" (default), "LightYellow", "FaintLightGray", "VeryLightGray", "LightGray"-->
<add key="GroupHeaderBackgroudColor" value=""/>


استفاده از ابزار PdfDataReport  در داخل برنامه خود


برای اینکه این ابزار گزارش ساز را در داخل برنامه خود استفاده کنید مراحل زیر را انجام دهید .
فولدر PdfDataReport و PdfFileWriter را به داخل برنامه خود و در ریشه کپی کنید .حال اگر برنامه خود را باز کنید برنامه شما شامل سه عدد project خواهد بود .
یک رفرنسی به PdfDataReport به پروژه خود بدهید .اگر مایل هستید مقداری را اضافه کنید این کارها را در داخل appsetting  انجام دهید .در داخل برنامه خود با صدا زدن متد ReportBuilder.GetPdfBytes می توانید گزارش دلخواه خود را بگیرید.

فایل های ضمیمه
دانلود نسخه ی PDF این مطلب