Chapters
-
Course Code
Subscribe to download the code!Compatible PHP versions: ^7.1.3
Subscribe to download the code!Compatible PHP versions: ^7.1.3
-
This Video
Subscribe to download the video!
Subscribe to download the video!
-
Subtitles
Subscribe to download the subtitles!
Subscribe to download the subtitles!
-
Course Script
Subscribe to download the script!
Subscribe to download the script!
Styling PDFs with CSS
Scroll down to the script below, click on any sentence (including terminal blocks) to jump to that spot in the video!
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOur PDF attachment looks terrible. I don't know why, but the CSS is definitely not working.
Debugging this can be tricky because, even though this was originally generated from an HTML page, we can't exactly "Inspect Element" on a PDF to see what went wrong.
So... let's... think about what's happening. The encore_entry_link_tags()
function creates one or more link tags to CSS files, which live in the public/build/
directory. But the paths it generates are relative - like href="/build/app.css"
.
We also know that the getOutputFromHtml()
method works by taking the HTML, saving it to a temporary file and then effectively loading that file in a browser... and creating a PDF from what it looks like. If you load a random HTML file on your computer into a browser... and that HTML file has a CSS link tag to /build/app.css
, what would happen? Well, it would look for that file on the filesystem - like literally a /build/
directory at the root of your drive.
That is what's happening behind the scenes. So, the CSS never loads... and the PDF looks like it was designed... well... by me. We can do better.
Making Absolute CSS Paths
Once you understand what's going on, the fix is pretty simple. Replace {{ encore_entry_link_tags() }}
with {% for path in encore_entry_css_files('app') %}
.
Show Lines
|
// ... lines 1 - 2 |
<head> | |
Show Lines
|
// ... lines 4 - 9 |
{% for path in encore_entry_css_files('app') %} | |
Show Lines
|
// ... line 11 |
{% endfor %} | |
</head> | |
Show Lines
|
// ... lines 14 - 26 |
Instead of printing all the link tags for all the CSS files we need, this allows us to loop over them. Inside, add <link rel="stylesheet" href="">
and then make the path absolute with absolute_url(path)
.
Show Lines
|
// ... lines 1 - 2 |
<head> | |
Show Lines
|
// ... lines 4 - 9 |
{% for path in encore_entry_css_files('app') %} | |
<link rel="stylesheet" href="{{ absolute_url(path) }}"> | |
{% endfor %} | |
</head> | |
Show Lines
|
// ... lines 14 - 26 |
We saw this earlier: we used it to make sure the path to our logo - before we embedded it - contained the hostname. Now when wkhtmltopdf
, more or less, opens the temporary HTML file in a browser, it will download the CSS from our public site and all should be happy with the world.
Let's try it! Run the console command:
php bin/console app:author-weekly-report:send
Move back over and... I'll refresh Mailtrap... great! 2 new emails. Check the attachment on the first one. It looks perfect! I mean, hopefully you're better at styling than I am... and can make this look even better, maybe with a hot-pink background and unicorn Emojis? I'm still working on my vision. The point is: the CSS is being loaded.
Let's check the other email to be sure. What? This one looks terrible! The first PDF is good... and the second one... which was generated the exact same way... has no styling!? What madness is this!?
Encore: Missing CSS after First PDF?
This is a little gotcha that's specific to Encore. For reasons that are... not that interesting right now - you can ask me in the comments - when you call an Encore Twig function the first time, it returns all the CSS files that you need for the app
entrypoint. But when we go through the loop the second time, render a second template and call encore_entry_css_files()
for a second time, Encore returns an empty array. Basically, you can only call an Encore function for an entrypoint once per request... or once per console command execution. Every time after, the method will return nothing.
There's a good reason for this... but it's totally messing us up! No worries, once you know what's going on, the fix is pretty simple. Find the constructor and add one more argument - I know, it's getting a bit crowded. It's EntrypointLookupInterface $entrypointLookup
. I'll do my normal Alt + Enter and select "Initialize fields" to create that property and set it.
Show Lines
|
// ... lines 1 - 16 |
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface; | |
Show Lines
|
// ... lines 18 - 19 |
class AuthorWeeklyReportSendCommand extends Command | |
{ | |
Show Lines
|
// ... lines 22 - 28 |
private $entrypointLookup; | |
public function __construct(UserRepository $userRepository, ArticleRepository $articleRepository, MailerInterface $mailer, Environment $twig, Pdf $pdf, EntrypointLookupInterface $entrypointLookup) | |
{ | |
Show Lines
|
// ... lines 33 - 39 |
$this->entrypointLookup = $entrypointLookup; | |
} | |
Show Lines
|
// ... lines 42 - 88 |
} |
Down below, right before we render... or right after... it won't matter, say $this->entrypointLookup->reset()
. This tells Encore to forget that it rendered anything and forces it to return the same array of CSS files on each call.
Show Lines
|
// ... lines 1 - 19 |
class AuthorWeeklyReportSendCommand extends Command | |
{ | |
Show Lines
|
// ... lines 22 - 49 |
protected function execute(InputInterface $input, OutputInterface $output) | |
{ | |
Show Lines
|
// ... lines 52 - 56 |
foreach ($authors as $author) { | |
Show Lines
|
// ... lines 58 - 62 |
if (count($articles) === 0) { | |
continue; | |
} | |
$this->entrypointLookup->reset(); | |
Show Lines
|
// ... lines 68 - 83 |
} | |
Show Lines
|
// ... lines 85 - 87 |
} | |
} |
This should make our PDF wonderful. Run the command one more time:
php bin/console app:author-weekly-report:send
Fly over to Mailtrap and... I'll refresh. Ok, two emails - let's check the second: that's the one what was broken before. The attachment... looks perfect.
Next, I like to keep my email logic close together and organized - it helps me to keep emails consistent and, honestly, remember what emails we're sending. Let's refactor the emails into a service... and eventually, use that to write a unit test.
43 Comments
Hey, I was also facing the same issues. And after searching for the solution for 2 days, I ended up with including the bootstap CDN in 'author-weekly-report-pdf.html.twig' file.
Add the cdn given here after looping through all the file links via encore_entry_css_files('app'):
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
And, the problem has gone now! :D
Hey Riya,
Hm, if you included (imported) Bootstrap in your app.scss - it should work well without manually importing that CDN. Probably make sure you're including it and it's included properly and the path is correct.
But anyway, thank you for sharing your solution with others! If it works for you - great! :)
Cheers!
Hey Victor, Although I had already imported it, it was not getting loaded via that.
Same error, forced to add the CDN by hand, yet bootstrap is correctly imported in the app.scss file :/
Hey Riya,
That's weird, I'm not sure why :/ I'm glad you found a workaround!
Cheers!
I check it again in another machine(Mac Os, and everything is installed locally, not on a docker), and it works just fine.
Hey Amin!
That is super weird. I can't think of a reason why Docker would make a difference. When you call the reset()
in your code, you are simply calling this method: https://github.com/symfony/webpack-encore-bundle/blob/787c2fdedde57788013339f05719c82ce07b6058/src/Asset/EntrypointLookup.php#L65-L71
As you can see, it reset a $returnedFiles
property back to an empty array. This property is what tracks which files have already been rendered. This isn't using any caching or anything special - it's just a property that we're resetting. Here's what I might try to debug: right before you render the template (so right after calling reset()
), try adding: dump($this->entrypointLookup)
and look at the dumped $returnedFiles
property. Is it empty? Or not? Put the same dump() line after the render call also. Is it empty there? Or not?
I think something weird is going on - as there are two people with an issue... but I don't know what it is yet :).
Cheers!
Thanks Ryan, actually I changed my OS, and everything is gone. :), I mean I am not using the previous config for my project anymore, and everything seems OK right now, if I can test in the previous environment I will inform you.
And also see https://symfonycasts.com/sc... - I think we're debugging the same issue with another user... and I think I may know what the problem is now :)
No, it doesn't, I have EntrypointLookupInterface and reset line in my code, but it doesn't work.
By the way my project is running in docker, I don't know if it is related to the problem or not?
Hey Ajie62
Interesting... Have you tried it with resetting entrypoints? or before it?
Cheers!
What do you mean by "resetting entrypoints"?
Hey Ajie62
Vladimir is talking about this code block https://symfonycasts.com/sc... specifically like 67
Cheers!
I'm gonna try this ;) I'll tell you if it's okay then. Thanks!
Ok, it didn't fix anything... The attachment is here (OK) but the CSS isn't loaded.
Hey Ajie62!
Check my comment here - https://symfonycasts.com/screencast/mailer/pdf-styles#comment-4712264760 - we need to do some debugging to figure out the issue! Also, to help debugging, it might also be useful to dump($html)
right after rendering the template and looking inside. Do you see any link
tags? Or are there none? If there are link tags, what do the the URLs look like?
Cheers!
After dumping `$html` : `<link rel="stylesheet" href="https://localhost:8000/build/app.css">`
I guess it's not supposed to be like this... hmm...
Hey Ajie62!
Actually, it is supposed to look like that. You've proven that the link tags are being rendered (so, the reset is working as we expect) AND that the URL is absolute. I think that, for some reason, when wkhtmltopdf tries to open this "page" and make a request to that URL, it's failing. It could be due to how the Symfony local web server installs the binary. Try this to prove it:
A) Start the web server with symfony serve --no-tls
. This will start the server at http://localhost:8000 - not https.
B) Update the SITE_BASE_SCHEME we set a few chapters ago - https://symfonycasts.com/screencast/mailer/route-context#codeblock-356162f44f - to http to match this
In theory, your site should work the same (except on http:// instead of https://) and the URLs to the link tags should also be http://. The key thing we're looking at is: does this fix things? If so, then we know the "https" part is the culprit. If it is, I have an idea of how to "get around it" in a reliable way.
Cheers!
I followed this workaround and it's printing correctly absolute paths. I had an issue with "http" scheme being printed instead of "https" because i work behing a reverse-proxy. Setting the TrustedProxies=ip-of-proxy does fix the pb, even if being in docker environment make uncertain about the ip to use. (I know you may try to assign fixed ip but there's other pbs there).
Well this is doing a good job :
{% for path in encore_entry_css_files('attestation_de_domiciliation') %}
<link rel="stylesheet" href="{{ absolute_url(path) }}">
{% endfor %}
But now wkhtmltopdf still seem to struggle loading css :
Do you have any idea to solve this ? (In browser the https://pdf-project.ii.dev/... is rendered ok).
The exit status code '1' says something went wrong:
stderr: "The switch --no-outline, is not support using unpatched qt, and will be ignored.QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-dockeruser'
Loading page (1/2)
[> ] 0%
[======> ] 10%
[==============================> ] 50%
Error: Failed to load https://pdf-project.ii.dev/..., with network status code 2 and http status code 0 - Connection closed
Warning: Failed to load https://pdf-project.ii.dev/... (ignore)
[============================================================] 100%
Printing pages (2/2)
[> ]
Done
Exit with code 1 due to network error: RemoteHostClosedError
"
stdout: ""
command: /usr/bin/wkhtmltopdf --lowquality --margin-bottom '254mm' --margin-left '254mm' --margin-right '254mm' --margin-top '254mm' --page-size 'A4' --no-outline --encoding 'UTF-8' --disable-javascript '/var/www/html/var/cache/dev/snappy/knp_snappy5e623d7e3e46a1.31281460.html' './pdf/file_b23bc0.pdf'.
Hey Gompali!
This is when working with wkhtmltopdf is a pain! It's an old tool, and not super friendly when it's not working. From my experience - and from the errors you have - it certainly looks like wkhtmltopdf is having a problem downloading *something* on the page - very likely CSS or JavaScript - but it could be something else. I would try 2 things:
1) Try using http everywhere - run your server in http and make sure your links use http. Let's rule out https being a problem
2) Try removing things from your page to see if you can get a successful PDF build. Like, remove the CSS and JS entirely from the page. Does it work now? If not, remove more and more. See if you can identify exactly which part of the page is causing the problem.
Let me know what you find out!
Cheers!
You're right, make this lib working is a "bummer". I pass it http, https, server local path, it - at best - ignores the .css files and images. I had it "once" right but never with correct headers and footers. I think the correct installation of the binary should be more detailed.
And at the end of the day, it's still buggy and no insurance it will keep do the job. I think I will implement a client for a python or go library to end it up in a reasonable time frame.
Hey Gompali!
> I think I will implement a client for a python or go library to end it up in a reasonable time frame.
That's not a bad idea. We use it on production, but we *have* had to fight with it, and I'm excited for there to be a different/better tool someday :). Another user discussed some alternatives in this comment: https://symfonycasts.com/sc...
Good luck and cheers!
Thks anyway.
Hi Symfony casts,
Thank you for this tutorial!
Question:
I have styling within the pdf attachment, i use e.g. a bootstrap class called "page-header' and it works just fine.
Also font-type is set so, the css is present.
This works:
<div class="container">
<div class="row">
<div class="col-sm-12">
<div class="page-header">
...
But this does not:
<div class="row">
<div class="col-sm-4">Foo</div>
<div class="col-sm-7">{{ bar }}</div>
</div>
It wil disgard the column sizes.
I've added some options to the getOutputFromHtml to make sure it has the right measurement:
$pdf = $this->pdf->getOutputFromHtml($html, [
'page-size' => 'A4',
'no-footer-line' => true,
]);
Can you help me out please?
Thank you in advance.
Hey @Annemieke-B!
Sorry for the slow reply! I have a guess at the problem. wkhtmltopdf
is very old, and it doesn't support modern CSS. So my guess is simply that you're using some modern CSS and it doesn't understand it. We've run into the same problem here on Symfonycasts. Unfortunately, rendering PDFs is annoyingly hard! But I have 2 recommendations (I was just struggling with this last week):
1) If you're able to install Chrome on your production environment (which you can do via apt-get
), try using https://github.com/chrome-php/chrome. I love how simple this looks:
$page->pdf(['printBackground' => false])->saveToFile('/foo/bar.pdf');
2) Else, I'd recommend using an API. We are about to start using https://pdfendpoint.com/. Unless you're generating many dynamic PDF's, it's quite cheap. I would actually prefer solution (1), but platform.sh (where we deploy) is an odd environment where I can't seem to get chrome installed.
Cheers!
Hi Ryan,
Thank you for responding in spite of your busy schedule.
In this case i used inky for the pdf, maybe it's not the right way, but it works.
But i will try your recommendations too, thanks.
The same problem was to me, I resolved it by replacing (services.yaml)
router.request_context.scheme: '%env(SITE_BASE_SCHEME)%'
router.request_context.host: '%env(SITE_BASE_HOST)%'
with
env(SITE_BASE_URL): '%env(SITE_BASE_SCHEME)%://%env(SITE_BASE_HOST)%'
Now works fine, every style loaded successfully
Hallo, I don't want to use encore/webpack so how can I rewrite this:
{% for path in encore_entry_css_files('app') %}
<link rel="stylesheet" href="{{ absolute_url(path) }}">
{% endfor %}
Thanks for any hint
Hey Götz V.!
For this, you can use absolute_url
along with your normal path to your CSS file. For example, suppose you have a file that lives in public/css/emails.css
. In that case, the public path to that asset would be /css/emails.css
. But, of course, you need to make it absolute :). So try this:
<link rel="stylesheet" href="{{ absolute_url(asset('css/emails.css')) }}">
Let me know if that works!
Cheers!
Thanks for the quick repIy! I tried it with: <link rel="stylesheet" href="{{ absolute_url(asset('build/css/email.css')) }}">
For debugging I put the {{ absolute_url(asset('build/css/email.css')) }}
in the pdf and it renders: http://localhost:8000/build/css/foundation-emails.css
. But I need the path relative to the temp pdf or am I wrong?
Hey Verdi,
The problem is that when you generate PDF files - you need to specify the absolute URLs to your assets, relative ones won't work unfortunately, at least when we're talking about Wkhtmltopdf tool - IIRC it's written in their docs :)
So, just generate absolute URLs and it should work.
Cheers!
Thank you.
I have to thing about an alternative to Wkhtmltopdf.
Hey Verdi,
Why? Because you can't use absolute links for some reasons? Well, as an alternative solution, you can use inline styles, i.e. add "style" tag inside the "head" tag and put all the necessary styles there. If you have all those styles in a separate file - I'd recommend you to look at inline_css Twig filter: https://github.com/twigphp/... - you can install this into your project and use in your Twig templates to convert styles from files to inline styles, that should do the trick.
I hope this helps :)
Cheers!
Hey Victor,
just tried:
{% apply inline_css(source('@styles/email.css')) %}
...
{% endapply %}
around my code. It works perfect! Thanks a lot!
Hey Verdi,
That's AWESOME! Thank for confirming it works for you, and for sharing it to others!
Cheers!
Hi Victor,
the use of inline styles is a good solution ( I remember it was used in the cast :)) Thanks for the hint. I will give it a try!
any way to get rid of $this->entrypointLookup->reset() every time we call render ?
Hey Pedro,
Unfortunately, that's the known compromise in Encore. If you render a few times in a single request - you have to add that reset() call. But thanks to this Encore works more stable, as you don't need to worry about rendering your assets twice somewhere on the page, Encore already handles it for you.
Cheers!
Hello
For my part it does not work with app.css (bootstrap), I have an error.
he exit status code '136' says something went wrong:
stderr: "Loading pages (1/6)
[=======>] 10 %%
Floating p ==================>] 48%
anointed exception (core dumped)
I tried with a simple css it works. but not the app.css
cordially
Hey @zakaria!
When wkhtmltopdf works, it's great! When it doesn't, it's super hard to debug :/. I have not seen this "anointed exception" before. To see if it's working at all, I would try to render VERY simple HTML as a PDF - like maybe literally just an <h1> with NO CSS at all. If that works, then you can re-add more HTML little-by-little and CSS. If it does *not* work, then there may be a bigger setup issue.
Cheers!
"Houston: no signs of life"
Start the conversation!
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
"doctrine/annotations": "^1.0", // 1.10.1
"doctrine/doctrine-bundle": "^1.6.10", // 1.11.2
"doctrine/doctrine-migrations-bundle": "^1.3|^2.0", // v2.0.0
"doctrine/orm": "^2.5.11", // v2.7.2
"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
"phpdocumentor/reflection-docblock": "^3.0|^4.0", // 4.3.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.21.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/property-access": "4.3.*", // v4.3.4
"symfony/property-info": "4.3.*", // v4.3.4
"symfony/security-bundle": "^4.0", // v4.3.4
"symfony/sendgrid-mailer": "4.3.*", // v4.3.4
"symfony/serializer": "4.3.*", // v4.3.4
"symfony/twig-bundle": "^4.0", // v4.3.4
"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/extra-bundle": "^2.12|^3.0", // v2.12.1
"twig/inky-extra": "^2.12", // v2.12.0
"twig/twig": "^2.12|^3.0" // v2.13.1
},
"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/stopwatch": "4.3.*", // v4.3.4
"symfony/var-dumper": "^3.3|^4.0", // v4.3.4
"symfony/web-profiler-bundle": "4.3.*" // v4.3.4
}
}
Hi, I followed each step of this chapter until replacing `{{ encore_entry_link_tags('app') }}` by the for loop. Then I tried sending the emails again, and downloaded the pdf, but the CSS isn't applied. I wonder what's going on here.