Skip to content

Generate PDF document with Weasyprint🔗

How it work🔗

  • We setup a local route that serves a HTML version of our document, with a dedicated print stylesheet.
  • We "print" this page to PDF using Weasyprint.

The PDF generator engine🔗

The weasyprint executable🔗

The weasyprint executable is a python package. There are several ways to make it available to the PHP app: - Through a docker image (recommended) - By direct installation

Docker image🔗

Weasyprint is directly run through a docker image, on the fly, so it doesn't need to be installed at all. This is true for both development and production environements.

To do so, declare the following env var:

1
WEASYPRINT_PATH='docker run --rm --network=host elao/weasyprint:53'

Typically in your .env for development, and through provisionning in production.

Note: If you're using custom fonts, be sure to make them available in the weasyprint docker container with this option: -v %kernel.project_dir%/assets/fonts:/usr/share/fonts.

Direct installation🔗

You can always install the weasyprint executable directly in your environment: - through the debian package - as a Python package

In that case the executable will be directly available, and you can declare the env var as follow:

1
WEASYPRINT_PATH=weasyprint

The printer service🔗

The printer service will call the weasyprint executable on a local route of our app and stream its output to a file in the local filesystem:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?php

namespace App\Service;

use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

class WeasyprintPdfGenerator
{
    public function __construct(
        private UrlGeneratorInterface $urlGenerator,
        private string $bin = 'weasyprint',
        private string $host = 'http://localhost'
    ) {
    }

    /**
     * Print the given route to PDF and save it on the disk.
     *
     * @param string $filepath   Destination PDF file path
     * @param string $name       Name of the route
     * @param array  $parameters Route parameters
     *
     * @return string The file path
     */
    public function print(string $filepath, string $name, array $parameters = []): string
    {
        $file = fopen($filepath, 'w');

        if (false === $file) {
            throw new \Exception('Could not write file "$filepath".');
        }

        $url = $this->host . $this->urlGenerator->generate($name, $parameters, UrlGeneratorInterface::ABSOLUTE_PATH);
        $process = new Process(array_merge(explode(' ', $this->bin), [$url, '-', '-f', 'pdf']));

        $process->run(function ($type, $buffer) use ($file): void {
            if (Process::ERR !== $type) {
                fwrite($file, $buffer);
            }
        });

        fclose($file);

        if (!$process->isSuccessful()) {
            throw new ProcessFailedException($process);
        }

        return $filepath;
    }
}

The service will be configured as follow:

1
2
3
4
# config/services.yaml
App\Service\WeasyprintPdfGenerator:
    $bin: '%env(WEASYPRINT_BIN)%'
    $host: '%env(WEASYPRINT_HOST)%'

Note: the WEASYPRINT_HOST env var should be set to http://localhost is most case.

The printer service can now be used in a domain-related command handler, for example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php

namespace App\MessageHandler;

use App\Service\WeasyprintPdfGenerator;
use App\Entity\Order;
use App\Message\PrintOrderCommand;
use App\Repository\OrderRepository;

class PrintOrderCommandHandler
{
    public function __construct(
        private WeasyprintPdfGenerator $pdfGenerator,
        private string $documentPath
    ) {
    }

    public function __invoke(PrintOrderCommand $command): string
    {
        // Generate the full file path:
        $filepath = implode('/', [$this->documentPath, "order-{$command->id}.pdf"]);

        // Ensure destination folder is created:
        @mkdir($this->documentPath);

        // Print the route to PDF:
        return $this->pdfGenerator->print($filepath, 'print_order', ['id' => $command->id]);
    }
}

Define document path for all services through variable binding:

1
2
# .env
DOCUMENT_PATH="%kernel.project_dir%/var/document"
1
2
3
4
5
6
7
8
9
# config/services.yaml
parameters:
    document_path: "%env(resolve:DOCUMENT_PATH)%"

services:
    _defaults:
        # ...
        bind:
            string $documentPath: '%document_path%'

The HTML document🔗

The print controller🔗

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<?php

namespace App\Controller;

use App\Entity\Order;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

#[Route('/print')]
class PrintController extends AbstractController
{
    #[Route('/order/{id}', name: 'print_order')]
    public function printOrder(Order $order): Response
    {
        return $this->render('print/order.html.twig', [
            'order' => $order,
        ]);
    }
}

Security🔗

The print route should only be accessible locally by Weasyprint, and not exposed publically.

To do so, setup the config/security.yaml as follow:

1
2
3
4
5
6
security:
    # ...
    access_control:
        # Print
        - { path: ^/print, roles: PUBLIC_ACCESS, ips: ['127.0.0.1', '::1'] }
        - { path: ^/print, roles: ROLE_NO_ACCESS }

The print template🔗

Twig template🔗

print/order.html.twig

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title 'My document' %}</title>
        {% block stylesheets %}
            {{ encore_entry_link_tags('print') }}
        {% endblock %}
    </head>
    <body class="print">
        {% block body %}
            <article class="page">
                <h1 class="title">Order #{{ order.id }}</h1>
            </article>

            <article>
                <h2 class="title break">Premièrement</h2>
                <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris elit enim, mattis sit amet ante sit amet, convallis aliquet diam. Nam id neque eget massa vehicula euismod fringilla pellentesque lorem.</p>

                <h3 class="subtitle">Deuxièmement</h3>
                <p>Praesent pharetra nibh at elit pharetra pellentesque. Curabitur ipsum ligula, condimentum vel fringilla non, elementum ac magna. Nunc eleifend odio tellus, et feugiat odio dapibus commodo.</p>
            </article>
        {% endblock %}
    </body>
</html>

The css part🔗

The styling of the document uses advanced print-specific CSS selectors such as @page , @top-right and counter(page);

Here's how to use these blocks:

assets/styles/print.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
@charset "UTF-8";

$margin: 10mm;
$header: 20mm;
$footer: 20mm;

@page {
  size: A4;
  margin: ($header + $margin) $margin ($margin + $footer) $margin;
  background-color: none;
  font-size: 10px;
  font-family: Arial, sans-serif;

  @top-right {
    margin-top: $margin;
    height: $header;
    display: block;
    font-size: 8pt;
    content: string(title); // Dynamic content
    text-align: right;
    vertical-align: middle;
    white-space: nowrap;
    z-index: 10;
  }

  @bottom-left {
    margin-top: $margin;
    height: $footer;
    display: block;
    font-size: 8pt;
    content: string(title) ' | ' string(subtitle); // Dynamic content
    text-align: left;
    vertical-align: middle;
    white-space: nowrap;
    z-index: 10;
  }

  @bottom-right {
    margin-top: $margin;
    height: $footer;
    display: block;
    font-size: 16px;
    content: "Page " counter(page); // Dynamic page count
    text-align: right;
    vertical-align: middle;
    white-space: nowrap;
    z-index: 10;
  }
}


// First page specific style
@page:first {
  margin: $margin $margin ($margin + $footer) $margin;
  background-color: blue;
  color: white

  @top-right { content: none; }
  @bottom-left { content: none; }
  @bottom-right { content: none; }
  @bottom-center {
    margin-top: $margin;
    height: $footer;
    display: block;
    font-size: 9pt;
    content: string(title); // Dynamic content
    text-align: center;
    vertical-align: middle;
    white-space: nowrap;
    z-index: 10;
  }
}

// Use to force a full page section
.page {
  height: 297mm - ($margin * 2 + $footer);
  width: 210mm - ($margin * 2);
}

// Set CSS vars from DOM content
.title { string-set: title content(); }
.subtitle { string-set: subtitle content(); }

// Page break managment
.break {
  // Always start this element on a new page
  break-before: always;
}

// Not printed
.hidden {
  height: 0;
  color: transparent;
}

A more detailed example is available in the Weasyprint repository (both HTML and CSS code).


Last update: December 20, 2024