gebeer Posted September 5 Share Posted September 5 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 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: Content pages: Specs table: 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. 9 1 Link to comment Share on other sites More sharing options...
gmclelland Posted September 9 Share Posted September 9 Great write up gebeer! Very nice looking pdfs. I would also be interested to see how the mega menu was created on that site. 2 Link to comment Share on other sites More sharing options...
bernhard Posted September 9 Share Posted September 9 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/ Link to comment Share on other sites More sharing options...
erikvanberkum Posted September 9 Share Posted September 9 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 2 Link to comment Share on other sites More sharing options...
gmclelland Posted September 10 Share Posted September 10 Thanks @erikvanberkum! How were the mega menus modeled in the backend of ProcessWire? Link to comment Share on other sites More sharing options...
gebeer Posted September 10 Author Share Posted September 10 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. 1 Link to comment Share on other sites More sharing options...
Recommended Posts
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 accountSign in
Already have an account? Sign in here.
Sign In Now