HIH exposé generator – automated PDF layout from WordPress with JetEngine

For the Hamburg-based real-estate company HIH, I built a WordPress plugin that generates property exposés fully automatically from JetEngine data: address, prices, areas, images, floor plans, energy certificate, location map, contact person – one click in the frontend, and the finished PDF in the corporate design is ready for download.

The old world looked different: exposés were assembled by hand in an Adobe layout tool. CRM data copied over, images imported one by one, fonts laboriously aligned, finally exported as a PDF. Today, agents enter address, price, and areas once into WordPress – and the generator produces the finished PDF in under ten seconds, with pixel-perfect corporate design.

This article walks through how it works under the hood: the data model with JetEngine as single source of truth, the PDF pipeline with Dompdf, FPDI, and PHP-GD, the hash-based cache versioning – and the trickiest part: a multiply blending filter that isn't possible in Dompdf and is therefore precomputed pixel by pixel in PHP-GD.

How were exposés created before?

The old world was a classic layout workflow in an Adobe program. For each listing, a master document was opened, data copied over from the CRM, images inserted individually, area and price tables maintained by hand, location map and energy certificate inserted as external PDFs – finally a PDF export, manual filing and dispatch by email.

That works. But every step is a source for errors or inconsistencies: typos in the address, the wrong square-metre figure, the logo from last quarter, an outdated disclaimer, a forgotten image caption. It also requires layout skills – not every agent on the team is comfortable with Adobe tools, and stand-ins become a bottleneck.

The real bottleneck

It's not just about time. Layout software requires skills, ties up a template per editor, and makes layout consistency dependent on human discipline. The biggest win of automation isn't the seconds-counter – it's that anyone on the team can produce an exposé in clean corporate design with two clicks.

What did the plugin need to do?

The plugin had to replace the entire workflow from data-entry backend to download link. Specifically:

Requirements at a glance
  • Data from JetEngine as the single source, no double maintenance
  • PDF layout in the HIH corporate design (colours, fonts, image styles)
  • Multi-page A4 landscape: cover, description, gallery, areas, floor plans, location, contact, disclaimer
  • External PDFs (floor plans, energy certificates) must be embedded into the exposé
  • Automatic refresh on data changes – no cron, no manual trigger
  • Secure download via signed URL and rate limit
  • Concurrent-safe: two simultaneous requests must not collide
  • Standard hosting must be enough – no headless browser, no external service

The last criterion mattered: no headless-Chrome solution, no external API. Generation runs entirely on the WordPress server, in pure PHP. That simplifies hosting, backup, maintenance, and data protection – the data never leaves the server.

JetEngine as the single source of truth

The data layer is built entirely on JetEngine. Two custom post types carry the content: inserate for the properties themselves, ansprechpartner for the responsible agents. They are linked via JetEngine relations, stored internally in a dedicated database table (wp_jet_rel_default).

Each listing has roughly 35 meta fields: address, location, location description, commission status, minimum and maximum area, prices per square metre, additional costs, property description, distances to public transit and motorway, equipment, highlights, featured image, gallery, parking data in several variants (outdoor, underground garage, EV charging with quantities and prices), energy certificate, and date.

The remarkable thing: agents work exclusively in the standard WordPress backend. No custom input mask, no separate application. That lowers the learning curve and makes the data-maintenance workflow trivial for stand-ins or new staff – anyone who can edit a listing can also generate the exposé.

Data collection: 35 fields plus property table

Some properties aren't single objects but entire buildings or districts with multiple units – office buildings with fifteen rental areas, a residential complex with different apartment types. For these cases, a flat field structure isn't enough.

The solution: a second plugin maintains a structured property table per listing – buildings, each with multiple units, per unit floor, area, rental status, starting price, additional costs, availability date, description, and attachments like floor plan, sample layout, and energy certificate. This data is stored as JSON in a single meta field (cptsrs_site_plan_json) and resolved by the generator at collection time.

Data collection itself sits in data-collector.php and returns a single, clean data array that the template just has to render. Separation of data model and presentation, classic pattern. One quirk: a custom decimal parser. Square metres and prices arrive sometimes as 1,234.56, sometimes as 1234,56 – the parser heuristically detects which separator is meant and always produces the correct float.

PDF pipeline: Dompdf, FPDI, and PHP-GD working together

PDF generation pulls in three libraries, each with a clearly delimited job:

  • Dompdf 2.x renders HTML+CSS to PDF. That's the main path.
  • FPDI merges external PDFs (floor plans, energy certificates) into the final result. Dompdf can't do that – FPDI can.
  • PHP-GD handles image preprocessing before images are embedded into the HTML. Multiply blending, cover cropping, filter overlays for featured images.

The layout itself is a classic templating setup: expose-template.php is a PHP file with output buffering that pulls data from the collector and produces HTML. A single master template for all exposés – that's the turning point. One master template means: consistent corporate design across every exposé, with no discipline cost. No one accidentally uses last quarter's logo, because there's only one place the logo comes from: the plugin's settings panel.

Multi-page exposé PDF in corporate design – cover, gallery, and area table
Multi-page A4 landscape – cover, description, gallery, and area table all come from a single master template.

Fonts (Proxima Nova and Helvetica Neue) are not bound via @font-face but registered as system fonts in Dompdf – more robust and avoids issues with font paths in the PDF engine.

Hash-based cache versioning

Cache behaviour needs an elegant answer to an awkward question: when does a PDF need to be regenerated? The obvious solution would be a cron job that re-renders nightly. Or a manual "refresh" button. Both are error-prone.

Instead, the plugin computes a hash across all relevant data fields. That hash is the cache version. If it differs from the stored value on the next download request, the PDF is regenerated; otherwise it's served from cache.

The advantage: a data change in the WordPress backend automatically triggers regeneration on the next download. No one has to remember. No one has to push a button. As long as nothing changes, the PDF comes from cache in under a second. The moment someone changes the price or swaps an image, the cache invalidates by itself.

PHP
// Cache versioning + concurrent lock in one function
function inex_get_or_generate_pdf($post_id, $force_regenerate = false) {
    $filepath        = inex_get_expose_directory() . '/' . inex_get_cached_pdf_filename($post_id);
    $cache_version   = get_post_meta($post_id, '_inex_pdf_cache_version', true);
    $current_version = inex_get_cache_version($post_id);

    // Cache hit: PDF exists and hash matches → return existing file
    if (!$force_regenerate && file_exists($filepath)
         && hash_equals((string) $cache_version, (string) $current_version)) {
        return $fileurl;
    }

    // Concurrent lock: only one worker generates the same file at a time
    if (!inex_acquire_pdf_generation_lock($post_id)) {
        return new WP_Error('pdf_generation_locked', 'PDF is currently being generated…');
    }

    try {
        $data = inex_collect_expose_data($post_id);
        inex_render_expose_pdf_file($data, $filepath . '.tmp');
        @rename($filepath . '.tmp', $filepath);
        update_post_meta($post_id, '_inex_pdf_cache_version', $current_version);
        return $fileurl;
    } finally {
        inex_release_pdf_generation_lock($post_id);
    }
}

Three details are worth flagging: first, the hash_equals for the cache comparison – not security-critical here, but consistent in avoiding timing issues. Second, the rename onto a .tmp file, which is atomic; a parallel reader never sees a half-finished file. Third, the try/finally, which cleanly releases the lock even if rendering throws.

Multiply blending in PHP-GD

Anyone who has worked with Dompdf knows one of its weaknesses: CSS opacity isn't reliably supported. When the layout – as it does here – uses semi-transparent filter overlays over the featured image (the classic real-estate-exposé look with a dark gradient for headline legibility), that quickly becomes a problem.

The solution: blending isn't attempted in CSS at all but performed pixel by pixel in PHP-GD before rendering. Featured image and filter overlay are loaded into memory, then a loop over each pixel combines the two images using the multiply formula: for each colour channel, the new value is (a × b) / 255. The result is encoded as JPEG with high quality and embedded as a base64 data URI in the HTML.

That's brute-force maths in PHP, no performance darling – image processing is the cold-generation bottleneck. But the result is pixel-perfect identical to what a designer would produce in a layout tool. And thanks to cache versioning, the computation only runs when something has actually changed.

When the library can't do something

The typical reaction to "Dompdf doesn't do CSS opacity" would be: pick a different library. For most projects that's the right call. For one well-defined layout requirement, a precomputation stage in PHP-GD is often quicker to implement than a full library swap – and it keeps the hosting setup simple.

Concurrent safety & signed URLs

As soon as multiple people generate exposés simultaneously, lock logic matters. Two parallel requests for the same listing must not both kick off the expensive image processing – one renders, the other waits. The solution runs through a WordPress transient as a mutex: whoever sets the lock first renders; everyone else polls in a short wait loop (max three seconds) before getting the finished cache entry.

For PDF delivery there are no direct URLs to filenames in the uploads folder. The storage folder is locked against directory listing via .htaccess with Options -Indexes, and every download goes through a signed URL: HMAC-SHA256 over post ID and expiry timestamp, signed with the WordPress auth salt, TTL one hour. On download, the signature is verified via hash_equals (timing-attack-safe), and if valid, the file is delivered with the correct MIME type.

A rate limit completes the picture: ten downloads per hour per IP address, also implemented via transients. Sufficient for legitimate use, blocks crawlers and scrapers.

Numbers & architecture

The plugin in numbers:

  • ~9,350 lines of PHP, CSS, and JavaScript without vendor code
  • 2 custom post types (listings, contacts) plus property-table JSON
  • ~35 meta fields per property
  • 1 master template, 1 CSS file with ~1,800 lines
  • Composer packages: dompdf/dompdf 2.x, setasign/fpdi 2.3, phenx/php-svg-lib 0.5
  • PHP ≥ 7.4
File Lines of code Responsibility
pdf-generator.php ~3,190 Dompdf pipeline, GD image preprocessing, FPDI merge, cache logic
data-collector.php ~1,320 JetEngine data collection, property-table parsing, decimal parser
expose-template.php ~1,440 HTML master template with output buffering
expose-styles.css ~1,825 Layout, page breaks, corporate design
settings-page.php ~750 Admin UI, logos, disclaimer, cache tools
ajax-handler.php ~400 Nonces, token, rate limit, signed URLs

The size of pdf-generator.php stands out. That would be a good refactoring target – the image processing logic (around 600 lines for multiply blending and cover cropping) really belongs in its own class. It worked the way it is, and stability over aesthetics was a deliberate trade-off on this project.

When is custom development worth it over a real-estate SaaS?

Real-estate platforms like onOffice, FlowFact, or propstack ship exposé generation as a module. That's not a critique – on the contrary, for many agency teams that's the right call, because it bundles CRM, customer management, portal connections, and exposés into one package.

Custom development as a WordPress plugin pays off in this constellation:

  • There is an existing WordPress site where listings are already maintained – exposé and website share data and images, no double maintenance
  • The layout has to match the corporate design exactly, without layout-editor limits
  • The team is small to medium-sized – the typical €50–150 per user per month adds up
  • Data sovereignty matters – listings don't leave the office's own server
  • There shouldn't be a second system with its own login, its own maintenance, its own audit log
Criterion Real-estate SaaS WordPress plugin
Layout customisation Templates within editor Pixel-perfect in code
Setup costs Low One-off four to five figures
Running costs Per user per month Hosting + maintenance
Data sovereignty With the vendor On own server
Workflow integration Separate system Directly in WordPress
Roadmap dependence Vendor-driven Self-determined

The greatest strength of the custom build is that website and exposé aren't two worlds. The images, descriptions, and data shown on the listing's web detail page are exactly the same that flow into the PDF. No export, no sync, no "which version is current?" – a hash comparison, a click, done.

What custom development doesn't replace

A plugin isn't a CRM. Anyone needing customer master data, viewing appointments, contract management, and portal connections in one interface is better served by real-estate software. The exposé generator is a specialty – excellent in its niche, but not a replacement for the entire agency platform.

Frequently Asked Questions about the Exposé Generator

How is an exposé PDF updated when WordPress data changes?

The plugin computes a hash across all relevant fields and compares it against the cache version of the existing PDF. Any change to address, price, images, or description shifts the hash – the next download triggers automatic regeneration. As long as nothing changes, the PDF comes from the cache and the download is back in under a second.

Which WordPress components does the exposé generator need?

JetEngine as the data layer with two custom post types (inserate, ansprechpartner) and around 35 meta fields per property. Optionally a second plugin for structured area management with buildings, units, and floor plans whose data flows in as JSON via property meta. The generator itself runs on Dompdf, FPDI, and PHP-GD – no external service, no cron, no REST API.

Why Dompdf and not mPDF or wkhtmltopdf?

Dompdf runs as a pure PHP library – no headless browser binary like wkhtmltopdf, no server-side service. That simplifies hosting and deployment on any standard WordPress host. mPDF would be a valid alternative; the project saw a test branch for it. Currently Dompdf 2.x runs in production, complemented by FPDI for merging external PDFs (floor plans, energy certificates) and PHP-GD for image preprocessing.

How does the multiply blending of images work without CSS opacity?

Dompdf doesn't reliably support CSS opacity. Instead, the plugin computes the blend pixel by pixel in PHP-GD: featured image and filter overlay are combined in memory before PDF generation, multiply blending is reproduced mathematically, and the result is embedded as a base64 data URI in the HTML. Expensive, but the result is pixel-perfect identical to what a designer would produce in a layout tool.

How secure are the PDF download links?

Every download runs through a signed URL: HMAC-SHA256 over post ID and expiry timestamp, signed with the WordPress auth salt and a one-hour TTL. Comparison via hash_equals() to defeat timing attacks. A rate limit of ten downloads per hour per IP via transient adds another layer. The storage folder is locked against directory listing via .htaccess.

What does a real-estate SaaS alternative like onOffice or FlowFact cost?

Market prices typically sit at €50–150 per user per month – depending on feature scope and contract. With ten users that adds up to €6,000–18,000 per year, with limited room to adapt layout, data fields, or workflow. A custom WordPress solution often pays off for medium-sized agency teams within 12–24 months – plus full ownership of data, layout, and code.

Automating your own exposés?

Planning something similar – exposé generation, automated PDFs from WordPress data, or a layout engine in your corporate design? Drop me a line and I'll take a look at your workflow.

Contact