1<?php
2/**
3 * DokuWiki xslfo plugin: Export single pages to PDF via XSL-FO.
4 *
5 * @license GPL 3 (http://www.gnu.org/licenses/gpl.html)
6 * @author Sam Wilson <sam@samwilson.id.au>
7 */
8
9/**
10 * Ensure that we're running within Dokuwiki.
11 */
12if (!defined('DOKU_INC')) {
13    die();
14}
15if (!defined('DOKU_PLUGIN')) {
16    define('DOKU_PLUGIN', DOKU_INC.'lib/plugins/');
17}
18
19/**
20 * The main xslfo class.
21 */
22class action_plugin_xslfo extends DokuWiki_Action_Plugin {
23
24    /** @var string Current XSL template */
25    private $template;
26
27    /** @var string Full path to the current XSL file */
28    private $template_path;
29
30    /**
31     * Register the events
32     */
33    public function register(&$controller) {
34        $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'preprocess', array());
35    }
36
37    /**
38     * Do the HTML to PDF conversion work
39     *
40     * @param Doku_Event $event
41     * @param array      $param
42     * @return bool
43     */
44    public function preprocess(&$event, $param) {
45        global $ID, $REV, $ACT;
46
47        // Check that this is our action
48        if ($ACT != 'export_xslfo') {
49            return false;
50        }
51        $event->preventDefault();
52
53        // Check for the XML plugin
54        if (!class_exists('renderer_plugin_xml')) {
55            msg('The XML plugin is required by the XSLFO plugin.', -1);
56            return false;
57        }
58
59        // Check user authorisation
60        if (auth_quickaclcheck($ID) < AUTH_READ) {
61            return false;
62        }
63
64        // Set up template and page title
65        $this->setupTemplate();
66        $title = p_get_first_heading($ID);
67
68        // Prepare and check the cache
69        // (The same cache key is also used below for the XML file)
70        $cache_key = $ID.$REV.$this->template;
71        $pdf_cache = new cache($cache_key, '.pdf');
72        $cache_dependencies['files'] = array(
73            __FILE__,
74            wikiFN($ID, $REV),
75            $this->template_path,
76            getConfigFiles('main'),
77        );
78        if (!$this->getConf('usecache') || !$pdf_cache->useCache($cache_dependencies)) {
79            if (!$this->generatePDF($cache_key, $pdf_cache->cache)) {
80                return false;
81            }
82        }
83
84        $this->sendFile($pdf_cache->cache, $title);
85    }
86
87    /**
88     * Generate the PDF file.
89     * If $_GET['debug'] is set, the raw XML will be output.
90     *
91     * @global string $ID
92     * @global string $REV
93     * @param string $cache_key The key of the cache, for the XML file
94     * @param string $pdf_filename The full path to write the PDF to
95     * @return boolean True if the PDF was generated successfully
96     */
97    protected function generatePDF($cache_key, $pdf_filename) {
98        global $ID, $REV;
99
100        // Replace placeholders in the command string
101        $filenames = array(
102            'xml' => $this->setupXML(),
103            'xsl' => $this->template_path,
104            'pdf' => $pdf_filename,
105        );
106
107        // Display final XML for debugging purposes (for admins only)
108        if (isset($_GET['debug'])) {
109            $xml = htmlspecialchars(file_get_contents($filenames['xml']));
110            msg("Final XML: <pre>$xml</pre>", 0, '', '', MSG_ADMINS_ONLY);
111            return false;
112        }
113
114        $command_template = $this->getConf('command').' 2>&1';
115        $command = preg_replace_callback('/{(\w+)}/', function ($m) use ($filenames) {
116                    return $filenames[$m[1]];
117                }, $command_template);
118
119        // Execute the FO processor command, and give up if it fails
120        if (file_exists($pdf_filename)) {
121            unlink($pdf_filename);
122        }
123        exec($command, $out);
124        if (!file_exists($pdf_filename)) {
125            msg("Unable to produce PDF.", -1);
126            msg("Command: <code>$command</code><br />Output:<pre>".join("\n", $out).'</pre>', 0, '', '', MSG_ADMINS_ONLY);
127            return false;
128        } else {
129            return true;
130        }
131    }
132
133    /**
134     * Get the page XML, add some useful paths to it (in the
135     * &lt;dokuwiki&gt; element) and return the filename of the cached XML file.
136     * Doesn't check for an existing XML cache because at this point we always
137     * want to re-render. The image paths are added here, rather than in the XML
138     * plugin, to avoid data exposure (the end user won't ever see this XML).
139     *
140     * @global string $ID
141     * @global string $REV
142     * @global array $conf
143     * @return string Full filesystem path to the cached XML file
144     */
145    protected function setupXML() {
146        global $ID, $REV, $INFO, $conf;
147
148        // Construct the new dokuwiki element
149        $dw_element = new SimpleXMLElement('<dokuwiki></dokuwiki>');
150        $dw_element->addChild('tplincdir', strtr(tpl_incdir(), '\\', '/'));
151        $dw_element->addChild('mediadir', strtr($conf['mediadir'], '\\', '/'));
152        $dw_element->addChild('lastmod', dformat($INFO['lastmod']));
153        $params = ($REV) ? array('rev'=> $REV) : null;
154        $dw_element->addChild('url', wl($ID, $params, true));
155
156        // Get the basic page XML
157        $file = wikiFN($ID, $REV);
158        $instructions = p_get_instructions(io_readWikiPage($file, $ID, $REV));
159        $original_xml = p_render('xml', $instructions, $info);
160
161        // Some plugins might break the XML rendering.
162        try {
163            // Produces an E_WARNING error message for each error found in the
164            // XML data and additionally throws an Exception if the XML data
165            // could not be parsed.
166            libxml_use_internal_errors(true);
167            $page = new SimpleXMLElement($original_xml);
168        } catch (Exception $e) {
169            msg($e->getMessage(), -1);
170            $admin_msg = '';
171            foreach (libxml_get_errors() as $error) {
172                $admin_msg .= "$error->message (line $error->line column $error->column)<br />";
173            }
174            $admin_msg .= "Unable to parse XML: <pre>".htmlspecialchars($original_xml)."</pre>";
175            msg($admin_msg, 0, '', '', MSG_ADMINS_ONLY);
176            return false;
177        }
178
179        // Add image paths (for resized images) for use in the XSL
180        foreach ($page->xpath('//media') as $media) {
181            $src = mediaFN($media['src']);
182            $ext = current(mimetype($src, false));
183            if($media['width'] && $media['height'] > 0) {
184                $filename = media_crop_image($src, $ext, (int)$media['width'], (int)$media['height']);
185            } else {
186                $filename = media_resize_image($src, $ext, (int)$media['width'], (int)$media['height']);
187            }
188            $media_filename = $dw_element->addChild('media_filename', $filename);
189            $media_filename->addAttribute('src', $media['src']);
190            $media_filename->addAttribute('width', $media['width']);
191            $media_filename->addAttribute('height', $media['height']);
192        }
193
194        // Insert the new XML into the page's XML
195        $new_xml = str_replace('<?xml version="1.0"?>', '', $dw_element->asXML());
196        $xml = str_replace('</document>', $new_xml.'</document>', $original_xml);
197
198        // Cache the XML (for use by the XSLFO processor, not subsequent calls
199        // to this method) and return its full filesystem path.
200        $xml_cache = new cache($ID.$REV.'_xslfo', '.xml');
201        $xml_cache->storeCache($xml);
202        return $xml_cache->cache;
203    }
204
205    /**
206     * Get the full filesystem path to the current XSL in the current site
207     * template's xslfo directory.
208     *
209     * @uses $_REQUEST['tpl']
210     * @return string The full path to the XSL file
211     */
212    protected function setupTemplate() {
213        if (!empty($_REQUEST['tpl'])) {
214            $this->template = $_REQUEST['tpl'];
215        } else {
216            $this->template = $this->getConf('template');
217        }
218        $this->template_path = realpath(tpl_incdir().$this->template);
219        // Might resolve to a directory, so check it's a file.
220        if (!is_file($this->template_path)) {
221            $this->template = 'default.xsl';
222            $this->template_path = __DIR__.DIRECTORY_SEPARATOR.$this->template;
223        }
224    }
225
226    /**
227     * Send the PDF file to the user.
228     *
229     * @param string $file Full filesystem path to the cached PDF
230     * @param string $title The title of the document, to be turned into a filename
231     */
232    public function sendFile($file, $title) {
233
234        // Start sending HTTP headers
235        header('Content-Type: application/pdf');
236        header('Cache-Control: must-revalidate, no-transform, post-check=0, pre-check=0');
237        header('Pragma: public');
238        http_conditionalRequest(filemtime($file));
239
240        // Construct a nice filename from the title
241        $filename = rawurlencode(cleanID(strtr($title, ':/;"', '    ')));
242        if ($this->getConf('output') == 'file') {
243            header('Content-Disposition: attachment; filename="'.$filename.'.pdf";');
244        } else {
245            header('Content-Disposition: inline; filename="'.$filename.'.pdf";');
246        }
247
248        // Use sendfile if possible
249        if (http_sendfile($file)) {
250            exit(0);
251        }
252
253        // Send file or fail with error
254        $fp = @fopen($file, "rb");
255        if ($fp) {
256            http_rangeRequest($fp, filesize($file), 'application/pdf');
257            exit(0);
258        } else {
259            header("HTTP/1.0 500 Internal Server Error");
260            print "Could not read file - bad permissions?";
261            exit(1);
262        }
263    }
264
265}
266