Jump to content

PDF Production: A ProcessWire Dev's Experience


gebeer
 Share

Recommended Posts

Introduction

Automated PDF production is a quite common task for ProcessWire developers. PW does not offer core tools for that. There are some external modules that we can utilize. In this showcase I would like to share my considerations for the tool of choice and my experiences working with it.

The task at hand

PDF production for product pages on an existing ProcessWire installation

acniti.com is a Japan based company that specializes in nanotechnology solutions for gases in water. They have developed technologies to create nanobubbles in water, which can change the properties of water and improve dissolved gas levels through super saturation. Their founder and CEO has chosen ProcessWire for his website and has developed the site himself. He tasked me to add functionality for creation of downloadable PDFs from available product data.
The client was forwarded to me from Bernhard because of time constraints on his side. I really appreciate that.

The core requirements in detail:

  • Produce separate PDF files for multiple languages, including LTR languages
  • Design a template: cover page, images page, content pages, specs pages
  • Cache PDFs
  • Minimal impact on existing template code

Tool choice: RockPdf module

I had done some PDF generation before, using mPDF library directly. While there are other options available for ProcessWire (Pages2PDF, MakePDF) I decided to use Bernhard's RockPdf module since it seems the most feature rich and well maintained one. It is also based on the quite popular mPDF library.

Reasons for choosing RockPdf

Auto-reloading of PDFs in concert with RockFrontend

This can save a lot of time (and strain on the F5 key), since the PDF is automatically reloaded every time a code change is made. All it requires to achieve that is one line of code:

$pdf->save(__DIR__ . "/tmp/{$filename}.pdf", preview: true);

Easy font loading

I needed to load different fonts depending on the content language. Here is, how I did that

Spoiler
// include fonts based on language

// Latin

if (in_array($lang, self::LATINLANGS)) {

$pdf->addFont([

    'R' => dirname(__FILE__) . '/assets/fonts/Inter-Regular.ttf',

    'B' => dirname(__FILE__) . '/assets/fonts/Inter-Bold.ttf',

], 'inter', true);

$pdf->addFont([

    'R' => dirname(__FILE__) . '/assets/fonts/itc-bauhaus-demi-1361514174.ttf',

    'B' => dirname(__FILE__) . '/assets/fonts/itc-bauhaus-demi-1361514174.ttf',

], 'itcbauhaus');

}

// Arabic

if ($lang == 'arabic') {

$pdf->addFont([

    'R' => dirname(__FILE__) . '/assets/fonts/UthmanicHafs_V22.ttf',

    'B' => dirname(__FILE__) . '/assets/fonts/UthmanicHafs_V22.ttf',

], 'inter', true);

}

// Farsi (Persian)

if ($lang == 'farsi') {

$pdf->addFont([

    'R' => dirname(__FILE__) . '/assets/fonts/B-NAZANIN.TTF',

    'B' => dirname(__FILE__) . '/assets/fonts/B-NAZANIN.TTF',

], 'inter', true);

}

// Japanese

if ($lang == 'japanese') {

$pdf->addFont([

    'R' => dirname(__FILE__) . '/assets/fonts/noto-sans-jp-v52-latin-regular.ttf',

    'B' => dirname(__FILE__) . '/assets/fonts/noto-sans-jp-v52-latin-700.ttf',

], 'inter', true);

}


// add Meiryo to all languages for character substitution of 〒 glyph (used for  postal code)

$pdf->addFont([

    'R' => dirname(__FILE__) . '/assets/fonts/Meiryo.ttf',

    'B' => dirname(__FILE__) . '/assets/fonts/Meiryo.ttf',

], 'meiryo');

RockPdf CSS utility classes

They really came in handy, since they are specifically targeted at mPDF, using pt units. I could easily set some base colors with

$pdf->setLessVars([

    'col-muted' => '#dddddd',

    'col-primary' => '#0b54a2',

]);

The utility classes that I mostly used are those for widths, margins and paddings. They are quite handy for setting up the layout of the PDF.

Easy file saving to PW field

For caching the created PDFs I utilized a RockPdf convenience method to save the files to a field on the product page

$pagefile = $pdf->saveToField(

    page: $page,

    field: self::PDF_FIELD,

    filename: $filename,

    append: true,

);

Implementation

Modular approach for minimal impact on existing code

I created two modules:

Main module ProductPdf: non-autoload, holds all logic/templates for generating PDFs and required markup (download button)

Module ProductPdfHooks: autoload, hooks:

  • Page(template=product)::render displays PDF for download
  • Pages::saved(template=product|product-type) creates PDFs in all languages and saves them to field for caching

     

Re-usage of existing template logic

There was quite a lot of logic (some of it rather complex) already available in the project that I wanted to utilize. Mainly for specs table creation. I had to do some minimal refactoring of original code. Then I was able to include that logic into my module without having to duplicate it. Benefits of this approach:

  • Minimal impact on existing code
  • Easier to maintain

Challenges

Limitations of mPDF library

mPDF is not good at modern CSS features. It is quite limited in terms of CSS support. I had to do some workarounds to make it work with the layout I needed.

Different approach to styling

Although RockPdf's utility classes helped a lot, I still had to do some inline styling.

Display of complex tables

Display of tables in particular was a pain point since mPDF does a lot of automatic adjustments to column widths and distribution that I needed to disable in order to get the desired results:

// Ensures that tables retain their original proportions
$mpdf->keep_table_proportions = true;
// And adding autosize="1" attribute to tables.

Page headers, footers, margins and page breaks

The RockPdf module docs have some great examples for setting up headers and footers, margins and page breaks. I used those to set up the layout of the PDF without having to read too much into the mPDF docs.

Minimal impact on exisiting code base

This was overcome by the modular approach I described earlier and it worked out really nice. The only addition to the original product template file for rendering the download button, was calling a static method from my module:

<?= ProductPdf::renderDownloadbutton($page) ?>

That button requests the page URL with a query parameter. The display of the PDF for download is handled through a Page::render hook

Spoiler
/**

* Handles the post-render actions for product pages, specifically checking for a 'download' request

* for the product's PDF. If such a request is detected, it attempts to retrieve the PDF file associated

* with the current product page. If the file exists, it sends the file to the client without forcing a download.

*

* @param HookEvent $event The event object, from which the current page object is extracted.

* @return void

*/

public function afterRenderProduct(HookEvent $event)

{

    $page = $event->object;

    if ($this->wire->input->get('ppdf', 'text') == 'download') {



    // full disk path to PDF

    $file = ProductPdf::getPDFFile($page);



    if (!is_file((string)$file)) return;

        $filename = basename($file);

        // show PDF in browser for manual download

        header('Content-Type: application/pdf');

        header('Content-Disposition: inline; filename="' . urlencode($filename) . '"');

        header('Expires: 0');

        header('Cache-Control: must-revalidate');

        header('Pragma: public');

        header('Content-Length: ' . filesize($file));

        readfile($file);

        exit;
    }

}

PHP DOM manipulation of existing markup necessary

Since I reused existing logic for constructing specs tables, I needed to add some inline styles and change some URLs on the fly. I used native PHP DOMDocument for that.

There is a feature in the RockFrontend module that offers DOM manipulation tools with a nice API. I would have loved to use those but at the the time of working on this project, I just was not aware of their existence.

The result

Product pages on acniti.com now have a download button that allows the user to download the PDF of the product page in their language.

See it live here

acniti-pdf-download-button.thumb.png.871479dbfcbba0f2baef5d023ec23428.png

The PDF is loaded from the cache field on the page, which is updated every time a product is edited and saved. If no cache file exists, the PDF is created on-the-fly and cached for future use. It is presented to the user in a new browser tab for viewing and downloading.

The PDFs feature a clean layout / design which corresponds to the acniti branding.

Cover page: 
acniti-minigalf-ultrafine_en_cover.thumb.jpg.fa730657d63ad7f354a6d6d57a537e5e.jpg

Content pages:
acniti-minigalf-ultrafine_en_content1.thumb.jpg.174ac5acdda866f71f2ecacf998a9a52.jpg
acniti-minigalf-ultrafine_en_content2.thumb.jpg.514ecf76a4e8eac78b68512db37d53de.jpg

Specs table:
acniti-minigalf-ultrafine_en_specs.thumb.jpg.2e5b5c213b9268adc5e53ae7468190ed.jpg

Feedback from the client

The client has a lot of experience with ProcessWire which one can see from looking at their website at acniti.com. He gave me great feedback on the project:

Quote

My initial tought was to convert Processwire pages to adoc and produce pdf's that way but I didn't get my head around it. The power of asciiDoctor is it produces great manuals using strict rules and formatting. It lacks the flexibility like a Photoshop application. So i checked RockPDF got in contact with both of you (Bernard and myself) and i think we had a very smooth process implementing it. When starting the process we were not sure RTL was going to work? Japanese fonts going to work? I am very happy you could reuse my logic in the tables because their are a lot of calculations converting metric values to imperial values. To get those tables right in the past was quite time consuming so not having to go over this process again but reusing big bonus.

At the end of the project RTL was smooth Japanese fonts went smooth.(...) Also in the project you never came to me and said impossible not going to work.

I think pdf is just not an easy topic. We use dolibarr as our ERP system built in php. To produce the 1 page PDF invoice it goes through 1977 lines of code.

Anyway I am very happy with the result and amazed by the amount of people that like to have a pdf.(...)

Erik van Berkum, acniti LLC

Lessons Learned and conclusion

PDF creation in PHP is still not an easy task. The most popular library, mPDF, has some restrictions related to CSS that can make it tedious to work with. Especially when it comes to more complex layouts and tables.

Using the RockPdf module for the task was a great choice. Its API is very well designed, offers a lot of conveniences and is well documented. Bernhard responded quickly to my questions and provided great support.

In conclusion, the ProcessWire ecosystem offers great tooling for PDF creation that makes life for developers more enjoyable :-)

Future considerations

Would I use this approach again? Well, that depends mainly on the requirements. For this task the chosen tooling worked very well and I am happy with my choice.

But for more complex layouts/designs that require modern CSS features, I would prefer rendering PDFs through Chromium browser using puppeteer or a self-hosted gotenberg service.

  • Like 10
  • Thanks 2
Link to comment
Share on other sites

Hey @gebeer thx for choosing RockPdf and thx for the great write up 😎

I agree with @gmclelland that the pdfs look very good! 🙂 

On 9/5/2024 at 12:30 PM, gebeer said:
$pagefile = $pdf->saveToField(

    page: $page,

    field: self::PDF_FIELD,

    filename: $filename,

    append: true,

);

I'm wondering why you have append = true here? Some background for anybody interested: This flag is intended for multi-language use, where you might want to create multiple versions of one pdf (english, german, etc.). Using this flag you can foreach over all languages and then create the PDF in that language and append it to the file field in question 🙂 Maybe that's why you are using it? Then this background info might be worth to mention 🙂 

On 9/5/2024 at 12:30 PM, gebeer said:

There is a feature in the RockFrontend module that offers DOM manipulation tools with a nice API. I would have loved to use those but at the the time of working on this project, I just was not aware of their existence.

This is a real gem! For anybody not aware of this see https://www.baumrock.com/en/processwire/modules/rockfrontend/docs/dom/

 

  • Like 1
Link to comment
Share on other sites

7 hours ago, gmclelland said:

Great write up gebeer! Very nice looking pdfs. I would also be interested to see how the mega menu was created on that site.

The site uses the Codyhouse CodyFrame CSS framework. They also have a version of Tailwind CSS.

The megamenu component can be found here https://codyhouse.co/ds/components/app/mega-site-navigation

  • Like 2
Link to comment
Share on other sites

12 hours ago, bernhard said:

I'm wondering why you have append = true here?

PDFs are created in multiple languages, so that is why :-) I looked at the implementation of the saveToField() method to figure out the exact behaviour.

  • Like 1
Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
 Share

  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...