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>
<!---->
<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();
$(html)
.hide()
.prependTo('#panels')
.slideDown()
.attr('id', id);
$(".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)
{
using (FileStream file = new FileStream(
Server.MapPath(string.Format("~/Upload/{0}.pdf", d)),
FileMode.Open, FileAccess.Read))
{
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);
using (Bitmap bitmap = new Bitmap(bmpWidth, bmpHeight))
using (Graphics graphics = Graphics.FromImage(bitmap))
{
graphics.ScaleTransform(scale, scale);
page.Draw(graphics);
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:
- CLIENT: The click handler of the download button enumerates the page thumbnails and creates an array of doc-guid/page-index tuples.
- CLIENT: The JSON representation of this array is POSTed to the
Download
action on the server.
- 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.
- CLIENT: Next, the client makes a GET request to another
Download
action and passes the same guid.
- 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 () {
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') });
});
$.ajax({
type: "POST",
url: '@Url.Action("Download", "Home")',
data: JSON.stringify(pages),
contentType: "application/json",
dataType: "text",
success: function (data) {
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)
{
string json = new JavaScriptSerializer().Serialize(pages);
string id = Guid.NewGuid().ToString();
string path = Server.MapPath(string.Format("~/Download/{0}.json", id));
System.IO.File.WriteAllText(path, json);
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();
}
});
}