Skip to content

Commit

Permalink
Issue 132 (#133)
Browse files Browse the repository at this point in the history
* Added event observer and argument mutation
* refactoring, removed window references in events because it felt wrong and there is no use case for it yet #132
* fixed documentation in README #132
  • Loading branch information
codecounselor committed Nov 21, 2016
1 parent 75bf7eb commit 4d8b36b
Show file tree
Hide file tree
Showing 3 changed files with 245 additions and 53 deletions.
26 changes: 26 additions & 0 deletions README.md
Expand Up @@ -143,6 +143,32 @@ In your application, at the point which the view is ready for rendering
document.body.dispatchEvent(new Event('view-ready'))
```

#### Observing your own event

If the page you are rending is under your control, and you wish to modify the behavior
of the rendering process you can use a [CustomEvent](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent)
and an observer that will be triggered after the view is ready but before it is captured.

##### your-page.html

```javascript
document.body.dispatchEvent(new CustomEvent('view-ready', { detail: {layout: landscape} }))
```

##### your-exporter.js
As an example, suppose you wanted to change the orientation of the PDF

```javascript
job.observeReadyEvent( (detail) => {
return new Promise( (resolve,reject) => {
if( detail && detail.landscape ){
job.changeArgValue('landscape', true)
}
resolve()
})
})
```

All Available Options
-----

Expand Down
268 changes: 217 additions & 51 deletions lib/exportJob.js
Expand Up @@ -22,6 +22,10 @@ const HTML_DPI = 96
const MICRONS_INCH_RATIO = 25400
const MAX_EVENT_WAIT = 10000

const DEFAULT_OPTIONS = {
closeWindow: true
}

class ExportJob extends EventEmitter {

/**
Expand All @@ -31,17 +35,23 @@ class ExportJob extends EventEmitter {
* @param output The name of the file to export to. If the extension is
* '.png' then a PNG image will be generated instead of a PDF.
* @param args {Object} the minimist arg object
* @param options {Object} electron-pdf options
* @param options.closeWindow default:true - If set to false, the window will
* not be closed when the job is complete. This can be useful if you wish
* to reuse a window by passing it to the render function.
*
* @fires ExportJob#pdf-complete after each PDF is available on the
* filesystem
* @fires ExportJob#job-complete after all PDFs are available on the
* @fires ExportJob#export-complete after each export is available on the
* filesystem
* @fires ExportJob#job-complete after all export resources are available on
* the filesystem
*/
constructor (input, output, args) {
constructor (input, output, args, options) {
super()
this.input = _.isArray(input) ? input : [input]
this.output = output
this.args = args
this.options = _.extend({}, DEFAULT_OPTIONS, options)
logger('job options:', this.options)

if (_.startsWith(this.args.pageSize, '{')) {
this.args.pageSize = JSON.parse(this.args.pageSize)
Expand All @@ -55,31 +65,60 @@ class ExportJob extends EventEmitter {
/**
* Render markdown or html to pdf
*/
render () {
render (window) {
logger('render starting...')
const args = this.args

const win = this._launchBrowserWindow(args)
this.window = win

// TODO: Check for different domains, this is meant to support only a single origin
const firstUrl = this.input[0]
this._setSessionCookies(args.cookies, firstUrl, win.webContents.session.cookies)

const windowEvents = []
this.input.forEach((uriPath, i) => {
windowEvents.push((pageDone) => {
this._loadURL(win, uriPath, args)
const targetFile = this._getTargetFile(i)
const generateFunction = this._generateOutput.bind(this, win, targetFile, args, pageDone)
const waitFunction = this._waitForPage.bind(this, win, generateFunction, args.outputWait)

win.webContents.removeAllListeners('did-finish-load')
win.webContents.on('did-finish-load', waitFunction)
win.webContents.on('did-fail-load', (r) => {
// http://electron.atom.io/docs/api/web-contents/#event-did-fail-load
logger('load failure!')
})
win.webContents.on('did-start-loading', (r) => {
// logger('loading!')
})
win.webContents.on('dom-ready', (r) => {
logger('dom ready!')
})
win.webContents.on('did-get-response-details',
function (event,
status,
newURL,
originalURL,
httpResponseCode,
requestMethod,
referrer,
headers,
resourceType) {
// logger('resource complete:', httpResponseCode, newURL)
})

this._loadURL(win, uriPath, args)
})
})

async.series(windowEvents, (err, results) => {
win.close()
if (this.options.closeWindow) {
win.close()
}
/**
* PDF Generation Event - fires when all PDFs have been persisted to disk
* @event PDFExporter#jobj-complete
* @event PDFExporter#job-complete
* @type {object}
* @property {String} results - array of generated pdf file locations
* @property {Object} error - If an error occurred, null otherwise
Expand All @@ -88,6 +127,41 @@ class ExportJob extends EventEmitter {
})
}

/**
* If the html page requested emits a CustomEvent
* (https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent)
* you may want to act upon the information it contains.
*
* Use this method to register your own observer.
*
* @param handler {function} A callback that is passed the following:
* args[0]: the details object from CustomEvent
*
* @fires PDFExporter#window.observer.start when the observer is invoked
* @fires PDFExporter#window.observer.timeout when the promise is not observed by
* the maximum wait time (default: 10 seconds). The process will continue on and
* capture the page, it is up to the caller to handle this event accordingly.
* @fires PDFExporter#window.observer.end when the observer fulfills the promise
*/
observeReadyEvent (handler) {
this.readyEventObserver = handler
}

/**
* Change one of the arguments provided in the constructor.
* Intended to be used with observeReadyEvent
*
* Note that electron-pdf uses the fully named arguments and none of the
* aliases (i.e. 'landscape' and not 'l'). Even if you used an alias during
* initialization make sure you pass the named argument here.
*
* @param arg The full name of the argument (i.e 'landscape')
* @param value The new value
*/
changeArgValue (arg, value) {
this.args[arg] = value
}

// ***************************************************************************
// ************************* Private Functions *******************************
// ***************************************************************************
Expand Down Expand Up @@ -245,80 +319,172 @@ class ExportJob extends EventEmitter {
this._executeJSListener(eventName, generateFunction, window)
}

/**
* responsible for executing JS in the browser that will wait for the page
* to emit an event before capturing the page.
*
* @param eventName
* @param generateFunction
* @param window
* @private
*/
_executeJSListener (eventName, generateFunction, window) {
const cmd = `var ipc = require('electron').ipcRenderer
var body = document.body
body.addEventListener('${eventName}', () => ipc.send('READY_TO_RENDER'))`
var body = document.body
body.addEventListener('${eventName}',
event => {
//Detail will only exist if a CustomEvent was emitted
ipc.send('READY_TO_RENDER', event.detail)
}
)`

// Don't let things hang forever
const timeout = setTimeout(() => {
this.emit('window.event.wait.timeout', {eventName: eventName})
electron.ipcMain.removeAllListeners('READY_TO_RENDER')
generateFunction()
}, this.args.outputWait > 0 ? this.args.outputWait : MAX_EVENT_WAIT)
this.once('window.capture.start', () => clearTimeout(timeout))

this.once('window.event.wait.end', () => clearTimeout(timeout))

window.webContents.executeJavaScript(cmd)
}

/**
* Listen for the browser to emit the READY_TO_RENDER event and when it does
* emit our own event so the max load timer is removed.
*
* @param eventName this is whatever the client provided
* @param generateFunction _generateOutput with all of its arguments bound
* @private
*/
_attachIPCListener (eventName, generateFunction) {
this.emit('window.event.wait.start', {eventName: eventName})
electron.ipcMain.once('READY_TO_RENDER', generateFunction)

electron.ipcMain.once('READY_TO_RENDER', (name, customEventDetail) => {
this.emit('window.event.wait.end', {})

if (this.readyEventObserver) {
this._triggerReadyEventObserver(customEventDetail, generateFunction)
} else {
generateFunction()
}
})
}

/**
* If an event observer was set it is invoked before the generateFunction.
*
* This function must ensure that the observer does not hang.
*
* @param customEventDetail detail from the DOMs CustomEvent
* @param generateFunction callback function to capture the page
* @private
*/
_triggerReadyEventObserver (customEventDetail, generateFunction) {
/**
* fires right before a readyEventObserver is invoked
* @event PDFExporter#window.observer.start
* @type {object}
* @property {String} detail - The CustomEvent detail
*/
this.emit('window.observer.start', {detail: customEventDetail})

const timeout = setTimeout(() => {
/**
* Fires when an observer times out
* @event PDFExporter#window.observer.start
* @type {object}
*/
this.emit('window.observer.timeout', {})
generateFunction()
}, MAX_EVENT_WAIT)

this.readyEventObserver(customEventDetail).then(() => {
/**
* Fires when an observer fulfills it's promise
* @event PDFExporter#window.observer.end
* @type {object}
*/
this.emit('window.observer.end', {})
clearTimeout(timeout)
generateFunction()
})
}

// Output

/**
* Create the PDF or PNG file
* Create the PDF or PNG file.
*
* Because of timeouts and promises being resolved this function
* is implemented to be idempotent
*
* @param window
* @param outputFile
*
* @private
*/
_generateOutput (window, outputFile, args, done) {
this.emit('window.capture.start', {})
if (!this.generated) {
this.generated = true
this.emit('window.capture.start', {})

if (outputFile.toLowerCase().endsWith('.png')) {
this._captureImage(window, outputFile, done)
} else {
this._capturePDF(args, window, done, outputFile)
}
}
}

const png = outputFile.toLowerCase().endsWith('.png')
// Image (PNG)
if (png) {
window.capturePage(function (image) {
_captureImage (window, outputFile, done) {
window.webContents.capturePage(image => {
const target = path.resolve(outputFile)
fs.writeFile(target, image.toPNG(), function (err) {
this.emit('window.capture.end', {file: target, error: err})
this.emit('export-complete', {file: target})
// REMOVE pdf-complete in 2.0 - keeping for backwards compatibility
this.emit('pdf-complete', {file: target})
done(err, target)
}.bind(this))
})
}

_capturePDF (args, window, done, outputFile) {
// TODO: Validate these because if they're wrong a non-obvious error will occur
const pdfOptions = {
marginsType: args.marginsType,
printBackground: args.printBackground,
printSelectionOnly: args.printSelectionOnly,
pageSize: args.pageSize,
landscape: args.landscape
}

window.webContents.printToPDF(pdfOptions, (err, data) => {
if (err) {
this.emit('window.capture.end', {error: err})
done(err)
} else {
const target = path.resolve(outputFile)
fs.writeFile(target, image.toPNG(), function (err) {
fs.writeFile(target, data, (err) => {
if (!err) {
// REMOVE in 2.0 - keeping for backwards compatibility
this.emit('pdf-complete', {file: target})
/**
* Generation Event - fires when an export has be persisted to
* disk
* @event PDFExporter#export-complete
* @type {object}
* @property {String} file - Path to the File
*/
this.emit('export-complete', {file: target})
}
this.emit('window.capture.end', {file: target, error: err})
done(err, target)
})
})
} else { // PDF
const pdfOptions = {
marginsType: args.marginsType,
printBackground: args.printBackground,
printSelectionOnly: args.printSelectionOnly,
pageSize: args.pageSize,
landscape: args.landscape
}

window.webContents.printToPDF(pdfOptions, (err, data) => {
if (err) {
this.emit('window.capture.end', {error: err})
done(err)
} else {
const target = path.resolve(outputFile)
fs.writeFile(target, data, (err) => {
if (!err) {
/**
* PDF Generation Event - fires when a PDF has be persisted to
* disk
* @event PDFExporter#pdf-complete
* @type {object}
* @property {String} file - Path to the PDF File
*/
this.emit('pdf-complete', {file: target})
}
this.emit('window.capture.end', {file: target, error: err})
done(err, target)
})
}
})
}
})
}

/**
Expand Down

0 comments on commit 4d8b36b

Please sign in to comment.