Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

PDF: Snappy, wkhtmltopdf & Template Setup

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

How can we make the email we're sending from the console command cooler? By adding an attachment! Wait, hmm. That's probably too easy - Mailer makes attachments simple. Ok, then... how about this: in addition to having the table inside the email that summarizes what the author wrote during the past week, let's generate a PDF with a similar table and attach it to the email.

So that's the first challenge: generating a styled PDF... and hopefully enjoying the process!

Installing Snappy & wkhtmltopdf

My favorite tool for creating PDFs is called Snappy. Fly over to your terminal and install it with:

composer require "knplabs/knp-snappy-bundle:^1.6"

Snappy is a wrapper around a command-line utility called wkhtmltopdf. It has some quirks, but is a super powerful tool: you create some HTML that's styled with CSS, give it to wkhtmltopdf, it renders it like a browser would, and gives you back a PDF version. Snappy makes working with wkhtmltopdf pretty easy, but you'll need to make sure it's installed on your system. I installed it on my Mac via brew.

wkhtmltopdf --version

Also, check where it's installed with which or whereis:

which wkhtmltopdf

Mine is installed at /usr/local/bin/wkhtmltopdf. If your binary live somewhere else, you'll need to tweak some config. When we installed the bundle, the bundle's recipe added a new section to the bottom of our .env file with two new environment variables.

46 lines .env
... lines 1 - 41
###> knplabs/knp-snappy-bundle ###
WKHTMLTOPDF_PATH=/usr/local/bin/wkhtmltopdf
WKHTMLTOIMAGE_PATH=/usr/local/bin/wkhtmltoimage
###

These are both used inside a new knp_snappy.yaml file that was also added by the bundle.

knp_snappy:
pdf:
enabled: true
binary: '%env(WKHTMLTOPDF_PATH)%'
options: []
image:
enabled: true
binary: '%env(WKHTMLTOIMAGE_PATH)%'
options: []

The WKHTMLTOPDF_PATH variable already equals what I have on my machine. So if your path is different, copy this, paste it to your .env.local file, and customize it. Oh, and don't worry about wkhtmltoimage: we won't use that utility.

Creating the PDF Templates

Ultimately, to create the PDF, we're going to render a template with Twig and pass the HTML from that to Snappy so it can do its work. Open up templates/email/author-weekly-report.html.twig.

{% extends 'email/emailBase.html.twig' %}
{% block content %}
<hr>
<spacer size="20"></spacer>
<row>
<columns>
<p>
What a week {{ email.toName }}! Here's a quick review of what you've been up to on the Space Bar this week
</p>
</columns>
</row>
<row>
<columns>
<table>
<tr>
<th>#</th>
<th>Title</th>
<th>Comments</th>
</tr>
{% for article in articles %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ article.title }}</td>
<td>{{ article.comments|length }}</td>
</tr>
{% endfor %}
</table>
</columns>
</row>
<row>
<columns>
<center>
<spacer size="20"></spacer>
<button href="{{ url('app_homepage') }}">Check on the Space Bar</button>
<spacer size="20"></spacer>
</center>
</columns>
</row>
{% endblock %}

Hmm. In theory, we could just render this template and use its HTML. But... that won't work because it relies on the special email variable. And more importantly, we probably don't want the PDF to look exactly like the email - we don't want the logo on top, for example.

No problem: let's do some organizing! Copy the table code. Then, in the templates/email directory, I'll create a new file called _report-table.html.twig and paste!

<table>
<tr>
<th>#</th>
<th>Title</th>
<th>Comments</th>
</tr>
{% for article in articles %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ article.title }}</td>
<td>{{ article.comments|length }}</td>
</tr>
{% endfor %}
</table>

Let's make this fancier by adding class="table table-striped". Oo, fancy!

<table class="table table-striped">
... lines 2 - 13
</table>

Those CSS classes come from Bootstrap CSS, which our site uses, but our emails do not. So when we render this table in the email, these won't do anything. But my hope is that when we generate the PDF, we will include Bootstrap CSS and our table will look pretty.

Back in author-weekly-report.html.twig, take out that table and just say {{ include('email/_report-table.html.twig') }}

... lines 1 - 2
{% block content %}
... lines 4 - 12
<row>
<columns>
{{ include('email/_report-table.html.twig') }}
</columns>
</row>
... lines 18 - 26
{% endblock %}

Now we can create a template that we will render to get the HTML for the PDF. Well, we could just render this _report-table.html.twig template... but because it doesn't have an HTML body or CSS, it would look... simply awful.

Instead, in templates/email/, create a new file: author-weekly-report-pdf.html.twig. To add some basic HTML, I'll use a PhpStorm shortcut that I just learned! Add an exclamation point then hit "tab". Boom! Thanks Victor!

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
</body>
</html>

Because we're going to add Bootstrap CSS to this template, let's add a little Bootstrap structure: <div class="container">, <div class="row"> and <div class="col-sm-12">.

... lines 1 - 11
<body>
<div class="container">
<div class="row">
<div class="col-sm-12">
... lines 16 - 18
</div>
</div>
</div>
</body>
... lines 23 - 24

Inside, how about an <h1> with "Weekly Report" and today's date, which we can get with {{ 'now'|date('Y-m-d') }}.

... lines 1 - 11
<body>
<div class="container">
<div class="row">
<div class="col-sm-12">
<h1>Weekly report {{ 'now'|date('Y-m-d') }}</h1>
... lines 17 - 18
</div>
</div>
</div>
</body>
... lines 23 - 24

Bring in the table with {{ include('email/_report-table.html.twig') }}.

... lines 1 - 11
<body>
<div class="container">
<div class="row">
<div class="col-sm-12">
<h1>Weekly report {{ 'now'|date('Y-m-d') }}</h1>
{{ include('email/_report-table.html.twig') }}
</div>
</div>
</div>
</body>
... lines 23 - 24

Adding CSS to the Template

If we just rendered this and passed the HTML to Snappy, it would work, but would contain no CSS styling... so it would look like it was designed in the 90's. If you look in base.html.twig, this project uses Webpack Encore. The encore_entry_link_tags() function basically adds the base CSS, which includes Bootstrap.

Copy this line, close that template, and add this to the PDF template.

... lines 1 - 2
<head>
... lines 4 - 9
{{ encore_entry_link_tags('app') }}
</head>
... lines 12 - 24

Even if you're not using Encore, the point is that an easy way to style your PDF is by bringing in the same CSS that your site uses. Oh, and because our site has a gray background... but I want my PDF to not share that specific styling, I'll hack in a background-color: #fff.

By the way, if our app needed to generate multiple PDF files, I would absolutely create a PDF "base template" - like pdfBase.html.twig - so that every PDF could share the same look and feel. Also, I'm not bringing in any JavaScript tags, but you could if your JavaScript is responsible for helping render how your page looks.

Ok, we're ready! Next, let's use Snappy to create the PDF, attach it to the email and high-five ourselves. Because celebrating victories is important!

Leave a comment!

20
Login or Register to join the conversation
Kevin S. Avatar
Kevin S. Avatar Kevin S. | posted 3 years ago

Though using wkhtmltopdf, was for me, a useful tool at the time, it certainly isn't perfect and can cause issue with complicated HTML/CSS. I do not use wkhtmltopdf anymore and it's very rare for a release to occur. This was one of the main reasons I dropped use of it, as there are some fixes in the alpha version that would have resolve some issues, but has been in alpha for over 2-3 years. Plus, it's trying re-invent the wheel with a browser.

An even more useful tutorial would be to use Puppeteer and Chromium to render the page "exactly" as it would look in a browser.

For a less complicated option, MPDF would also be a good choice, though you have to be careful with the CSS - but perfectly easy to manage this.

I'd be happy to share my experiences with Puppeteer and Chromium and a solid way of managing these dependencies for a project.

1 Reply

Yo Kevin S.!

Yea, it's SO true that their releases are a mess these days and we've had compatibility issues getting it installed in certain systems :/. We haven't had many problems with the rendering - though we have had a few minor issues.

> For a less complicated option, MPDF would also be a good choice, though you have to be careful with the CSS - but perfectly easy to manage this

What do you mean by "have to be careful with the CSS"?

> I'd be happy to share my experiences with Puppeteer and Chromium and a solid way of managing these dependencies for a project

Sure! I'd love to hear about this - it looks like it's a Node library and you can write a pretty small script that would do, basically something similar to wkhtmltopdf?

Cheers!

1 Reply
Kevin S. Avatar

Hi!

MPDF does support CSS, but you'll find what your design for a modern browser, the results from MPDF aren't the same and can be wildly different. It's possible to resolve these by fiddling with the CSS. Just depends on your project and having to keep a set of additional CSS styles just for PDF generation.

With node, you can install Puppeteer (which comes with Chromium) and you use a simple Node JS script to call a URL and then convert it to PDF.

The reason I'm quite passionate about this subject is, that I was solely working on a project (6 years old now) that did the following;

* Used wkthmltopdf to render SPA app and hit many issues with rendering, was never perfect and was prone to issues on attempting to wait for the JS to finish rendering
* Used Firefox in headless mode, which requires the use of X Server to do it and that can be a world of pain and it's not always reliable. Maintenance hell and really a security risk having this on a webserver.

About 2 months ago, I rewrote the whole PDF processor as a microservice, using Symfony 4 and Puppeteer and the results produced were perfect, as well as being a lot faster and not needing to run X Server. Also, the set-up is much more secure, as the service can only be called from within our internal network - a much better set-up.

You do need to install gtk3 and libXScrnSaver from your OS packager manager (yum for example) and that's it - much easier to manage packages than having to manage installing a binary.

I can put together a simple working Symfony project to get you up and running, and perhaps, with your pizzazz, you can turn it into an "awesome" tutorial for the SymfonyCasts community ? :)

2 Reply

There is also https://weasyprint.org/ . It does not support forms and JavaScript but looks promising.

Reply

Hmm, that's a good one. And maybe https://pdflayer.com/ if you want something as a paid-service? I haven't used it yet - but having this HTML->PDF generation as a service... would be nice for lazy people like me ;)

Reply

pdflayer seems to use wkthmltopdf under the hood. :) It generates files with creator property set to qt. qt is toolkit library used for GUI application aaaand used by wkhtmltopdf to generate pdfs. :)

Reply
ChrisV Avatar

Hey Ryan, the encore_entry_link_tags() doesn't (or no longer) work... It generates an absolute BROWSER path, meaning it starts from the public folder as root, like the page is served to the browser. But WKHTMLTOPDF is not actually served from the browser, so the paths need to be absolute to your filesystem.

Am I missing some extra configuration you did for this or did it maybe break over time? (considering we are quite a few versions of ahead since the making of this article)

Reply

Hey @ChrisV!

Hmm. Actually, iirc, the absolute paths are exactly what you want. Behind the scenes, wkhtmltopdf renders the HTML like a web page, including grabbing any external JS, CSS or images.

Btw, another option is to inline all the JS and CSS - e.g. by using inline_css() or writing a quick Twig extension that loads all of the CSS & JS files you need and returns their contents as a string (so you can dump them into script and link tags). Then no external requests to CSS or JS would be needed.

Cheers!

1 Reply
ChrisV Avatar

Well yes, absolute paths are what I want/need, but absolute to the filesystem, not the browser path (the URI bit). But since WKHTMLTOPDF is not rendering inside a browser, it won't find the css files correctly. If I simple do encore_entry_link_tags(), my css doesn't work because the paths are not found.

Therefore I did indeed already add all the css inline, but i'm running into problems with fonts.

We use Google Fonts, but loading them externally is often resulting in connection problems. Who knows why, maybe a ratelimit of some sorts. So I'm trying to include the ttf font in the assets bundle. But this won't work in WKHTMLTOPDF because the all the paths are relative to the public dir (absolute to the URL). This means the font files can not be found. Making the path hardcoded absolute doesn't work either because DEV / TEST / PROD environment all have different filesystems...

The only other alternative is to base64-encode the font en include them hardcoded in the inline css, but that seems quite dirty...

Reply

Hey Chris!

We use Google Fonts, but loading them externally is often resulting in connection problems. Who knows why, maybe a ratelimit of some sorts

I've noticed that wkhtmltopdf can sometimes be "finicky" with external files also. It works some times, but not all the times.

So I'm trying to include the ttf font in the assets bundle. But this won't work in WKHTMLTOPDF because the all the paths are relative to the public dir (absolute to the URL). This means the font files can not be found. Making the path hardcoded absolute doesn't work either because DEV / TEST / PROD environment all have different filesystems...

To be honest, I'm not sure :/. We do "inline" the CSS for our PDFs like we've been discussing. But we haven't had any problems with the fonts. I would say that I don't have any practical problem with embedding the fonts as base64 - that might be a nice workaround. We're effectively doing that already for the CSS by inlining it, so why not also make sure that the fonts are entirely "inside" of the source. It seems ok if it gets this working. PDF's are such a pain to deal with.

Cheers!

Reply
davidmintz Avatar
davidmintz Avatar davidmintz | posted 1 year ago

I infer from some of these comments that this tutorial is kind of dated, and I should probaby look elsewhere for a PDF generation library for my Symfony 6 project. Just wondering if anyone has any recent/new suggestions. Do you still like https://github.com/dompdf/d... I'm planning to give it a try.

Reply

Hey davidmintz!

I haven't had a chance to use DomPDF yet but I believe that it IS the way forward - so definitely try it :).

Cheers!

Reply
Default user avatar

Hello,

Since we migrated from symfony 4 to 5, all my PDFs generated with snappy and wkhtmltopdf are "zoomed off" by around 25%, leaving a padding on the right and on the bottom of each page.
Have you ever experienced that ?

I do not reproduce with wkhtmltopdf 0.12.5 but the binary is now only available on 0.12.6 and it doesn't work with this version on my server.
And i am usign KnpSnappy 1.9

I tried to find a solution on the internet but without success...

Reply

Hey Pascal!

Hmm. I have not experienced this. But this is the pain of wkhtmltopdf: the version of the binary are super tricky to get correct in each environment :/. If you have enough problems, I'd recommend trying: https://github.com/dompdf/d.... In general, I think wkhtmltopdf is (and has been for awhile) kind of dead. And (finally) some new solutions are being proposed.

Cheres!

1 Reply
Kehlhoffner E. Avatar
Kehlhoffner E. Avatar Kehlhoffner E. | posted 2 years ago | edited

Hello
i use knpSnappy
I wanted to know if it was possible to recover content to generate in JS in the TWIG and then generate a pdf
Currently, I cannot retrieve my js :

`

<!DOCTYPE html>
<html lang="fr" >
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Trombi</title>
    
    {% for path in encore_entry_css_files('app') %}
        <link rel="stylesheet" href="{{ absolute_url(path) }}">
    {% endfor %}
</head>
<body>
<div id="result"></div>

...
S1 :

    {% for path in encore_script_tags('myPath') %}
        <script type="text/javascript" src="{{ absolute_url(path) }}">
    {% endfor %}

S2:

    {% for path in encore_entry_js_files('myPath')%}
       <script type="text/javascript" src="{{ absolute_url(path) }}">
    {% endfor %}

S3:

{% macro encore_absolute_link_tags(entry_point) %}
    {% for file in encore_entry_css_files(entry_point) %}
        <link href="{{ absolute_url(asset(file)) }}" rel="stylesheet" />
    {% endfor %}
{% endmacro %}

</body>
</html>
`

What is the right approach knowing that I am generating charts.
Is it possible ?

Thanks

Reply

Hi Kehlhoffner E.!

Hmm. You mentioned:

> Currently, I cannot retrieve my js

Do you know the nature of the problem? Are the script tags generating with the wrong path? Are they not generating at all? Let me know, and I'll do my best to offer some advice :)

Cheers!

Reply
Abelardo Avatar
Abelardo Avatar Abelardo | posted 3 years ago | edited

Hi there!

composer require knplabs/knp-snappy-bundle

outputs this message:

`Problem 1

- Installation request for knplabs/knp-snappy-bundle ^1.6 -> satisfiable by knplabs/knp-snappy-bundle[v1.6.0].
- knplabs/knp-snappy-bundle v1.6.0 requires symfony/framework-bundle ~2.7|~3.0|^4.0 -> no matching package found.`

:)

Brs.

Reply

Hey

Are you trying to install KnpSnappyBundle on Symfony 5? Or what version of Symfony do you have? Because looks like it is not ready for Symfony 5 yet as you can see from composer.json: https://github.com/KnpLabs/... though there's a PR for this: https://github.com/KnpLabs/...

Cheers!

Reply
Abelardo Avatar

Hi there!

I am using

"extra": {
"symfony": {
"allow-contrib": false,
"require": "4.4.*"
}
}

Best regards.

Reply

Hey AbelardoLG,

Hm, what version of "symfony/framework-bundle" do you have installed? You can check it with:

$ composer info symfony/framework-bundle

Or you don't have this framework bundle at all? Try to upgrade this first, because from the output I see knplabs/knp-snappy-bundle v1.6.0 requires symfony/framework-bundle ~2.7|~3.0|^4.0 and your "4.4.*" you have in extra should fit the "^4.0" pattern.

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial is built on Symfony 4.3, but will work well with Symfony 4.4 or 5.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "aws/aws-sdk-php": "^3.87", // 3.110.11
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-snappy-bundle": "^1.6", // v1.6.0
        "knplabs/knp-time-bundle": "^1.8", // v1.9.1
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.23
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "league/html-to-markdown": "^4.8", // 4.8.2
        "liip/imagine-bundle": "^2.1", // 2.1.0
        "nexylan/slack-bundle": "^2.1,<2.2.0", // v2.1.0
        "oneup/flysystem-bundle": "^3.0", // 3.1.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.4.1
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.3.4
        "symfony/console": "^4.0", // v4.3.4
        "symfony/flex": "^1.9", // v1.17.6
        "symfony/form": "^4.0", // v4.3.4
        "symfony/framework-bundle": "^4.0", // v4.3.4
        "symfony/mailer": "4.3.*", // v4.3.4
        "symfony/messenger": "4.3.*", // v4.3.4
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.3.4
        "symfony/sendgrid-mailer": "4.3.*", // v4.3.4
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "^4.0", // v4.3.4
        "symfony/twig-pack": "^1.0", // v1.0.0
        "symfony/validator": "^4.0", // v4.3.4
        "symfony/web-server-bundle": "^4.0", // v4.3.4
        "symfony/webpack-encore-bundle": "^1.4", // v1.6.2
        "symfony/yaml": "^4.0", // v4.3.4
        "twig/cssinliner-extra": "^2.12", // v2.12.0
        "twig/extensions": "^1.5", // v1.5.4
        "twig/inky-extra": "^2.12" // v2.12.0
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.2.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/browser-kit": "4.3.*", // v4.3.5
        "symfony/debug-bundle": "^3.3|^4.0", // v4.3.4
        "symfony/dotenv": "^4.0", // v4.3.4
        "symfony/maker-bundle": "^1.0", // v1.13.0
        "symfony/monolog-bundle": "^3.0", // v3.4.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.3.4
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "^3.3|^4.0" // v4.3.4
    }
}
userVoice