Exporting Highcharts JS to PDF & DOCX with TCPDF and PHPWord
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 JS, TCPDF, PHPWord, Batik 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.