You are here

Exporting Highcharts JS to PDF & DOCX with TCPDF and PHPWord

Development
jQuery
PHP
Other

For integration into Drupal I tried to export Highcharts JS graphics to PDF and DOCX.
The actual integration into Drupal was done by a colleague, so this is once again a simple proof of concept.

Technologies used are: Highcharts JSTCPDFPHPWordBatik rasterizer

The Highcharts JS site provided already some decent examples I could start from.

The idea is basically this: I have a XML with a predefined structure which will have all text and numbers in there (i only needed pie charts btw), this XML is used to render the page (also the Highcharts). When a user clicks either the pdf or docx button, the svg is pulled from the Highcharts object and send to a executable (Batik) on the (php) server which converts it into a image. These images are placed in a pdf or docx with their according title(s) and text(s).

Example XML:

<?xml version="1.0" encoding="UTF-8"?>
<charts>
  <chart>
  	<series>
  		<name>Status projecten</name>
  		<type>pie</type>
  		<data>
  			<categorie>gerealiseerd</categorie>
  			<point>45.0</point>
  			<url>http://www.google.be</url>
  		</data>
  		<data>
        <categorie><a href="http://www.google.be">OK</a></categorie>
        <point>26.8</point>
        <url>http://www.google.be</url>
  		</data>
  		<data>
        <categorie>met vertraging</categorie>
        <point>12.8</point>
        <url>http://www.google.be</url>
  		</data>
  		<data>
        <categorie>niet OK</categorie>
        <point>8.5</point>
        <url>http://www.google.be</url>
  		</data>
      <data>
        <categorie>onbekend</categorie>
        <point>6.9</point>
        <url>http://www.google.be</url>
      </data>
  	</series>
  	<title>
  	  chart title
  	</title>
  	<text>
  	  chart text
  	</text>
  </chart>
...
</charts>

The first part of the XML element "chart" is more or less what the examples from Highcharts provided, I added the URL element so a click through on the corresponding pie could be implemented.

The last part with "title" and "text", well, are the title and text per chart.

This HTML/JS is used for the page rendering:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=EmulateIE7" />


<title>jQuery Highcharts</title>

<link href="https://www.calibrate.be/css/css.css" rel="stylesheet" type="text/css">

<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.6.1/jquery.min.js" type="text/javascript"></script>

<script src="https://www.calibrate.be/js/highcharts.js" type="text/javascript"></script>
<script src="https://www.calibrate.be/js/modules/exporting.js" type="text/javascript"></script>
<script src="https://www.calibrate.be/js/jquery.json-2.3.min.js" type="text/javascript"></script>
<script>
var charts;


$(document).ready(function() {
  
  $('#pdf').click(function(){
    getDocument('pdf');
  });
  
  $('#doc').click(function(){
    getDocument('doc');
  });
  
  
  /*BUILD PAGE FROM XML*/
  $.ajax({
    type: 'GET',
    url: 'data.xml',
    dataType : 'xml',
    success: function(data) {
      charts=new Array();
      // Iterate over the lines and add categories or series
      $(data).find('chart').each(function(id) {
        var chart, title, subtitle, containerChart, renderTo;
        
        var options = {
          chart: {renderTo: ''},
          title: null,
          subtitle: null,
          tooltip: {
            formatter: function() {
              return '<b>' + this.point.name + '</b>: ' + this.percentage + ' %';
            }
          },
          plotOptions: {
            pie: {
              allowPointSelect: true,
              cursor: 'pointer',
              dataLabels: {
                enabled: true,
                formatter: function() {
                  return '<b>' + this.point.name + '</b>: ' + this.percentage + ' %';
                }
              },
              showInLegend: true,
              events: {
                click: function(e) {
                    window.open(e.point.url);
                }
              }
            }
          },
          navigation: {
            buttonOptions: true
          },
          series: [],
          navigation: {
            buttonOptions: {
              enabled: false
            }
          },
          exporting: {
            type: 'image/jpeg',
            url:'http://projects.local/via/highcharts/exporting-server/'
          },
          credits: {
            enabled: false
          }
        };
        
        containerChart = 'containerChart-'+id;
        renderTo = 'chart-'+id;
        
        
        $('#container').append('<div class="chart" id="' + containerChart + '"></div>');
        $('#'+containerChart)
          .append('<h2 class="title">'+$(this).find('title').text()+'</h2>')
          .append('<div id="' + renderTo + '"></div>')
          .append('<div class="text">'+$(this).find('text').text()+'</div');
        
        options.chart.renderTo = renderTo;
        
        $(this).find('series').each(function() {
          var serie = new Object();
          serie.type = $(this).find('type').text();
          serie.name = $(this).find('name').text();         
          serie.data = new Array();
          
          $(this).find('data').each(function() {
            var categorie = $(this).find('categorie').text();
            var point = parseFloat($(this).find('point').text());
            var url=$(this).find('url').text();
            var item = {
              name: categorie,
              y: point,
              url:url
            };
            
            
            serie.data.push(item);
          });
          
          options.series.push(serie);
        });
        
        // Create the chart
        chart = new Highcharts.Chart(options);
        /*ADD CHART DATA TO ARRAY, getSVG for exporting*/
        charts.push({title:$(this).find('title').text(),text:$(this).find('text').text(),svg:chart.getSVG()})
        
        
      });
    }
  });
});


function getDocument(type){
  
  var title='title';
  var header='header';
  var footer='footer';
  
  
  var json={
    'type':type, 
    'title':title,
    'header':header,
    'footer':footer,
    'data': $.toJSON(charts)
  };

  /*TRICK CLIENT INTO DOWNLOAD FILE WITH jQuery*/
  download('docgen.php',json,'POST');
}

function download(url, data, method){
  //url and data options required
  if( url && data ){ 
    //data can be string of parameters or array/object
    data = typeof data == 'string' ? data : jQuery.param(data);
    //split params into form inputs
    var inputs = '';
    jQuery.each(data.split('&'), function(){ 
      var pair = this.split('=');
      inputs+='<input type="hidden" name="'+ pair[0] +'" value="'+ pair[1] +'" />'; 
    });
    //send request
    jQuery('<form action="https://www.calibrate.be/%27%2B%20url%20%2B%27" method="'+ (method||'post') +'">'+inputs+'</form>')
    .appendTo('body').submit().remove();
  };
};
</script>
</head>

<body>
  <button id="pdf">PDF</button>
  <button id="doc">DOC</button>
  <div id="container" style=""></div>
</body>
</html>

When the page is constructed, the title, text and svg data are pushed into an array (line 126)

When the user clicks one of the buttons, a document will be generated with all the charts (getDocument, line 135): a json object is passed with the document title, header, footer and the json data per chart (and the type = pdf/doc), this is send to docgen.php, based on the exporting-server Highcharts example.

<?php
require_once 'PHPWord/PHPWord.php';
require_once('tcpdf/tcpdf.php');

define ('BATIK_PATH', 'exporting-server/batik-rasterizer.jar');
define ('TEMP_PATH', 'exporting-server/temp/');


if(isset($_POST['type']) && isset($_POST['title']) && isset($_POST['header']) && isset($_POST['footer']) && isset($_POST['data'])){
    
  $title=urldecode($_POST['title']);
  $header=urldecode($_POST['header']);
  $footer=urldecode($_POST['footer']);
    
  $type=urldecode($_POST['type']);
  $data=json_decode(urldecode($_POST['data']));
  
  
  $items=array();
  for($i=0; $i < count($data); $i++){
    $items[]=svgToJpg($data[$i]);
  }
 
  if($type=='pdf'){
    doPDF($title,$header,$footer,$items);
  }elseif($type=='doc'){
    doDoc($title,$header,$footer,$items);
  }
  foreach($items as $item){
    unlink($item->filename);
  }
  exit;
}else{
  print 'ERROR docgen.php: no post data!';
}


function svgToJpg($item){
  /*CONVERTS SVG TO JPG*/
  
  ///////////////////////////////////////////////////////////////////////////////
  ini_set('magic_quotes_gpc', 'off');
  
  
  $filename =  isset($_POST['filename']) ? $_POST['filename'] : 'chart';
  $width =  isset($_POST['width']) ? $_POST['width'] : 300;
  
  $svg=$item->svg;
  if (get_magic_quotes_gpc()) {
    $svg = stripslashes($svg);  
  }
  
  
  
  $tempName = md5(rand());
  $typeString = '-m image/jpeg';
  $ext = '.jpg';
  $outfile = TEMP_PATH.$tempName.$ext;
  
  if (isset($typeString)) {
    
    // size
   $width = "-w $width";
   
  
    // generate the temporary file
    if (!file_put_contents(TEMP_PATH.$tempName.".svg", $svg)) { 
      die("Couldn't create temporary file. Check that the directory permissions for
        the /temp directory are set to 777.");
    }
    
    // do the conversion
    shell_exec("chmod 777 ".TEMP_PATH.$tempName.".svg");
    $output = shell_exec("java -jar ". BATIK_PATH ." $typeString -d $outfile $width ".TEMP_PATH.$tempName.".svg");
    
    // catch error
    if (!is_file($outfile) || filesize($outfile) < 10) {
      echo "<pre>$output</pre>";
      echo "Error while converting SVG. ";
      
      if (strpos($output, 'SVGConverter.error.while.rasterizing.file') !== false) {
        echo "SVG code for debugging: <hr/>";
        echo htmlentities($svg);
      }
    } 
    
    // stream it
    else {
      unlink(TEMP_PATH.$tempName.".svg");
      $item->filename=$outfile;
      return $item;
    }
    
    // delete it
    
    unlink($outfile);
  
  // SVG can be streamed directly back
  } else {
    echo "Invalid type";
  }
}
exit;



function doDoc($title,$headertext,$footertext,$items){
  // New Word Document
  $PHPWord = new PHPWord();
  // New portrait section
  $section = $PHPWord->createSection();
  
  // Add header
  $header = $section->createHeader();
  $table = $header->addTable();
  $table->addRow();
  $table->addCell(4500)->addText($headertext);
  
  // Add footer
  $footer = $section->createFooter();
  //$footer->addPreserveText('Page {PAGE} of {NUMPAGES}.', array('align'=>'center'));
  $footer->addPreserveText($footertext, array('align'=>'center'));
  
  // Title styles
  $PHPWord->addTitleStyle(1, array('size'=>20, 'color'=>'333333', 'bold'=>true));
  $PHPWord->addTitleStyle(2, array('size'=>16, 'color'=>'666666'));
  
  $section->addTitle($title, 1);
  
  foreach($items as $item){
    $section->addTitle($item->title, 2);
    $section->addTextBreak(1);
    $section->addText($item->text);
    $section->addTextBreak(1);
    $section->addImage($item->filename);
    $section->addTextBreak(1);
  }
  
  
  $objWriter = PHPWord_IOFactory::createWriter($PHPWord, 'Word2007');
  header('Content-Type: application/vnd.ms-word');
  header('Content-Disposition: attachment;filename="'.$title.'.docx"');
  header('Cache-Control: max-age=0');
  // At least write the document to webspace:
  $objWriter = PHPWord_IOFactory::createWriter($PHPWord, 'Word2007');
  $objWriter->save('php://output');
    
}


function doPDF($title,$headertext,$footertext,$items){
  
  require_once('tcpdf/config/lang/eng.php');
  require_once('tcpdf/tcpdf.php');
  
  
  // create new PDF document
  $pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
  
  // set default header data
  $pdf->SetHeaderData(NULL, NULL, $headertext, NULL);
  
  // set header and footer fonts
  $pdf->setHeaderFont(Array(PDF_FONT_NAME_MAIN, '', PDF_FONT_SIZE_MAIN));
  $pdf->setFooterFont(Array(PDF_FONT_NAME_DATA, '', PDF_FONT_SIZE_DATA));
  
  // set default monospaced font
  $pdf->SetDefaultMonospacedFont(PDF_FONT_MONOSPACED);
  
  //set margins
  $pdf->SetMargins(PDF_MARGIN_LEFT, PDF_MARGIN_TOP, PDF_MARGIN_RIGHT);
  $pdf->SetHeaderMargin(PDF_MARGIN_HEADER);
  $pdf->SetFooterMargin(PDF_MARGIN_FOOTER);
  
  //set auto page breaks
  $pdf->SetAutoPageBreak(TRUE, PDF_MARGIN_BOTTOM);
  
  //set image scale factor
  $pdf->setImageScale(PDF_IMAGE_SCALE_RATIO);
  
  //set some language-dependent strings
  $pdf->setLanguageArray($l);
  
  // ---------------------------------------------------------
  
  // set font
  $pdf->SetFont('helvetica', '', 10);
  
  // add a page
  $pdf->AddPage();
    
  
  $html = '<h1>'.$title.'</h1>';
  
  
  
  foreach($items as $item){
    $html .= '<h2>'.$item->title.'</h2>';
    $html .= '<p>'.$item->text.'</p>';
    $html .= '<img src="https://www.calibrate.be/%27.%24item-%3Efilename.%27" />';
  }
  
  $pdf->writeHTML($html, true, false, true, false, '');
  
  
  //Close and output PDF document
  $pdf->Output($title.'.pdf', 'D');
}
  ?>

This will convert all the charts to a jpg (svgToJpg, line 39): the svg string is converted to a temp svg file, which is converted to an image using Batik (this is serverside, so check your resources if you plan to use this in a production environment), the generated image paths are passed to the item array which is used to generate the pdf/docx.

I won't go into detail about the document generation itself, as this is pretty basic and the used libraries possibly provide better examples, but the reason I didn't use any HTML to PDF/DOCX generation (which would have been a little more generic) is that the whole idea was that a table of contents could be generated from the predefined titles in the DOCX. I didn't find any tools who could do this from HTML code, so I decided to loop through the XML data. And since I was doing this custom for the docx, why not for the pdf..

So that's it very very very roughly, happy to answer any questions that pop into your mind.