diff --git a/.gitignore b/.gitignore index 0e69430..135cfd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ *.pdf -!example/businesscard.pdf +!examples/businesscard.pdf node_modules diff --git a/README.md b/README.md index fcaaef0..3f3a1d3 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # node-html-pdf ## HTML to PDF converter that uses phantomjs -![image](example/businesscard.png) -[Example Business Card](example/businesscard.pdf) - -> [and its Source file](example/businesscard.html) +![image](examples/businesscard.png) +[Example Business Card](examples/businesscard.pdf) + -> [and its Source file](examples/businesscard.html) [Example Receipt](http://imgr-static.s3-eu-west-1.amazonaws.com/order.pdf) @@ -102,6 +102,7 @@ config = { "left": "1.5in" }, + paginationOffset: 1, // Override the initial pagination number "header": { "height": "45mm", "contents": '
Author: Marc Bachmann
' @@ -133,12 +134,35 @@ config = { "script": '/url', // Absolute path to a custom phantomjs script, use the file in lib/scripts as example "timeout": 30000, // Timeout that will cancel phantomjs, in milliseconds + // Time we should wait after window load + // accepted values are 'manual', some delay in milliseconds or undefined to wait for a render event + "renderDelay": 1000, + // HTTP Headers that are used for requests "httpHeaders": { // e.g. "Authorization": "Bearer ACEFAD8C-4B4D-4042-AB30-6C735F5BAC8B" + }, + + // To run Node application as Windows service + "childProcessOptions": { + "detached": true } + // HTTP Cookies that are used for requests + "httpCookies": [ + // e.g. + { + "name": "Valid-Cookie-Name", // required + "value": "Valid-Cookie-Value", // required + "domain": "localhost", + "path": "/foo", // required + "httponly": true, + "secure": false, + "expires": (new Date()).getTime() + (1000 * 60 * 60) // e.g. expires in 1 hour + } + ] + } ``` diff --git a/example/businesscard.pdf b/example/businesscard.pdf deleted file mode 100644 index a6f8752..0000000 Binary files a/example/businesscard.pdf and /dev/null differ diff --git a/example/businesscard.html b/examples/businesscard/businesscard.html similarity index 100% rename from example/businesscard.html rename to examples/businesscard/businesscard.html diff --git a/example/businesscard.png b/examples/businesscard/businesscard.png similarity index 100% rename from example/businesscard.png rename to examples/businesscard/businesscard.png diff --git a/example/image.png b/examples/businesscard/image.png similarity index 100% rename from example/image.png rename to examples/businesscard/image.png diff --git a/examples/serve-http/index.js b/examples/serve-http/index.js new file mode 100644 index 0000000..2af62e6 --- /dev/null +++ b/examples/serve-http/index.js @@ -0,0 +1,19 @@ +const fs = require('fs') +const http = require('http') +const pdf = require('../../') +const tmpl = fs.readFileSync(require.resolve('../businesscard/businesscard.html'), 'utf8') + +const server = http.createServer(function (req, res) { + if (req.url === '/favicon.ico') return res.end('404') + const html = tmpl.replace('{{image}}', `file://${require.resolve('../businesscard/image.png')}`) + pdf.create(html, {width: '50mm', height: '90mm'}).toStream((err, stream) => { + if (err) return res.end(err.stack) + res.setHeader('Content-type', 'application/pdf') + stream.pipe(res) + }) +}) + +server.listen(8080, function (err) { + if (err) throw err + console.log('Listening on http://localhost:%s', server.address().port) +}) diff --git a/lib/pdf.js b/lib/pdf.js index 7211d0f..4ca1afd 100644 --- a/lib/pdf.js +++ b/lib/pdf.js @@ -63,7 +63,7 @@ PDF.prototype.toStream = function PdfToStream (callback) { } stream.on('end', function () { - fs.unlink(res.filename, function (err) { + fs.unlink(res.filename, function unlinkPdfFile (err) { if (err) console.log('html-pdf:', err) }) }) @@ -84,54 +84,65 @@ PDF.prototype.toFile = function PdfToFile (filename, callback) { } PDF.prototype.exec = function PdfExec (callback) { - var callbacked = false - var child = childprocess.spawn(this.options.phantomPath, [].concat(this.options.phantomArgs, [this.script])) - var stdout = [] + var child = childprocess.spawn(this.options.phantomPath, [].concat(this.options.phantomArgs, [this.script]), this.options.childProcessOptions) var stderr = [] + var timeout = setTimeout(function execTimeout () { - child.stdin.end() - child.kill() - if (!stderr.length) { - stderr = [new Buffer('html-pdf: PDF generation timeout. Phantom.js script did not exit.')] - } + respond(null, new Error('html-pdf: PDF generation timeout. Phantom.js script did not exit.')) }, this.options.timeout) - child.stdout.on('data', function (buffer) { - return stdout.push(buffer) - }) - - child.stderr.on('data', function (buffer) { + function onError (buffer) { stderr.push(buffer) - child.stdin.end() - return child.kill() - }) + } + + function onData (buffer) { + var result + try { + var json = buffer.toString().trim() + if (json) result = JSON.parse(json) + } catch (err) { + // Proxy for debugging purposes + process.stdout.write(buffer) + } + + if (result) respond(null, null, result) + } - function exit (err, data) { + var callbacked = false + function respond (code, err, data) { if (callbacked) return callbacked = true clearTimeout(timeout) - if (err) return callback(err) - return callback(null, data) - } - child.on('error', exit) - - child.on('exit', function (code) { - if (code || stderr.length) { - var err = new Error(Buffer.concat(stderr).toString() || 'html-pdf: Unknown Error') - return exit(err) - } else { - try { - var buff = Buffer.concat(stdout).toString() - var data = (buff) != null ? buff.trim() : undefined - data = JSON.parse(data) - } catch (err) { - return exit(err) - } - return exit(null, data) + // If we don't have an exit code, we kill the process, ignore stderr after this point + if (code === null) kill(child, onData, onError) + + if (!data) { + if (!err && code) err = new Error("html-pdf: Received the exit code '" + code + "'") + else if (!err) err = new Error('html-pdf: Unknown Error') + + var postfix = stderr.length ? '\n' + Buffer.concat(stderr).toString() : '' + if (postfix) err.message += postfix + return callback(err, null) } - }) - var res = JSON.stringify({html: this.html, options: this.options}) - return child.stdin.write(res + '\n', 'utf8') + callback(null, data) + } + + child.stdout.on('data', onData) + child.stderr.on('data', onError) + child.on('error', function onError (err) { respond(null, err) }) + + // An exit event is most likely an error because we didn't get any data at this point + child.on('close', respond) + child.on('exit', respond) + + var config = JSON.stringify({html: this.html, options: this.options}) + child.stdin.write(config + '\n', 'utf8') + child.stdin.end() +} + +function kill (child, onData, onError) { + child.stdin.end() + child.kill() } diff --git a/lib/scripts/pdf_a4_portrait.js b/lib/scripts/pdf_a4_portrait.js index a9197c4..3542baa 100755 --- a/lib/scripts/pdf_a4_portrait.js +++ b/lib/scripts/pdf_a4_portrait.js @@ -33,6 +33,46 @@ if (!json.html || !json.html.trim()) exit('Did not receive any html') var options = json.options var page = webpage.create() +// Completely load page & end process +// ---------------------------------- +var rendered = false +var renderTimeout + +// If renderDelay is manual, then listen for an event and don't automatically render +if (options.renderDelay === 'manual') { + page.onCallback = function (message) { + setTimeout(renderNow, 0) + return message + } +} + +page.onLoadFinished = function () { + if (options.renderDelay === 'manual') return + renderTimeout = setTimeout(renderNow, Math.floor(options.renderDelay) || 0) +} + +function renderNow () { + if (rendered) return + rendered = true + clearTimeout(renderTimeout) + page.paperSize = definePaperSize(getContent(page), options) + + var fileOptions = { + type: options.type || 'pdf', + quality: options.quality || 75 + } + + var filename = options.filename || (options.directory || '/tmp') + '/html-pdf-' + system.pid + '.' + fileOptions.type + page.render(filename, fileOptions) + + // Output to parent process + system.stdout.write(JSON.stringify({filename: filename})) + exit(null) +} + +// Set Content and begin loading +// ----------------------------- +if (options.httpCookies) page.cookies = options.httpCookies if (options.httpHeaders) page.customHeaders = options.httpHeaders if (options.viewportSize) page.viewportSize = options.viewportSize if (options.zoomFactor) page.zoomFactor = options.zoomFactor @@ -51,25 +91,6 @@ setTimeout(function () { exit('Force timeout') }, timeout) -// Completely load page & end process -// ---------------------------------- -page.onLoadFinished = function (status) { - // The paperSize object must be set at once - page.paperSize = definePaperSize(getContent(page), options) - - // Output to parent process - var fileOptions = { - type: options.type || 'pdf', - quality: options.quality || 75 - } - - var filename = options.filename || (options.directory || '/tmp') + '/html-pdf-' + system.pid + '.' + fileOptions.type - page.render(filename, fileOptions) - system.stdout.write(JSON.stringify({filename: filename})) - - exit(null) -} - // Returns a hash of HTML content // ------------------------------ function getContent (page) { @@ -147,17 +168,23 @@ function createSection (section, content, options) { options = options[section] || {} var c = content[section] || {} var o = options.contents + var paginationOffset = Math.floor(options.paginationOffset) || 0 + if (typeof o !== 'object') o = {default: o} return { height: options.height, contents: phantom.callback(function (pageNum, numPages) { var html = o[pageNum] || c[pageNum] - if (pageNum === 1 && !html) html = o.first || c.first - if (pageNum === numPages && !html) html = o.last || c.last + + var pageNumFinal = pageNum + paginationOffset + var numPagesFinal = numPages + paginationOffset + + if (pageNumFinal === 1 && !html) html = o.first || c.first + if (numPagesFinal === numPages && !html) html = o.last || c.last return (html || o.default || c.default || '') - .replace(/{{page}}/g, pageNum) - .replace(/{{pages}}/g, numPages) + content.styles + .replace(/{{page}}/g, pageNumFinal) + .replace(/{{pages}}/g, numPagesFinal) + content.styles }) } } diff --git a/test/callback.html b/test/callback.html new file mode 100644 index 0000000..5684a35 --- /dev/null +++ b/test/callback.html @@ -0,0 +1,30 @@ + + + + + + + +
+ + + + + diff --git a/test/index.js b/test/index.js index bc8841f..a48b42e 100644 --- a/test/index.js +++ b/test/index.js @@ -1,5 +1,7 @@ var test = require('tape') var tapSpec = require('tap-spec') +function noop (err) { if (err) throw err } + test.createStream() .pipe(tapSpec()) .pipe(process.stdout) @@ -42,7 +44,7 @@ test('pdf.create(html[, options]).toFile([filename, ]callback)', function (t) { pdf.create(html).toFile(function (err, pdf) { t.error(err) t.assert(typeof pdf.filename === 'string', `toFile(callback) returns {filename: '${pdf.filename}'} as second cb argument`) - fs.unlink(pdf.filename) + fs.unlink(pdf.filename, noop) }) var file = path.join(__dirname, 'simple.pdf') @@ -72,6 +74,32 @@ test('pdf.create(html, {directory: "/tmp"}).toBuffer(callback)', function (t) { }) }) +test('pdf.create(html, {renderDelay: 1000}).toBuffer(callback)', function (t) { + t.plan(2) + + pdf.create(html, {renderDelay: 1000}).toBuffer(function (err, pdf) { + t.error(err) + t.assert(Buffer.isBuffer(pdf), 'still returns after renderDelay') + }) +}) + +test('window.callPhantom renders page', function (t) { + t.plan(3) + + var callbackHtml = fs.readFileSync(path.join(__dirname, 'callback.html'), 'utf8') + var file = path.join(__dirname, 'callback.pdf') + var startTime = new Date().getTime() + + pdf.create(callbackHtml, {renderDelay: 'manual'}).toFile(file, function (err, pdf) { + var endTime = new Date().getTime() + t.error(err) + + var time = endTime - startTime + t.assert(time > 1000 && time < 2000, 'rendered in response to callPhantom') + t.assert(fs.existsSync(file), 'writes the file to the given destination') + }) +}) + test('pdf.create(html[, options]).toStream(callback)', function (t) { t.plan(3) @@ -82,7 +110,7 @@ test('pdf.create(html[, options]).toStream(callback)', function (t) { stream.pipe(fs.createWriteStream(destination)) stream.on('end', function () { t.assert(fs.existsSync(destination), 'toStream returns a working readable stream') - fs.unlink(destination) + fs.unlink(destination, noop) }) }) }) @@ -93,11 +121,11 @@ test('pdf.create(html[, options]).toStream(callback)', function (t) { test('allows custom html and css', function (t) { t.plan(3) - var template = path.join(__dirname, '../example/businesscard.html') + var template = path.join(__dirname, '../examples/businesscard/businesscard.html') var filename = template.replace('.html', '.pdf') var templateHtml = fs.readFileSync(template, 'utf8') - var image = path.join('file://', __dirname, '../example/image.png') + var image = path.join('file://', __dirname, '../examples/businesscard/image.png') templateHtml = templateHtml.replace('{{image}}', image) var options = { @@ -194,3 +222,36 @@ test('load external js', function (t) { t.assert(fs.existsSync(pdf.filename), 'Saves the pdf with a custom page size and footer') }) }) + +test('load with cookies js', function (t) { + t.plan(3) + + var server = require('http').createServer(function (req, res) { + res.write(req.headers.cookie) + res.end() + }) + + server.listen(0, function (err) { + t.error(err, 'http server for iframe started') + + var port = server.address().port + var filename = path.join(__dirname, 'cookies.pdf') + pdf.create(` + here is an iframe which receives the cookies + + + `, { + httpCookies: [{ + name: 'Valid-Cookie-Name', + value: 'Valid-Cookie-Value', + domain: 'localhost', + path: '/' + }] + }) + .toFile(filename, function (error, pdf) { + server.close() + t.error(error, 'There must be no render error') + t.assert(fs.existsSync(pdf.filename), 'Saves the pdf') + }) + }) +})