PDFs Another Way: Custom Component + html2pdf

A while back I posted about using jsPDF, and found a way to make it work. Recently, it became more of an issue as we wanted to print nicer (i.e. send to your customer) PDFs.

As we have to produce sometimes a lot of these in a given week, I didn't want to rely on a 3rd-party API with their charges, but I wanted more control over the PDF, especially around page breaking and such. So, a quick trip back to the drawing board, and now we have an ultra-simple custom component that can be fed a string of HTML, and then print a nice version of it (depending on your CSS skills, of course!)

So, 1st-up, you need to create a custom component.

Two key things here:

  1. Our model references a temporary variable. This temp variable is where we will store the HTML string that will be our document to print
  2. You need to select Allow popups to escape sandbox

Generating the HTML to use in your document is an exercise left to the reader

Now, once we've set that up, we need to put our code together. This is going to be an extremely simple custom component.

<html>
<head>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
       <!-- put any fonts you need here -->

	<style>
                <!-- here is where you'll need to style your html for printing, especially if you're also displaying it natively inside of your retool app -->
	</style>
</head>
<body>
   <script src="https://cdn.tryretool.com/js/react.production.min.js" crossorigin></script>
   <script src="https://cdn.tryretool.com/js/react-dom.production.min.js" crossorigin></script>
       <!-- get the HTML to PDF bundle -->
   <script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.9.3/html2pdf.bundle.min.js"></script>

<script>
    // subscribe to model changes
 window.Retool.subscribe(function(model) {
   if (!model) { return }
   var content = model.content;

    // move our printed content into an element we can access later 
   document.getElementById("templateContents").innerHTML = content;   
 });

  
 function pdprint() {
     // our function to generate and display the pdf in a new window
   var opt = {
      margin:       0.5,
      filename:     'myfile.pdf',
      image:        { type: 'jpeg', quality: 0.98 },
      html2canvas:  { scale: 2 },
      jsPDF:        { unit: 'in', format: 'letter', orientation: 'portrait' }
  };

  window.html2pdf().set(opt)
    .from(document.getElementById("templateContents").innerHTML)
    .toPdf()
    .output('bloburl')
    .then(function(pdfas) {  window.open(pdfas) } );

 }
</script>

      <!-- now, you just need a button to trigger the printing (you must take an action to open the new window) -->
   <button id="pdfDownloadLink" href="" onclick="pdprint(); return false">Download PDF</button>
        <!-- ... and a place to put our html conten -->
   <div id="templateContents" style="display:none"></div>
 </body>
</html>

That's it, now you have a button that when you click it, it will put the contents of the variable you referenced in the model into a PDF and then open that PDF in a new window.

They key here is that the opening of the PDF in a new window is triggered as a result of you hitting the button (to get around sandbox security issues), and you don't rely on the native print/save function from html2pdf, but instead you take the blob as a datauri, and pass it to window.open().

As you now have full control over the CSS (within the custom component), you can go all out, and make your PDFs look great without relying on any 3rd-party services. You will, of course, need to flex your CSS muscles as printing to a PDF has its own little gotchas.

8 Likes

Wait, you aren't going to make my HTML for me as well!? :laughing: :laughing:

Nicely done!

I may play with this to replace Carbone.io for some things, but I am also creating xlsx files with Carbone so can't get rid it entirely, sigh.) Carbone is still a viable option for those less skillful in HTML/CSS and more do in MS Word or need to create more than PDFs.

@JoeyKarczewski you might find this interesting ^

1 Like

I tried this out and want to report back on some pros/cons I encountered.

The cons with html2pdf were threefold:

  1. There is a known issue with PDF size generated via html2pdf. html2pdf uses the canvas object and there are limitations on its size. I ended up with a couple dozen blank pages. There are workarounds involving breaking down your PDF into multiple pieces and combine them back together - and this can be complex. If you have short documents, then there are no issues with this.

  2. As @church mentioned you need a good foundation in html and css. I newly tried some of these drag/drop html editors and they still create Frankenstein code that you have to edit after the fact. If you want to make changes, do you just change the code directly or use the html editor and do your post processing again?

  3. And you need to edit/build the HTML dynamically. If you are just replacing fields then liberal use of {{ }} within an iFrame or Custom component will suffice. If you need to create tables that loop through query results and such, this is more work, but doable.

Cons with Carbone:

  1. Costs money. How much depends on your volume.
  2. A little more code overall, unless you need to do a lot of work to make your dynamic html.

So if you need a simpler, smaller PDF then html2pdf looks like a solid option. If you need a more complex, larger PDF then consider Carbone.

1 Like

Amazing workaround, thank you so much, @church

Is there any way to set the filename? The filename option in html2pdf does not work for me.

  1. This looks awesome
  2. Fully appreciate that you can't write everyone else's HTML/CSS for them, but......
  3. A single example (possibly in a working public app) would really help to get going on this, for many simple pdf outputs (e.g. no tables, no repeating items) I imagine even a novice CSS user can probably hack an example version into something useable.

Request to all community members!

Yours hopefully
Dominic

I love what you've done with this, thank you!

I've created a generic module (linked below) with a modified version of this custom component. You can insert the module into any project, pass in an HTML string, and the component will provide a base64 binary output of the PDF, which you can then render inside of a standard Retool PDF component or output using the utils.downloadFile() function. The module contains hidden elements to make it easy for you to preview the generated PDF, but you can hide the module itself in your app.

https://storage.googleapis.com/public_shares/HTML_PDF_Export.json

3 Likes

@jmikem it's working great!
I've just a problem: if I set hidden to true for htmlPdfExport1 component in the app, the pdf is empty. it seems that pdf_base64 is created only if the component is visible (if hidden it remains undefined).
I'm using a button to generate the pdf and I don't want to leave the preview visible.

@number15 So happy to hear that it's working!!

Yep, the hidden-not-generating issue is definitely a challenge due to the way that custom components currently work as iframes (I know the Retool team is aware of it and might give us some other options for custom components in the future). I've encountered this with a couple of other "data-only" modules and I usually end up just making the custom component a very small one-line / minimal width component with no visual content. In the screenshot below, I have a custom component called "twilioDevice" that I use for Twilio call handling (similar to the PDF conversion, it requires no visual display and just acts as a data handler) and I usually place this in some innocuous place in my page layout where it doesn't interfere with any other components. Note, though, that if the parent page causes a layout adjustment that changes the position of where the custom component is located (such as another component having a dynamic "hidden" condition), the custom component will completely reload itself, so keep that in mind when placing it.
image

@jmikem I did some other tests and it was working in edit but not in the app preview version (i mean the pdf generation itself).
For what I understood, the reason why was that the components were set hidden in the module itself.
I've solved it leaving only pdf_preview set to hidden, and now in edit I see the pdf generated and in the preview version it's hidden (no matter the size of the component), but the button works properly.

1 Like

I just want to bump this post and say that it is by far the best way [In my opinion] to create highly customizable and flexible PDF's via retool! No External libraires or API's required!

I was able to build a simple invoice program which allows the user to add 'line items' to a project, and it will print out a beatifully formatted invoice.

See attached Example:
The module @jmikem provided worked beautiflly. I just modifed the HTML template/handlebars data to my liking and voila - perfect invoicing system complete! Customer/Project management with billing - took all of a day or two.

Thanks again for your efforts!

2 Likes

This is great! but I tried it and the viewer content keeps refreshing and is kind of "blinking". Have you observed this?

@ofedrigo I have seen that before, I believe it occurs when there is any sort of malformed HTML - the PDF library just keeps reattempting to generate the PDF binary and it fails on a loop. I'd suggest having a look through your HTML structure and see if there's anything that might be causing it to become malformed so that the rendering library isn't processing correctly.

That works. Thank you!

O.

Is there a way to specify a file name for the pdf component download button?

@ofedrigo i use this in the JS query:

utils.downloadFile({ base64Binary: htmlPdfExport1.pdf_base64}, "AAA" + query1.selectedRow.data.name, "pdf");

The module @jmikem shared works well, there is just one major downside I realized to using html2pdf. From their documentation:

html2pdf.js renders all content into an image, then places that image into a PDF. This means text is not selectable or searchable, and causes large file sizes.

I didn't realize this until I got it all setup and working. Unfortunately, this is a dealbreaker for my project. I didn't see it mentioned anywhere else here, so just sharing to potentially save others time in the future.

1 Like

I have a component that uses jsPDF instead of html2pdf. I found jsPDF easier to use and more reliable. In addition it lets you select and search for text.

How can I pass my existing container into this to be exported in PDF?