Using iron-router, create a server-side route to serve up the PDF.
Router.route '/api/pdf/:_id',
name : 'generatePDF'
where: 'server'
action: ->
document= Documents.findOne @params._id
sanitize = Meteor.npmRequire 'sanitize-filename'
filename = sanitize(document.title).replace(/\s+/g, '-')
@response.writeHead 200,
'Content-Type': 'application/pdf'
'Content-Disposition': "attachment; filename=#{filename}.pdf"
markdown = document.content
Async.runSync (done)->
Meteor.npmRequire('markdown-pdf')
.from.string(markdown)
.to.buffer (err, buffer)->
done null, new BufferStream buffer
.result.pipe @response
The route above receives an _id as a route parameter. This is used to retrieve the associated document from the Documents collection. The PDF filename is then generated by sanitizing the document.title replacing all spaces with hyphens.
The response headers are now set to force the browser to download the PDF as a file with the sanitized filename.
The PDF is generated from the document.content markdown using the markdown-pdf package. This process is complicated by two issues:
The call to generate the PDF is intrinsically asynchronous and therefore requires a callback. This needs to be converted to a synchronous call by wrapping it in Meteor's Async.runSynch method. This returns an object with a result property we can use.
The markdown-pdf package has a to.buffer method that returns a buffer containing the generated PDF. This allows us to keep everything in code and removes any need to save a temporary file to the server. To pipe this buffer into the response we need to convert it to a stream. I use a helper BufferStream object to do this for me (see below)
With this route in place I just need to place a 'Download as PDF' button somewhere on my display template (the code below is a Jade link styled as a button by Bootstrap 3 classes)
a.btn.btn-primary(href='{{pathFor "generatePDF"}}' target='_blank') Download as PDF
And finally, here is that BufferStream helper class:
stream = Meteor.npmRequire "stream"
class @BufferStream extends stream.Readable
constructor: (@source, @offset = 0) ->
throw new Meteor.Error 'InvalidBuffer', 'BufferStream source must be a buffer.' unless Buffer.isBuffer(@source)
super
@length = @source.length
_read: (size) ->
if @offset < @length
@push @source.slice @offset, @offset + size
@offset += size
@push null if @offset >= @length