Create ASP.NET PDF Editor with Barcode Features

The PDF (Portable Document Format) has become one of the widely used formats in the world of digital documents. Various industries are using PDF format to generate their reports, invoices, bills, and other types of business documents. The barcode, on the other hand, has become an essential part of the business that contains information in a machine-readable form. These days, you may even find a barcode on your bills and invoices. In this article, I’ll present a use case where you need to create a PDF file and embed a barcode on it, for example, when generating an invoice. For the demonstration, I’ll create an ASP.NET PDF Editor using which you will be able to generate the PDF files and barcodes using C# within ASP.NET Core web application.

This ASP.NET web application will have the following features:

  • A WYSIWYG editor to write the content of the PDF document.
  • Option to generate a barcode based on provided text.
  • Option to set the desired symbology of the barcode.

Prerequisites for Generating PDFs having Barcode in ASP.NET

The following are the tools and APIs that you would need to create the ASP.NET PDF Editor having barcode features.

Create ASP.NET PDF Editor with Barcode Features

Let’s begin our journey and see how to create our ASP.NET PDF Editor that will let to generate PDF and embed barcode with a single click.

  • Create a new ASP.NET Core Web Application in Visual Studio.
Create ASP.NET Core web application
  • Select Web Application (Model-View-Controller) from the templates.
ASP.NET MVC
  • Install the packages of Aspose.PDF, Aspose.BarCode and CKEditor.
Add Aspose .NET PDF and Barcode APIs
  • Download the CKEditor’s package, extract it, and copy/paste the folder in wwwroot directory. You can also integrate any of your favorite WYSIWYG HTML editors based on your requirement.
  • Add the following script in Views/Home/index.cshtml view.
@{
    ViewData["Title"] = "PDF Creator";
}
<script src="~/ckeditor/ckeditor.js"></script>
<br />
<form method="post">
    <div class="row">
        <div class="col-md-12">
            <textarea name="editor1" id="editor1" rows="80" cols="80">
                Start creating your PDF document.
            </textarea>
            <br />
            <script>
                // Replace the <textarea id="editor1"> with a CKEditor
                // instance, using default configuration.
                CKEDITOR.replace('editor1');
            </script>
        </div>
        <hr />
    </div>
    <div class="row">
        <div class="col-md-12">
            <h3>Create a Barcode</h3>
        </div>
    </div>
    <hr />
    <div class="row">
        <div class="col-md-9 form-horizontal" align="center">
            <div class="form-group">
                <label class="control-label col-sm-2" for="CodeText">Code Text</label>
                <div class="col-sm-10">
                    <input class="form-control" type="text" name="codeText" id="codeText" placeholder="Code text" />
                </div>
            </div>
            <div class="form-group">
                <label class="control-label col-sm-2" for="barcodeType">Symbology</label>
                <div class="col-sm-10">
                    <select name="barcodeType" class="form-control">
                        <option value="Code128">Code128</option>
                        <option value="Code11">Code11</option>
                        <option value="QR">QR</option>
                        <option value="Pdf417">Pdf417</option>
                        <option value="Datamatrix">Datamatrix</option>
                    </select>
                </div>
            </div>
        </div>
    </div>
    <hr />
    <div class="row">
        <div class="col-md-12" align="center">
            <input type="submit" class="btn btn-lg btn-success" value="Generate PDF" />
        </div>
    </div>
</form>
  • Add the following methods in Controllers/HomeController.cs.
[HttpPost]
public FileResult Index(string editor1, string codeText, string barcodeType)
{
	// generate a barcode
	string barcodeImagePath = Path.Combine("wwwroot/barcodes/", Guid.NewGuid() + ".png");
	SymbologyEncodeType type = GetBarcodeSymbology(barcodeType);
	BarcodeGenerator generator = new BarcodeGenerator(type, codeText);
	generator.Parameters.BackColor = System.Drawing.Color.Transparent;
	// set resolution of the barcode image
	generator.Parameters.Resolution = 200;
	// generate barcode
	generator.Save(barcodeImagePath, BarCodeImageFormat.Png);

	// create a unique file name for PDF
	string fileName = Guid.NewGuid() + ".pdf";
	// convert HTML text to stream
	byte[] byteArray = Encoding.UTF8.GetBytes(editor1);
	// generate PDF from the HTML
	MemoryStream stream = new MemoryStream(byteArray);
	HtmlLoadOptions options = new HtmlLoadOptions();
	Document pdfDocument = new Document(stream, options);

	// add barcode image to the generated PDF 
	pdfDocument = InsertImage(pdfDocument, barcodeImagePath);

	// create memory stream for the PDF file
	Stream outputStream = new MemoryStream();
	// save PDF to output stream
	pdfDocument.Save(outputStream);

	// return generated PDF file
	return File(outputStream, System.Net.Mime.MediaTypeNames.Application.Pdf, fileName);
}
private SymbologyEncodeType GetBarcodeSymbology(string symbology)
{
	if (symbology.ToLower() == "qr")
		return EncodeTypes.QR;
	else if (symbology.ToLower() == "code128")
		return EncodeTypes.Code128;
	else if (symbology.ToLower() == "code11")
		return EncodeTypes.Code11;
	else if (symbology.ToLower() == "pdf417")
		return EncodeTypes.Pdf417;
	else if (symbology.ToLower() == "datamatrix")
		return EncodeTypes.DataMatrix;
	else
		return EncodeTypes.Code128; // default barcode type
}
private Document InsertImage(Document document, string barcodeImagePath)
{
	// get page from Pages collection of PDF file
	Aspose.Pdf.Page page = document.Pages[1];
	// create an image instance
	Aspose.Pdf.Image img = new Aspose.Pdf.Image();
	img.IsInLineParagraph = true;
	// set Image Width and Height in Points
	img.FixWidth = 100;
	img.FixHeight = 100;
	img.HorizontalAlignment = HorizontalAlignment.Right;
	img.VerticalAlignment = VerticalAlignment.Top;
	// set image type as SVG
	img.FileType = Aspose.Pdf.ImageFileType.Unknown;
	// path for source barcode image file
	img.File = barcodeImagePath;
	page.Paragraphs.Add(img);
	// return updated PDF document
	return document;
}
  • Build the application and run it in your favorite browser.
ASP.NET PDF Editor with Barcode Features

Generating a PDF with ASP.NET PDF Editor

The following are the steps as well as the demonstration of how to generate a PDF file and embed a barcode with a single click.

  • Write or copy/paste the content of the PDF document in the editor area.
  • Set the code text to generate the barcode.
  • Select your desired barcode symbology (see all supported barcode symbologies).
  • Click the Generate PDF button to create the PDF having a barcode on it.

Download Source Code

You can download the complete source code of this ASP.NET PDF Editor from here.

Try Aspose APIs for Free

Get your temporary license and try our APIs for free for one month.

See Also

Posted in Conholdate.Total Product Family | Tagged , , , | Leave a comment

Build your own Google Docs like App (Part -II)

In the first blog post, we created a Google Docs like web app that has the following features:

  • Rich Text Editing (change text font, size, colour, style (boldface, italic), alignment etc.).
  • Real-time collaborative editing of the same document. Multiple users can access the document at the same time and modify it.
  • Upload content of an existing Word Document into an editor.

In this blog post, we will extend our web-application to include the following features:

  • Download content of the editor as MS Word, PDF, TXT or HTML document.
  • Share the URL with friends so that they can edit the document at the same time.

The final product will look as follows:

Google Docs like App Interface

Download Content of the Editor as Microsoft Word Document

First, we add an <input> element of type submit to our form to show the “Download Document” button on the front-end. We use asp-page-handler attribute to specify the handler of the button. Our form element now looks as follows:

<form method="post" enctype="multipart/form-data" id="uploadForm">
    <input asp-for="UploadedDocument" />
    
    <input type="submit" value="Upload Document" class="btn btn-primary" asp-page-handler="UploadDocument" />
    <input type="submit" value="Download Document" class="btn btn-primary" asp-page-handler="DownloadDocument" />

    <input asp-for="DocumentContent" type="hidden" />
</form>

You may have noticed that we have added another <input> element of type hidden. This element is bound to DocumentContent property in Index.cshtml.cs. We will use this property to store the content of the editor as an HTML string.

[BindProperty]
public string DocumentContent { get; set; }

Firepad provides another event synced for listening. It is fired when our local client edits the document and when those edits have been successfully written to Firebase. We attach a callback to this event type in order to set the value of DocumentContent property to firepad.getHtml(). Please add the following code in the init() function in Index.cshtml.

firepad.on('synced', function (isSynced) {
    // isSynced will be false immediately after the user edits 
    // the pad, and true when their edit has been saved to Firebase.
    if (isSynced) {
        document.getElementById("DocumentContent").value = firepad.getHtml();
    }
});

Now we implement the functionality of OnPostDownloadDocument() handler. We use GroupDocs.Editor library to save contents of the editor as Microsoft Word document, which are stored in DocumentContent property. The handler returns the Word document as a response to the user.

public FileResult OnPostDownloadDocument()
{
    // Editor object is referencing to the document initially uploaded for editing.
    WordProcessingLoadOptions loadOptions = new WordProcessingLoadOptions();
    Editor editor = new Editor(UploadedDocumentPath, delegate { return loadOptions; });
    
    // <html>, <head> and <body> tags are missing in the HTML string stored in DocumentContent, so we are adding them manually.
    string completeHTML = "<!DOCTYPE html><html><head><title></title></head><body>" + DocumentContent + "</body></html>";
    EditableDocument document = EditableDocument.FromMarkup(completeHTML, null);
    
    // Path to the output document        
    var projectRootPath = Path.Combine(_hostingEnvironment.ContentRootPath, "DownloadedDocuments");
    var outputPath = Path.Combine(projectRootPath, Path.GetFileName(UploadedDocumentPath));
    
    // Save the Word Document at the outputPath 
    WordProcessingSaveOptions saveOptions = new WordProcessingSaveOptions(WordProcessingFormats.Docx);
    editor.Save(document, outputPath, saveOptions);
    
    // Return the Word Document as response to the User
    var bytes = System.IO.File.ReadAllBytes(outputPath);        
    return new FileContentResult(bytes, new MediaTypeHeaderValue("application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
    {
        FileDownloadName = Path.GetFileName(UploadedDocumentPath)
    };
}

UploadedDocumentPath variable is defined as a volatile string to keep the value of the uploaded document path between sessions.

static volatile string UploadedDocumentPath;

public void OnPostUploadDocument()
{
    var projectRootPath = Path.Combine(_hostingEnvironment.ContentRootPath, "UploadedDocuments");
    var filePath = Path.Combine(projectRootPath, UploadedDocument.FileName);
    UploadedDocument.CopyTo(new FileStream(filePath, FileMode.Create));

    // Retain the path of uploaded document between sessions.
    UploadedDocumentPath = filePath;

    ShowDocumentContentInTextEditor();
}

Please check Save Document and Create EditableDocument from file or markup articles to learn more about GroupDocs.Editor classes.

Run the project and test the following use case:

  1. Upload content of an existing Word document into an editor by clicking on the Upload Document button.
  2. Make desired changes in the content.
  3. Click on the Download Document button to download the updated content as a Word Document.

Download Content of the Editor as PDF Document

We need to make a few modifications in OnPostDownloadDocument() handler to return PDF document as a response. We use PdfSaveOptions instead of WordProcessingSaveOptions and use application/pdf as a MIME type.

public FileResult OnPostDownloadDocument()
{
    WordProcessingLoadOptions loadOptions = new WordProcessingLoadOptions();
    Editor editor = new Editor(UploadedDocumentPath, delegate { return loadOptions; });

    string completeHTML = "<!DOCTYPE html><html><head><title></title></head><body>" + DocumentContent + "</body></html>";
    EditableDocument document = EditableDocument.FromMarkup(completeHTML, null);

    var projectRootPath = Path.Combine(_hostingEnvironment.ContentRootPath, "DownloadedDocuments");
    var outputPath = Path.Combine(projectRootPath, Path.GetFileNameWithoutExtension(UploadedDocumentPath) + ".pdf");

    PdfSaveOptions saveOptions = new PdfSaveOptions();
    editor.Save(document, outputPath, saveOptions);

    var bytes = System.IO.File.ReadAllBytes(outputPath);
    return new FileContentResult(bytes, new MediaTypeHeaderValue("application/pdf"))
    {
        FileDownloadName = Path.GetFileNameWithoutExtension(UploadedDocumentPath) + ".pdf"
    };
}

Download Content of the Editor as Plain Text Document

In order to return a plain text document (.txt) as a response, we use TextSaveOptions class and use text/plain as a MIME type.

public FileResult OnPostDownloadDocument()
{
    WordProcessingLoadOptions loadOptions = new WordProcessingLoadOptions();
    Editor editor = new Editor(UploadedDocumentPath, delegate { return loadOptions; });

    string completeHTML = "<!DOCTYPE html><html><head><title></title></head><body>" + DocumentContent + "</body></html>";
    EditableDocument document = EditableDocument.FromMarkup(completeHTML, null);

    var projectRootPath = Path.Combine(_hostingEnvironment.ContentRootPath, "DownloadedDocuments");
    var outputPath = Path.Combine(projectRootPath, Path.GetFileNameWithoutExtension(UploadedDocumentPath) + ".txt");

    TextSaveOptions saveOptions = new TextSaveOptions();
    editor.Save(document, outputPath, saveOptions);

    var bytes = System.IO.File.ReadAllBytes(outputPath);
    return new FileContentResult(bytes, new MediaTypeHeaderValue("text/plain"))
    {
        FileDownloadName = Path.GetFileNameWithoutExtension(UploadedDocumentPath) + ".txt"
    };
}

Share URL of an Editor with friends

We should provide users with a convenient way to copy the URL of an editor and share it with friends. To do this, add an <input> element of type text in Index.cshtml.

<div>
    <strong>
        <label for="shareURL">Edit with Friends: </label>
    </strong>
    <input type="text" name="shareURL" id="shareURL" size="50">
</div>

Add the above <div> element before <div id=”userlist”> tag. We set value of this text input field to URL of an editor by using the following line of code. Add this code in init() function in Index.cshtml.

document.getElementById("shareURL").value = window.location.origin + window.location.pathname + window.location.hash;

We will make minor changes to our CSS to ensure that the text input field is shown properly. Set top position of firepad and userlist to 100px, and add a left margin to the text input field.

#userlist {
    position: absolute;
    left: 0;
    top: 100px;
    bottom: 0;
    height: auto;
    width: 175px;
}

#firepad {
    position: absolute;
    left: 175px;
    top: 100px;
    bottom: 0;
    right: 0;
    height: auto;
}

#uploadForm {
    margin: 16px 2px;
}

#shareURL {
    margin-left: 123px;
}

Run the project, you should see a text field that lets you copy the URL of the editor. Share it with your friends and edit the document simultaneously with them.

Complete source code of the project is available on GitHub.

See Also

Posted in Conholdate.Total Product Family | Tagged , , , | Leave a comment

Build your own Google Docs like App

Several customers have approached us and asked how they can create a Google Docs like web app using our APIs. Google Docs is a word processor that allows users to create and edit files online while collaborating with other users in real-time.

This blog post explains how easy it is to create a lite version of Google Docs that has the following features:

  • Rich Text Editing (change text font, size, colour, style (boldface, italic), alignment etc.).
  • Real-time collaborative editing of the same document. Multiple users can access the document at the same time and modify it.
  • Upload content of an existing Word Document into an editor.
  • Save text in the editor as MS Word, PDF, TXT or HTML document.

Our end product will look as follows:

Google Docs like App Interface

Tools and Technologies – Create Google Docs like App

We will develop the Google Docs like web app in ASP.NET Core and use the following two libraries:

  • Firepad is an open-source, collaborative text editor. It uses the Firebase Realtime Database as a backend so it requires no server-side code and can be added to any web app simply by including the JavaScript files.
  • GroupDocs.Editor for .NET gives us an ability to edit most popular document formats using any WYSIWYG editor without any additional applications. We will load document via GroupDocs.Editor into Firepad, edit document in a way we want and save it back to original document format.

I have used Visual Studio for Mac as an IDE. However, you can download the free community edition of Visual Studio, depending upon your platform, from here. Let’s start.

Create a new ASP.NET Core Web Application project and name the project “GoogleDocsLite”.

Create a new ASP.NET Core Web App

Run the application to ensure everything is set up properly.

Integrate Firepad

We can add Firepad to our web app by including the following JavaScript files in the <head> section of _Layout.cshtml.

<!-- Firebase -->
<script src="https://www.gstatic.com/firebasejs/7.13.2/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/7.13.2/firebase-auth.js"></script>
<script src="https://www.gstatic.com/firebasejs/7.13.2/firebase-database.js"></script>

<!-- CodeMirror -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.17.0/codemirror.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.17.0/codemirror.css" />

<!-- Firepad -->
<link rel="stylesheet" href="https://firepad.io/releases/v1.5.9/firepad.css" />
<script src="https://firepad.io/releases/v1.5.9/firepad.min.js"></script>

<!-- userlist -->
<script src="~/js/firepad-userlist.js"></script>
<link rel="stylesheet" href="~/css/firepad-userlist.css" />

To create a Firepad, we will initialize Firebase, CodeMirror and then Firepad. Add the following script and HTML code in Index.cshtml.

<script>
    function init() {
        // Initialize Firebase.
        // TODO: replace with your Firebase project configuration.
        var config = {
            apiKey: '',
            authDomain: "",
            databaseURL: ""
        };
        firebase.initializeApp(config);
        
        // Get Firebase Database reference.
        var firepadRef = getExampleRef();
        
        // Create CodeMirror (with lineWrapping on).
        var codeMirror = CodeMirror(document.getElementById('firepad'), { lineWrapping: true });

        // Create a random ID to use as our user ID (we must give this to firepad and FirepadUserList).
        var userId = Math.floor(Math.random() * 9999999999).toString();

        // Create Firepad (with rich text features and our desired userId).
        var firepad = Firepad.fromCodeMirror(firepadRef, codeMirror,
                    { richTextToolbar: true, richTextShortcuts: true, userId: userId });

        // Create FirepadUserList (with our desired userId).
        var firepadUserList = FirepadUserList.fromDiv(firepadRef.child('users'),
        document.getElementById('userlist'), userId);
    }
    
    // Helper to get hash from end of URL or generate a random one.
    function getExampleRef() {
        var ref = firebase.database().ref();
        var hash = window.location.hash.replace(/#/g, '');
        if (hash) {
            ref = ref.child(hash);
        } else {
            ref = ref.push(); // generate unique location.
            window.location = window.location + '#' + ref.key; // add it as a hash to the URL.
        }
        if (typeof console !== 'undefined') {
            console.log('Firebase data: ', ref.toString());
        }
        return ref;
    }
</script>

<div id="userlist"></div>
<div id="firepad"></div>

Please replace the contents of config with your own Firebase project’s config.

We want the above script to execute once a web page has completely loaded all content (script files, CSS files etc.). So, call init() function from the onLoad event attribute of <body> element in _Layout.cshtml.

<body onload="init()">

Your <body> element should look as follows. If it contains unnecessary tags like <header>, <footer>, please remove them.

Body Element

If you run the project, you will notice that firepad and userlist are not aligned properly. Please use the following CSS code to adjust the size/position of the firepad and userlist. You may add the following code within <head> element of _Layout.cshtml.

<style>
    html {
        height: 100%;
    }

    body {
        margin: 0;
        height: 100%;
    }

    /* We make the user list 175px and firepad fill the rest of the page. */
    #userlist {
        position: absolute;
        left: 0;
        top: 50px;
        bottom: 0;
        height: auto;
        width: 175px;
    }

    #firepad {
        position: absolute;
        left: 175px;
        top: 50px;
        bottom: 0;
        right: 0;
        height: auto;
    }
</style>

Firepad has been successfully set up.

Upload Content of an Existing Word Document into an Editor

Now we want to give our users a way to upload content of an exiting Word Document in the text editor. On the frontend, we add an <input> element of type file that lets a user select a Word Document from their local machine. On the backend, we use GroupDocs.Editor library to retrieve the content of a Word Document as HTML string. Finally, we use Firepad’s setHtml() method to show content in the text editor.

Add the following <form> element in Index.cshtml file before <div id=”userlist”> tag.

<form method="post" enctype="multipart/form-data" id="uploadForm">
    <input asp-for="UploadedDocument" />
    <input type="submit" value="Upload Document" class="btn btn-primary" asp-page-handler="UploadDocument" />
</form>

In Index.cshtml.cs file, define a corresponding property.

[BindProperty]
public IFormFile UploadedDocument { get;  set; }

Run the project and click on the Choose file button. Select a Word Document that you want to upload and click on the Upload Document button. Nothing will happen because we have not yet defined the handler in Index.cshtml.cs. Before we do this, let’s first add GroupDocs.Editor library in our project.

Integrate GroupDocs.Editor

GroupDocs.Editor is available as a NuGet package so we can easily add it to our project. Right-click on the project and select Manage NuGet Packages option. The Manage NuGet Packages window will open, select the Browse tab, and enter GroupDocs.Editor in the search field. GroupDocs.Editor should appear as a first result, select it and then click on Add Package button.

Add GroupDocs.Editor via NuGet Package Manager

When the package is successfully added, it will appear under NuGet subfolder in the Dependencies folder.

Form Data Handling

Now we write a handler (OnPostUploadDocument() method) that will be called when a user clicks on Upload Document button. UploadedDocument object (of type IFormFile) contains content of the uploaded document. First, we save the document on the server then use GroupDocs.Editor library to get its content as HTML string. Please add the following code in Index.cshtml.cs file.

private readonly IWebHostEnvironment _hostingEnvironment;

public string DocumentContent { get; set; }

public IndexModel(IWebHostEnvironment hostingEnvironment)
{
    _hostingEnvironment = hostingEnvironment;
}

public void OnPostUploadDocument()
{
    var projectRootPath = Path.Combine(_hostingEnvironment.ContentRootPath, "UploadedDocuments");
    var filePath = Path.Combine(projectRootPath, UploadedDocument.FileName);
    UploadedDocument.CopyTo(new FileStream(filePath, FileMode.Create));
    ShowDocumentContentInTextEditor(filePath);
}

private void ShowDocumentContentInTextEditor(string filePath)
{
    WordProcessingLoadOptions loadOptions = new WordProcessingLoadOptions();
    Editor editor = new Editor(filePath, delegate { return loadOptions; }); //passing path and load options (via delegate) to the constructor
    EditableDocument document = editor.Edit(new WordProcessingEditOptions()); //opening document for editing with format-specific edit options

    DocumentContent = document.GetContent();
}

Firepad provides two events for listening. One of them is ‘ready‘ which fires once Firepad has retrieved the initial editor contents. We attach a callback to this event type and in the callback, we pass DocumentContent string as an argument to setHtml() method of firepad object. Please add the following code in the init() function in Index.cshtml.

firepad.on('ready', function () {
    if (firepad.isHistoryEmpty()) {
        var documentContent = '@Model.DocumentContent';
        if (documentContent.length != 0) {   
            firepad.setHtml(htmlDecode(documentContent));
        } else {
            firepad.setText("Welcome to your own private pad! Share the URL above and collaborate with your friends.");
        }
    }
});

You may have noticed that we passed the documentContent string first to htmlDecode() method before passing to setHtml() method. It is to replace character entities such as &lt, &gt with signs (< and >). The htmlDecode() method looks as follows.

function htmlDecode(input) {
    var e = document.createElement('div');
    e.innerHTML = input;
    return e.childNodes[0].nodeValue;
}

Run the project, now you should be able to upload the content of a Word Document into an editor.

In part II of this post, I have explained how we can extend our application to include the following features:

  • Download content of the editor as MS Word, PDF, TXT or HTML document.
  • Share the URL with friends so that they can edit the document at the same time.

Please check it out.

Complete source code of the project is available on GitHub.

See Also

Posted in Conholdate.Total Product Family | Tagged , , , | Leave a comment

Conholdate.Total Discount Offer ends January 31st

LinkedIn YouTube Twitter Facebook
Share this issue:

Monthly Newsletter

January 2020

25% off Conholdate.Total
Hurry! Offer ends January 31st.
 

Get 25% off Conholdate.Total for .NET and Java. Quote HOLOFF2019 when placing your order.

 
Buy Now

This offer is only available on new Conholdate.Total purchases and cannot be used in conjunction with other offers, renewals or upgrades. Only available directly from conholdate.com, not through third parties or resellers. Ts&Cs Apply.

Product News
Product News
Feedback
 
Conholdate for .NETConholdate for Java
 
Product Releases and Updates

 
Posted in Customer Newsletters, Newsletter | Tagged , , , | Leave a comment