Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Single Page ASP.NET Application for Splitting and Stitching PDF Documents

18 Sep 2015 1  
This is a single page ASP.NET application for splitting and stitching PDF documents.

This article is in the Product Showcase section for our sponsors at CodeProject. These articles are intended to provide you with information on products and services that we consider useful and of value to developers.

Introduction

This article shows a single page ASP.NET application that allows the user to:

  • Upload PDF documents
  • Drag and drop pages between PDF document and assemble new documents
  • Download the modifed PDF documents

The following technologies are used:

How the Application Works

Before going into the implementation details, let's take a look at the application itself:

Now that we have seen what the application does, let's take a detailed look at the code. The full project can be downloaded from here. In the text, I will include code snippets and refer to the location of the source file in the project. Sometimes, I will simplify the snippet for readability.

Design overview

The application is implemented as a single controller (/Controllers/HomeController.cs), a home view (/Views/Home/Index.cshtml) and a partial view (/Views/Home/_Panel.cshtml). Roughly, the application implements the following functionality:

  • Uploading a PDF and rendering the PDF as a list of pages
  • Dragging and dropping pages between documents
  • Downloading a new PDF document

The following diagram and pseudo-code shows the steps involved in uploading a PDF document and rendering the page thumbnails.

After uploading, each page is rendered as an img element that encodes its origin using data-guid and data-index attributes. When pages are dropped to another panel, this panel includes all information required to ask the server to create a new PDF document from the guid/index pairs. This is shown in the following diagram.

In the remainder of the article I will discuss all parts in detail by taking a look at the server and the client code.

Upload

For uploading, we use the jQuery File Upload Plugin. The HTML for the upload button can be found in /Views/Home/Index.cshtml and looks like this:

<span class="btn btn-success fileinput-button">
   <i class="glyphicon glyphicon-plus"></i>
   <span>Upload PDF...</span>
   <!-- The file input field used as target for the file upload widget -->
   <input id="fileupload" type="file" name="files[]" multiple>
</span>

Here is the client-side JavaScript event handler of the upload button:

$('#fileupload').fileupload({
   url: '@Url.Action("Upload", "Home")',
   dataType: 'html',
   sequentialUploads: true,
   done: function (e, data) {
      addPanel(data.result);
   }
});

The url argument points to the Upload action of the Home controller which can be found in /Controllers/HomeController.cs. This action method is quite simple. It just saves the uploaded PDF in an upload folder (using a new guid for the file name) and returns a partial view that displays the panel with the page thumbnails:

[HttpPost]
public ActionResult Upload()
{
   HttpPostedFileBase file = Request.Files[0];
   Guid guid = Guid.NewGuid();
   kit.Document pdf = new kit.Document(file.InputStream);
   file.SaveAs(Server.MapPath(string.Format("~/Upload/{0}.pdf", guid)));
   
   return PartialView("_Panel", new PanelModel() { 
      DocumentGuid = guid.ToString(), 
      Document = pdf 
   });
}

The HTML of the returned view (discussed next) is available on the client side through the done callback of the fileupload function. This function passes it to the helper function addPanel that looks like this:

function addPanel(html) {
  var id = guid();
  // prepend html to div with id 'panels' (with a slide effect)
  $(html)
    .hide()
    .prependTo('#panels')
    .slideDown()
    .attr('id', id);
  // make the list of pages draggable
  $(".pageslist").sortable({
    connectWith: ".pageslist",
    stop: function (event, ui) {
      updatePanels();
    }
  }).disableSelection();
  
  updatePanels();
  updateToolbar();
}

It first prepends the HTML to the div with id 'panels' (with a slide effect). Next, it makes the list of pages draggable (discussed later).

Display the PDF Document Using a Partial View

The Upload method returns partial view _Panel (see /Views/Home/Panel.cshtml) which is rendered from PanelModel. The Panel model is nothing more than a guid/document tuple. The document is an instance of the PDFKit.NET Document class. This third-party library is used to programmatically combine pages from documents to create a new document. It is important to note that the Document instance only lives during the rendering of the partial view. It is discarded thereafter. The filename (the guid) is stored as an attribute in the HTML so that the PDF document can be loaded when needed. There is no in-memory server state whatsoever.

Here is the code of partial view _Panel:

@model PanelModel
<div class="panel">
  <div class="panelheader">
    <a href="#" class="closepanel pull-right"><i class=" fa fa-close"></i></a>
    <button type="submit" class="download btn btn-xs btn-primary">Download</button>
  </div>
  <div class="pagesarea">
    <ul class="pageslist">
    @for (int i = 0; Model.Document != null && i < Model.Document.Pages.Count; i++)
    {
      // enumerate the pages of the document
      Page page = Model.Document.Pages[i];
      // calculate the width of the thumbnail (18 dpi)
      int width = (int)((PanelModel.THUMBRES / 72f) * page.Width);
      int height = (int)((PanelModel.THUMBRES / 72f) * page.Height);
      // the src point to the Thumbnail action
      // attributes data-guid and data-index store the 
      // page origin for later retrieval
      <li class="ui-state-default">
        <img class="pagethumbnail" src="/Home/Thumbnail?d=@Model.DocumentGuid&i=@i" 
          width="@width" height="@height" data-guid="@Model.DocumentGuid" data-index="@i" />
      </li>
    }
    </ul>
  </div>
</div>

The PDF panel uses the bootstap classes panel and panelheader. The panel header has a download button (discussed later) and a close button. I refer to the full source code for the close button. The download button is discussed later.

The body of the panel is an unordered list. The list items are the thumbnail images for the different pages. The following CSS styles the unordered list so there is no bullet and the items run from left to right:

ul.pageslist { 
    list-style-type: none; 
    float: left;
    margin: 0; 
    padding: 0; 
    width: 100%;
    min-height: 100px;
}

The object model of PDFKit.NET is used to enumerate the pages of the document. The interesting parts are the src, data-guid and data-index attributes of the img element. The src attribute points to a Thumbnail action method that dynamically renders the page thunbmnail. The URL includes the guid of the document and the page index. The Thumnail action is dicussed next.

The data-guid and data-index attributes of the img element make the page (thumbnail) self-describing. When the page is dragged (discussed later) to another panel, the data-guid and data-index are included and continue to identify the original document and page. This way, all state is maintained in the browser. How this state is used to download the new document is explained shorty.

Render Page Thumbnails

The src attribute of the page thumbnail image points to the following action method (see /Controllers/HomeController):

public ActionResult Thumbnail(string d, int i)
{
  // open the file in the upload folder identified by d
  using (FileStream file = new FileStream(
    Server.MapPath(string.Format("~/Upload/{0}.pdf", d)), 
    FileMode.Open, FileAccess.Read))
  {
    // contruct a PDFRasterizer.NET document
    // and get the page at index i
    Document pdf = new Document(file);
    Page page = pdf.Pages[i];
    float resolution = PanelModel.THUMBRES;
    float scale = resolution / 72f;
    int bmpWidth = (int)(scale * page.Width);
    int bmpHeight = (int)(scale * page.Height);
    // render the page to a 18 DPI PNG bitmap
    using (Bitmap bitmap = new Bitmap(bmpWidth, bmpHeight))
    using (Graphics graphics = Graphics.FromImage(bitmap))
    {
      graphics.ScaleTransform(scale, scale);
      page.Draw(graphics);
      // save the bitmap to the HTTP response
      bitmap.Save(Response.OutputStream, ImageFormat.Png);
    }
  }
  return null;
}

Looking at this code, arguments d and i are respectively, the file name and page index of the page to render. PDFRasterizer.NET is used to render this page to a GDI+ bitmap. This bitmap is then saved to the output stream as a png. The browser will display the thumbnail.

Drag and Drop Pages

For client side drag and drop, we use the sortable interaction of jQuery UI. The list of pages per panel is made sortable inside the addPanel function that we saw earlier, using the following code:

function addPanel(data) {
  ...
  $(".pageslist").sortable({
    connectWith: ".pageslist",
    stop: function (event, ui) {
      updatePanels();
    }
  }).disableSelection();
  ...
}

When a page is dragged from one panel to another, no call is made to the server. All information required to download the new PDF document is stored client-side by means of the data-guid and data-index attributes of the thumbnail img elements.

Download New PDF Document

Downloading the new document is the most interesting part. Before looking at the code, let me outline the 5 steps involved:

  1. CLIENT: The click handler of the download button enumerates the page thumbnails and creates an array of doc-guid/page-index tuples.
  2. CLIENT: The JSON representation of this array is POSTed to the Download action on the server.
  3. SERVER: Because a POST request cannot trigger the browser to open a file, it temporarily stores the JSON as a file and returns a guid identifying the JSON file.
  4. CLIENT: Next, the client makes a GET request to another Download action and passes the same guid.
  5. SERVER: The server reads the JSON from the temporary file, deletes the file, creates the PDF and writes it to HTTP response.

The click handler of the download button looks like this. Note that we use delegated events because download buttons are created dynamically. This handler includes all 3 client-side steps described above.

$(document).on('click', 'button.download', function () {
  // enumerate all the pages and create
  // an array of document guid/page index tuples
  pages = new Array();
  var id = $(this).parents('div.panel').attr('id');
  $('#' + id).find('img.pagethumbnail').each(function () {
    pages.push({ "Guid": $(this).attr('data-guid'), "Index": $(this).attr('data-index') });
  });
  
  // POST array of tuples to download action
  $.ajax({
    type: "POST",
    url: '@Url.Action("Download", "Home")',
    data: JSON.stringify(pages),
    contentType: "application/json",
    dataType: "text",
    success: function (data) {
      // request the PDF
      var url = '@Url.Action("Download", "Home")' + '?id=' + data;
      window.location = url;
    }
  });
});

The server part consists of two action methods: One stores the JSON, the other creates and returns the PDF. This technique and alternatives are also discussed on StackOverflow. Here is the POST action:

[HttpPost]
public ActionResult Download(PanelPage[] pages)
{
  // create a JSON string from the PagePanel array
  string json = new JavaScriptSerializer().Serialize(pages);
  // save the JSON to ~/download/<newguid>.json
  string id = Guid.NewGuid().ToString();
  string path = Server.MapPath(string.Format("~/Download/{0}.json", id));
  System.IO.File.WriteAllText(path, json);
  // return the guid that identifies the JSON file
  return Content(id);
}
public class PanelPage
{
  public string Guid { get; set; }
  public int Index { get; set; }
}

Show/Hide Download Button

The download button at the top of a panel is only shown if the panel has atleast one page, otherwise there is nothing to download. This is taken care of by the updatePanels function which is called whenever a new panel is added or when a page is dragged from one panel to another:

function updatePanels() {
  $('.panel').each(function () {
    if ($(this).find('.pagethumbnail').size() == 0) {
      $(this).find('button.download').hide();
    }
    else {
      $(this).find('button.download').show();
    }
  });
}

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here