This article shares a brief tutorial for how to save web page as PDF with NodeJs.  We will be using the Puppeteer headless chrome browser to pull the web page on a Node server and convert it to PDF.  Big thanks to the Chrome DevTools team for maintaining this excellent headless browser!  

So what's a headless browser?  Simply put, its a browser with no display.  That may initially sound strange, but they are great for automating PDF rendering, or creating a robot to search the web.  

A Brief Note on Trial and Error

In my efforts to find a good method to print html to PDF, I ran into many dead ends.  In efforts to save you from that, here's what did not work for me, and why:

  • PDFMake - A great javascript library that generates pdf from an object array. I did not want to rewrite my entire html into an object array, so instead I spent many hours trying to convert html to canvas, but could not get past error messages from the canvas size.
  • jsPDF - Another great javascript library that generates pdfs. This one was able to convert html to canvas without errors. Hooray! But wait... Canvas is essentially an image on a pdf page. This option does not support multiple paged pdfs or page breaks very well, nor does it preserve the text data very well. Its basically a picture printed on one pdf page. Even simply getting the size and margins right can be a painful struggle.

Enter the Headless Browser

I went down the path of using PhantomJs as a headless browser to print PDFs several years ago, and remember ultimately not being successful.  Hence, I was thrilled to learn of the Puppeteer browser via the Chrome team.  However, even after working down this path, I still found myself in trial and error mode, with only a handful of tutorials available to help in my journey.  Hope this post can save you some time in this endeavor!

Prerequisites

Here's what I'm running:

  • Ubuntu 16.04
  • Node
  • NPM
  • ExpressJs
  • AngularJS

You may need to install the following Debian dependencies:

sudo apt-get install gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget

Installing Puppeteer

Special Note: Puppeteer downloads the headless browser to your node package during installation.  This file is relatively large, and exceeds Github's file size limit.  If you don't ignore your node_modules file, you will likely have trouble pushing.  You can either look into GitHub's Large File Storage, or just ignore the Puppeteer package by opening your .gitignore file and adding: node_modules/puppeteer/

Puppeteer is available from npm (learn more here).  Install with the following:

npm i puppeteer

Step 1 - Server Side Setup with ExpressJs

Here is a basic server side setup.  This code opens the headless browser, routes to an example webpage, renders as PDF, and sends back to the client.  See commented code below:

// Initialize the module
const puppeteer = require('puppeteer');

// A Post Route to Open the Headless Browser
app.post('/printPdf', function (req, res, next) {

  async function generatePdf() {
    const browser = await puppeteer.launch({ 
      args: ['--no-sandbox', '--disable-setuid-sandbox']
    });

    // Open a new page with the headless browser
    const page = await browser.newPage();

    // Route the headless browser to the webpage for printing
    await page.goto('http://www.example.com'); // add your url

    // Print the page as pdf
    const buffer = await page.pdf({ 
      printBackground: true, 
      format: 'Letter', 
      PreferCSSPageSize: true 
    }); 

    // send the pdf
    res.type('application/pdf');
    res.send(buffer);

    // Close the headless browser
    browser.close();
  };
  generatePdf();
});

Step 2: The Client Setup with AngularJs

The client setup will look something like this (an example AngularJs controller function):

// save as pdf
$scope.savePdf = function(){

  var response=$http.post("printPdf", { 
    responseType: 'arraybuffer',
    headers: {
      'Accept': 'application/pdf'
    }
  });

  response.then(function (success) {

    var fileName = 'Example.pdf';
    var a = angular.element('<a/>');
    var blob = new Blob([success.data], {
      type:'application/octet-stream'
    });
    var url = window.URL.createObjectURL(blob);

    if (window.navigator.msSaveBlob) { 
      // For IE
      window.navigator.msSaveOrOpenBlob(blob, fileName)
    } else if (navigator.userAgent.search("Firefox") !== -1) { 
      // For Firefox
      a.style = "display: none";
      angular.element(document.body).append(a);

      a.attr({
        href: 'data:application/pdf,' + encodeURIComponent(success.data),
        target: '_blank',
        download: fileName
      })[0].click();

      a.remove();
    } else { 
      // For Chrome
      a.attr({
        href: url,
        target: '_blank',
        download: fileName
      })[0].click();
    }
    window.URL.revokeObjectURL(url);
    }, function (err)  {
    });
}

Above, we make the post request for the pdf file, and use Blob to download the file.  Each browser has their own way of handling this (see commented code above).  

You can call the code with a button that calls the savePdf function somewhere in your Html (e.g. <submit ng-click="savePdf()">Save Me!</submit> ).

Step 3: Making This Dynamic

Now let's say you have an application that requires a dynamic front end.  First, we'll need to modify our router with a few extra lines of code.  

In the below code, after we open our headless browser and route to our web page url using page.goto, we'll add a function to be called within the headless browser from our server, using page.evaluate.  

In the example below, we will call a client function: window.exampleFunction, and load whatever data we want to the headless browser, with the data variable.

// Initialize the module
const puppeteer = require('puppeteer');

// A Post Route to Open the Headless Browser
app.post('/printPdf', function (req, res, next) {

  let data = 'Whatever data you want to inject to the client';

  async function generatePdf() {
    const browser = await puppeteer.launch({
      args: ['--no-sandbox', '--disable-setuid-sandbox']
    });

    // Open a new page with the headless browser
    const page = await browser.newPage();

    // Route the headless browser to the webpage for printing
    await page.goto('http://www.example.com'); // add your url

	// ADD THIS FUNCTION TO CALL A CLIENT FUNCTION IN THE HEADLESS BROWSER
    await page.evaluate((data) => { 
      return Promise.resolve(window.exampleFunction(data)); 
    }, data);

    // Print the page as pdf
    const buffer = await page.pdf({ 
      printBackground: true, 
      format: 'Letter', 
      PreferCSSPageSize: true 
    }); 

    // send the pdf
    res.type('application/pdf');
    res.send(buffer);

    // Close the headless browser
    browser.close();
  };
  generatePdf();
});

Finally, we must also add our function on the client, which will be called by the headless browser.  This would be added to your front end framework. Here's how we would update our AngularJs controller:

// Add a window function to your controller, which can inject data and funtionallity when loaded by the headless browser
window.exampleFunction = function(data) {
    $scope.example = data;
    $scope.$apply();
}

The above window.exampleFunction is essentially being called in the router, with the page.evaluate method.  You can inject whatever data you like, and add functionality as needed before rendering the PDF.  In my case, I simply needed to inject data to the scope before rendering.

Some Final Nuances

Displaying Images

I found that rendering images can be challenging, especially with dynamic urls.  Cross origin requests may also present problems.  

I needed to display images by ID from AWS S3, and found I could no longer simply point my image element to the S3 url.  I also could not add any parameters to the url in the image element.  The image simply would not show.

My final messy work around was to route the image tag to my express router, with a simplified route - <img src="example-image.image"> (for whatever reason, I couldn't get this to work without an extension, so I made up .image).  

Next, I added the image ID as an Angular router parameter on the page (e.g. http://www.example.com/ID12345)

I then setup Express to pull the ID from the requesting header, and route the image back to the client  Here's how that looks:

// Send the image to client
app.get('/example-image.image',function(req, res){

  // Get the params from the requesting header
  let imageId = req.headers.referer.substring(req.headers.referer.lastIndexOf('/')+1);

  // The S3 url
  let url = 'https://s3-us-west-1.amazonaws.com/MYBUCKET/' + imageId; 
  request(url).pipe(res);
});

I'm sure there's a better way to do this.  Feel free to add your solution to the comments of this post.

Displaying Margins

PDF page margins can also be tricky with Puppeteer.  In our initial server route, we added the following option PreferCSSPageSize: true, which will allow us to set print styling in our CSS file.

I could never get the margins to work right, but found success in just sizing the actual page a little smaller than Letter size.  See the below CSS:

@media print {
  @page { 
    size:8in 10in; 
    margin: 0  
  }
}

Finally, if there are elements that you don't want to render, simply create a CSS class, and add it to those elements:

@media print {    
  .no-pdf {
    display: none !important;
  }
}

Hope that helps! If you have any issues or feedback, feel free to comment below.