Dynamic PDFs With Sitecore

I recently had to make dynamic PDFs with Sitecore.

This proved exceedingly difficult. I pulled together resources from a variety of places. The fundamental technology is iTextSharp, an open source tool for converting to PDFs. In this case we will be converting a Razor view to a PDF. The guts of this were reworked from a Sitecore Marketplace tool called PDF Rendering Using iTextSharp. I installed this tool, but you could really build all the pieces from scratch.

Create a PDF Device

  1. Create a new device in Layouts/Devices called Pdfpdfdevice
  2. Set a query string value for the device (Thats how Sitecore knows to use this particular device). I used Pdf=true, but it could really be anything)

Create a PDF Configuration Template

  1. Create a new template Called PdfConfiguration. This will have two fields:
    1. Pdf Styles – This is a tree list that links to a style sheet or sheets. That style sheets should be stored as content in the Media Library. Set the data source to the folder you want in the media library
    2. Use Device – This should be a drop list that points to the devices under layouts. Set the data source to “/sitecore/Layout/Devices”
    3. The Marketplace tool has a third field called Button Image. In their implementation they use this for creating the “Print to PDF” button on a page. I am not using this field at all, so you could leave  it out.

pdfconfig-template

Add Css To Media Library

  1. Add your css file as an object to your Media Library. Wherever makes sense for the organization of your site. Having the css as a media library item is going to make trouble shooting difficult. Also, if you change the styles, you will need to re-upload the file to the media library

Add a PdfConfiguration To Your Site

  1. Add an instance of the PdfConfiguration to your site
  2. I am using only a single instance of this PdfConfiguration for the entire site, so I have added it to my Globals folder. You could have multiple instances, for example if you needed different css files for different Pdfs

pdfconfiginglobals

Create a Pdf Specific Layout

  1. Create a Layout for the Pdf in Sitecore.
  2. Point this layout to your Layout .cshtml file in Visual Studio
  3. My layout .cshtml file is VERY simple
@{
    Layout = null;
}
<div>@Html.Sitecore().Placeholder("main");</div>

Create a View Model For Your Data

  1. Your view model should only contain the data that will be displayed in the PDF
  2. Particularly in this case, the view model should be very simple and straight forward
using System.Collections.Generic;

namespace ProviderSearch
{
    public class PrintableSearchResults
    {
        public PrintableSearchResults()
        {
            Providers = new List();
        }
        public string Heading { get; set; }
        public string Province { get; set; }
        public string City { get; set; }
        public string Specialty { get; set; }
        public string ProviderName { get; set; }
        public string Status { get; set; }
        public bool NotSeenInTwoYears { get; set; }
        public string Disclaimer { get; set; }

        public string Css { get; set; }

        public ICollection Providers { get; set; } 
    }
}

namespace ProviderSearch
{
    public class Provider
    {
        public string Status { get; set; }
        public string Category { get; set; }
        public string Name { get; set; }
        public string Specialties { get; set; }
        public string Address { get; set; }
        public string City { get; set; }
        public string Province { get; set; }
        public string Phone { get; set; }
    }
}

Create a View For Your Pdf

  1. This .cshtml file can be whatever you want to display in your Pdf
    Create a Pdf Specific Rendering.
  2. In my case I was not in control of the design. They wanted tables. I gave them tables.
@model PrintableSearchResults
@{
    ViewBag.Title = "Search Results PDF";
    Layout = null;
}
<div>
    <h1>@Model.Heading</h1>
    <p>@Html.Raw(Model.Disclaimer)</p>
</div>
<main>
    <table>
        <tr>
            <td><strong>Province: </strong>@Model.Province</td>
            <td><strong>City: </strong>@Model.City</td>
            <td><strong>Specialty: </strong>@Model.Specialty</td>
        </tr>
        <tr>
            <td><strong>Status: </strong>@Model.Status</td>
            <td><strong>Provider Name: </strong>@Model.ProviderName</td>
            <td></td>
        </tr>
    </table>
    <table>
        <tr>
            <th>Status</th>
            <th>Category</th>
            <th>Name</th>
            <th>Specialties</th>
            <th class="wide">Address</th>
            <th>City</th>
            <th>Province</th>
            <th>Phone No.</th>
        </tr>
        @{
            foreach (var provider in Model.Providers)
            {
                <tr>
                    <td>@provider.Status</td>
                    <td>@provider.Category</td>
                    <td>@provider.Name</td>
                    <td>@provider.Specialties</td>
                    <td class="wide">@provider.Address</td>
                    <td>@provider.City</td>
                    <td>@provider.Province</td>
                    <td>@provider.Phone</td>
                </tr>
            }
        }
    </table>
</main>

Create A Rendering For Your Pdf

  1. This will be a rendering for the specific rendering you are working on
  2. Use a controller rendering
  3. Point to the controller and action where you will be performing your logic

Add Your Layout and Rendering To Your Page

  1. Go to the presentation details for your page. In this case my search results page
  2. Scroll down to the Pdf Device and click Edit
  3. Add your Pdf Layout
  4. Add your Pdf controller rendering to the layout

pdfpresentationdetails

Create A PDF

Now, down to the hard part. First, we will separate the pdf creation code into its own class which, in a fit of inspiration I called PdfCreator.

    public class PdfCreator
    {
        private IPdfConfiguration PdfConfiguration { get; set; }
        private HttpRequestBase Request { get; set; }
        private ControllerContext Context { get; set; }

        /// <summary>
        /// Create the class responsible for creating a Pdf
        /// </summary>
        /// <param name="pdfConfiguration">The Pdf configuration. A glass mapper interface of an object from Sitecore</param>
        /// <param name="request">The HttpContext. We use HttpContextBase so that it can be mocked for a test</param>
        /// <param name="context">The controller context. Needed for generating the view</param>
        public PdfCreator(IPdfConfiguration pdfConfiguration, HttpRequestBase request, ControllerContext context)
        {
            PdfConfiguration = pdfConfiguration;
            Request = request;
            Context = context;
        }

        /// <summary>
        /// Generate the Pdf
        /// </summary>
        /// <param name="viewName">The name of the view that dispalys the information  for the Pdf</param>
        /// <param name="model">The model that contains the actual information for the view</param>
        /// <param name="fileNameSlug">The pdf will first be stored on disk, using a Guid as a unique file name. Its injected so that we can still know whatg it is in the controller</param>
        /// <param name="pageSize">This is needed by iTextSharp. We injecdt it so that it van be set from the calling class. For example, you use this to set portrait or landscape</param>
        /// <returns></returns>
        public string GetPdf(string viewName, object model, Guid fileNameSlug, Rectangle pageSize)
        {
            try
            {
                // Set the view datas
                var viewData = new ViewDataDictionary { Model = model };
                // Convert the view to an Html string
                var content = ToHtml(viewName, viewData);

                // Get the Css from the media library
                var css = getCssContent();
                string outputFileName = null;

                // Make a stream of the Pdf
                using (var memoryStream = getPdfStream(content, css, pageSize))
                {
                    if (memoryStream != null)
                    {
                        memoryStream.Position = 0;

                        // Set the file name
                        var inputFileName = string.Format("{0}.pdf", fileNameSlug.ToString().ToLower().Replace("-", ""));

                        //Set the default folder for storing pdfs
                       var path = Path.Combine("~/printfiles",
                            inputFileName);

                        // Map the virtual path to an actual disk path
                        outputFileName = HttpContext.Current.Server.MapPath(path);

                        // Write the pdf to disk 
                        using (var fileStream = new FileStream(outputFileName, FileMode.Create))
                        {
                            memoryStream.WriteTo(fileStream);
                        }
                    }
                }
                
                // Return the full path of the pdf on disk
                return outputFileName;
            }
            catch (Exception ex)
            {
                return null;
            }
        }        

        /// <summary>
        /// Convert the view to a string of html
        /// </summary>
        /// <param name="viewToRender">The view to be converted</param>
        /// <param name="viewData">The model for the view</param>
        /// <returns></returns>
        private string ToHtml(string viewToRender, ViewDataDictionary viewData)
        {
            // Find the necessary view
            var result = ViewEngines.Engines.FindView(Context, viewToRender, null);

            // Render the view - That is the Razor and the data
            StringWriter output;
            using (output = new StringWriter())
            {
                var viewContext = new ViewContext(Context, result.View, viewData, Context.Controller.TempData, output);
                result.View.Render(viewContext, output);
                result.ViewEngine.ReleaseView(Context, result.View);
            }
            // Return the rendered view as a string
            return output.ToString();
        }

        // MAke a request and some Xml back
        private static string GenerateResponseXMl(string url)
        {
            string responseString = string.Empty;
            var request = (HttpWebRequest)WebRequest.Create(url);
            request.Method = "GET";
            request.Accept = "*/*";
            var response = (HttpWebResponse)request.GetResponse();
            StreamReader streamReader = null;
            streamReader = new StreamReader(response.GetResponseStream());
            responseString = streamReader.ReadToEnd();

            if (responseString == string.Empty)
            {
                responseString = response.StatusDescription;
            }

            responseString = responseString.Replace("xmlns=\"http://schemas.datacontract.org/2004/07/\"", "");
            responseString = responseString.Replace("xmlns=\"\"", "");

            return responseString;
        }

        /// <summary>
        /// Get the Css
        /// </summary>
        /// <returns></returns>
        private string getCssContent()
        {
            // Get the css media item
            string cssContent = "";
            var printstyles = PdfConfiguration.PDFStyles;
            if (printstyles == null) return cssContent;
            var items = printstyles;

            // if there are no css files, return null
            if (items == null || !items.Any()) return cssContent;
            // Loop through the Css files
            foreach (var t in items)
            {
                //Get the url of the css file
                var path = Sitecore.Resources.Media.MediaManager.GetMediaUrl(t);
                var baseurl = Request.Url.Scheme + "://" + Request.Url.Authority + Request.ApplicationPath.TrimEnd('/');

                // Get the context of the css file rendered as xml
                cssContent = cssContent + GenerateResponseXMl(baseurl + path);
            }
            return cssContent;
        }

        /// <summary>
        /// Generate a stream of the Pdf. This where iTextSharp is doing the work
        /// </summary>
        /// <param name="htmlContent">A string of the Html to be rendered</param>
        /// <param name="cssContent">The css to apply</param>
        /// <param name="pageSize">The iTextSharpo pagesize object</param>
        /// <returns></returns>
        private MemoryStream getPdfStream(string htmlContent, string cssContent, Rectangle pageSize)
        {
            // Create a iTextSharp document
            var document = new iTextSharp.text.Document(pageSize);
            try
            {
                // Create memory stream
                var memoryStream = new MemoryStream();
                // Create the Pdf writer
                var writer = PdfWriter.GetInstance(document, memoryStream);

                //Open the document
                document.Open();
                
                // Set up dictionary for properties
                var interfaceProps = new Dictionary<string, Object>();
                // Create an image handler
                var ih = new ImageHander() { BaseUri = Request.Url.ToString() };

                // Add image handler to properties
                interfaceProps.Add(HTMLWorker.IMG_PROVIDER, ih);

                //Create style resolver
                var cssResolver = new StyleAttrCSSResolver();

                // Create a strewam of the Css
                var cssStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(cssContent));

                //Read the Xml-ified css
                var cssFile = XMLWorkerHelper.GetCSS(cssStream);
                //Add that Css to the resolver
                cssResolver.AddCss(cssFile);

                // Create html pipeline context
                CssAppliers ca = new CssAppliersImpl();
                HtmlPipelineContext hpc = new HtmlPipelineContext(ca);
                hpc.SetTagFactory(Tags.GetHtmlTagProcessorFactory());

                //Create Pdf and css pipelines
                PdfWriterPipeline pdf = new PdfWriterPipeline(document, writer);
                CssResolverPipeline css = new CssResolverPipeline(cssResolver, new HtmlPipeline(hpc, pdf));

                // Create xml workers
                var xmlWorker = new XMLWorker(css, true);
                var xmlParser = new XMLParser(xmlWorker);

                // Create Html and Css stream
                var htmlStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(htmlContent));
                var cssStream1 = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(cssContent));

                //Parse the html into the stream
                using (TextReader stringReader = new StringReader(htmlContent))
                {
                    XMLWorkerHelper.GetInstance().ParseXHtml(writer, document, htmlStream, cssStream1);
                }

                writer.CloseStream = false;
                document.Close();

                //return the stream of the pdf content
                return memoryStream;
            }
            catch (Exception ex)
            {
                document.Close();
                return null;
            }
        }
    }

A lot happening here:

  • It takes the view name and the model data and renders the view and creates a string of the html of that view
  • It gets the Css from the media library
  • Using iTextSharp it converts the Html and the Css into a Pdf stream
  • Because of some Sitecore MVC weirdness which I will explain in a minute, it writes the PDF to disk, giving it a Guid for a name

Create A Controller To Do The Work

OK, last step(s). First, I discovered, and this is why I write the PDF to disk, that when I use a custom stream, in this case the PDF stream, I can’t then inject that into the output stream as a File like I normally would with MVC. Sitecore has already closed the stream. I believe this has to do with the way Sitecore handles the stream, running multiple MVC controllers on a single page, etc… So what I found was that I needed to do this in two MVC steps. First, in my controller action that is linked to my Sitecore controller rendering, I generate the PDF. Then I read the new PDF back from disk and redirect to ANOTHER action in the controller that actually displays the PDF. That action just does one thing, renders a file.

To do this, we need a custom route for this. To redirect to another action in Sitecore, you need to use RedirectToRoute, not RedirectToAction. And, to ensure you are not going to run into conflicts with Sitecore routes, you should use a named route. So, before we get to the controller, here’s how I am adding a custom route.

First create processor to register the your custom routes.

    public class RouteProcessor
    {
        public virtual void Process(PipelineArgs args)
        {
            Register(System.Web.Routing.RouteTable.Routes);
        }

        public static void Register(RouteCollection routes)
        {
            //Create routes for controller
            routes.MapRoute("FileDownload", "my/custom/path/printsearchresults", new { controller = "ProviderSearch", action = "PrintSearchResults" });            
        }
    }

Then I need to inject the processor in the pipeline using a patch config file.

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <initialize>
        <processor type="MyProject.Processors.RouteProcessor, MyProject" />
      </initialize>
    </pipelines>
  </sitecore>
</configuration>

You will notice I am not setting a patch before or after or anything. This will add these routes last. If you are using a route that must supercede something in Sitecore, you will need to be more selective about where you patch this. For this purpose, last is fine.

No, the controller.

        public ActionResult ProviderSearchPrintableResults(string searchTerms)
        {
            var curItem = Sitecore.Context.Item;

            if (curItem == null) return null;

            //Get the page - needed for api path
            var globalsItem = GlobalSettings.GetGlobalSettingsItem<IGlobalSettings>();
            var config = GlobalsSettingsManager.GetPdfConfiguration(globalsItem);
            var item = GlassContext.Cast<IProviderSearchResultsPage>(Sitecore.Context.Item);

            //Perform the search
            object model = GetModel(searchTerms);

            // File name will be a guid
            var guid = Guid.NewGuid();

            // Get the view as a PDF
            var pdfCreator = new PdfCreator(config, Request, ControllerContext);
            var fileName = pdfCreator.GetPdf(
                ViewRenderingConstants.Composites.PhilippineProviderSearch.PrintableResults, model, guid, PageSize.LETTER.Rotate());

            // If no pdf generated return a 404
            if (fileName == null)
                return HttpNotFound();

            // Create a friendly name for the PDF
            var downloadName = string.Format("Philippine-Provider-Search-Results-{0}.pdf", DateTime.Now.ToString("dd_MM_yyyy"));

            return RedirectToRoute("FileDownload", new
            {
                soureFileName = fileName,
                fileName = downloadName
            });
        }

        public ActionResult PrintSearchResults(string soureFileName, string fileName)
        {
            //Return the PDF
            return File(soureFileName, "application/pdf");
        }

        public SearchResults GetModel(string searchTerms)
        {
            // Peform a search here
            return searchResults;
        }

A couple things to note here. First, I get my globals item. This method just looks in the tree for my Globals folder and gets it. Then I have a GlobalSettingsManager to abstract away the specific settings in it. I get my PdfConfiguration object. It just loops through the children of the Global Settings item looking for an object of the correct template.

Then it get the current item, which is a search results page. Then I get the model. In this case, the model is some search results. So, the controller action takes my search terms as parameters and just performs a search. Thats the GetModel method. Its calling an external API. I wont cloud this post with the mechanics of that. Suffice it to say, I need the model that the view is looking for.

I create a Guid that will be the file name on disk. I create my PDF using the PdfCreator. I create a friendly name for the PDF, the I redirect to my route, which sends me to the PrintSearchResults action, which just creates the PDF.

Create a Button To Generate The PDF

The last step is to create a link on the search results page that switches the device, so I see the PDF. I did this by adding the query string I want, including the device switch (“pdf=true”) that we set when we set up the device. So, I add this to the view bag on the search results page. I also made sure that the search results page has its own Url as a field value. So here is the ActionResult that returns my search results:

        public ActionResult SearchResults(string searchTerms)
        {
            ViewBag.QueryString =
                string.Format(
                    "?searchTerms={0}&pdf=true",searchTerms);
            var model = GlassContext.GetCurrentItem<IProviderSearchResultsPage>();
            return PartialView("SearchResultsView", model);
        }

This way, in the razor of the search results, I can create a link that will switch my device:

 <a href="@(Model.Url)@ViewBag.QueryString" target="_blank">

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s