# easy-invoice/2.4.3/includes/Services/PdfRenderer.php

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.4.3. 584 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.4.3/code/includes/Services/PdfRenderer.php
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.4.3/raw/includes/Services/PdfRenderer.php
- Modified: 2026-09-29T06:14:16+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/easy-invoice/2.4.3/code/includes/Services/PdfRenderer.php#L10-L20`.

```php
<?php
/**
 * Server-side PDF rendering.
 *
 * @package Easy_Invoice
 * @subpackage Services
 */

namespace EasyInvoice\Services;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Renders an invoice or quote to a real PDF on the server.
 *
 * Why this exists
 * ---------------
 * Until now every PDF this plugin produced was made in the visitor's browser:
 * html2canvas rasterised the document view and jsPDF wrapped the resulting bitmap.
 * That has four consequences the plugin has been living with.
 *
 *   1. The "PDF" is a picture. No selectable text, no search, no accessibility,
 *      and a file two orders of magnitude larger than it needs to be — a real
 *      invoice renders here at a few kilobytes against ~180 KB for the screenshot.
 *   2. The server never holds the document, so nothing can be attached to an
 *      email. That is why the Email Enhancements addon could not offer
 *      "PDF attached" and why the claim had to be removed from its description.
 *   3. Nothing scheduled — a recurring run, a payment reminder — can carry a PDF,
 *      because there is no browser present when cron fires.
 *   4. Structured e-invoicing (Factur-X, ZUGFeRD) requires PDF/A-3 with XML
 *      embedded inside the file. You cannot embed anything in a screenshot.
 *
 * This class fixes the root cause. It is deliberately additive: the browser path
 * is untouched and remains the default for the on-screen download button, so no
 * existing behaviour changes. New capabilities are built on this instead.
 *
 * On templates
 * ------------
 * The on-screen designs in templates/invoice-templates/ cannot be reused here.
 * They are laid out with flexbox, and dompdf implements CSS 2.1 — it ignores
 * `display: flex` entirely and stacks the children, which turns a two-column
 * header into two stacked rows. This was verified rather than assumed. PDF output
 * therefore has its own template, laid out with tables, which every PDF engine
 * agrees on. `easy_invoice_pdf_template_path` overrides it.
 */
class PdfRenderer {

    /** @var bool True while a design template is being rendered for dompdf. */
    private static $rendering = false;

    /**
     * Whether markup is currently being produced for the PDF engine — hooks
     * that print differently for print (inline images, no links) check this.
     *
     * @return bool
     */
    public static function isRendering(): bool {
        return self::$rendering;
    }

    /** Filter name for overriding the template file. */
    const TEMPLATE_FILTER = 'easy_invoice_pdf_template_path';

    /**
     * Is server-side rendering possible on this install?
     *
     * dompdf is a Composer dependency, but a site owner who deploys by copying
     * files around can end up without vendor/. Callers use this to fall back to
     * the browser path rather than fatal.
     *
     * @return bool
     */
    public static function isAvailable(): bool {
        return class_exists( '\Dompdf\Dompdf' );
    }

    /**
     * Render an invoice to PDF bytes.
     *
     * @param \EasyInvoice\Models\Invoice $invoice Invoice to render.
     * @return string|\WP_Error PDF bytes, or an error if rendering is impossible.
     */
    public static function renderInvoice( $invoice ) {
        return self::render( $invoice, 'invoice' );
    }

    /**
     * Render a quote to PDF bytes.
     *
     * @param \EasyInvoice\Models\Quote $quote Quote to render.
     * @return string|\WP_Error PDF bytes, or an error if rendering is impossible.
     */
    public static function renderQuote( $quote ) {
        return self::render( $quote, 'quote' );
    }

    /**
     * Shared rendering path.
     *
     * @param object $document Invoice or Quote model.
     * @param string $type     'invoice' or 'quote'.
     * @return string|\WP_Error
     */
    private static function render( $document, string $type ) {
        if ( ! self::isAvailable() ) {
            return new \WP_Error(
                'easy_invoice_pdf_unavailable',
                __( 'Server-side PDF rendering is unavailable because the PDF library is missing.', 'easy-invoice' )
            );
        }

        if ( ! is_object( $document ) ) {
            return new \WP_Error( 'easy_invoice_pdf_no_document', __( 'No document to render.', 'easy-invoice' ) );
        }

        $html = self::buildHtml( $document, $type );
        if ( is_wp_error( $html ) ) {
            return $html;
        }

        /**
         * Filter the complete HTML of a document PDF — design or plain layout —
         * before dompdf renders it. Pro's PDF Toolkit lays its watermark here.
         *
         * @param string $html     HTML document.
         * @param object $document Invoice or Quote model.
         * @param string $type     'invoice' or 'quote'.
         */
        $html = (string) apply_filters( 'easy_invoice_pdf_html', $html, $document, $type );

        return self::fromHtml( $html );
    }

    /**
     * Turn a complete HTML document into PDF bytes.
     *
     * Public because the renderer is useful to callers that have already
     * produced their own markup — Pro's PDF Toolkit composes a watermark layer
     * over the document HTML and had nowhere to send the result, so its endpoint
     * returned "not implemented" even after server-side rendering existed here.
     * One dompdf configuration, shared, rather than a second one that drifts.
     *
     * @param string $html Complete HTML document.
     * @return string|\WP_Error PDF bytes.
     */
    public static function fromHtml( string $html ) {
        if ( ! self::isAvailable() ) {
            return new \WP_Error(
                'easy_invoice_pdf_unavailable',
                __( 'Server-side PDF rendering is unavailable because the PDF library is missing.', 'easy-invoice' )
            );
        }

        if ( '' === trim( $html ) ) {
            return new \WP_Error( 'easy_invoice_pdf_empty', __( 'There is nothing to render.', 'easy-invoice' ) );
        }

        // Rendering a page of HTML to PDF needs headroom beyond the 40 MB front-end
        // default; use the same ceiling WordPress gives image editing.
        wp_raise_memory_limit( 'admin' );

        try {
            $dompdf = new \Dompdf\Dompdf( self::options() );
            $dompdf->loadHtml( $html, 'UTF-8' );
            $dompdf->setPaper( self::paperSize(), 'portrait' );
            $dompdf->render();

            $output = $dompdf->output();
            if ( ! is_string( $output ) || strncmp( $output, '%PDF-', 5 ) !== 0 ) {
                return new \WP_Error( 'easy_invoice_pdf_bad_output', __( 'The PDF library returned an unreadable file.', 'easy-invoice' ) );
            }

            return $output;
        } catch ( \Throwable $e ) {
            // A malformed template or an unreachable asset should degrade to the
            // browser path, never take down the request that asked for the PDF.
            error_log( 'Easy Invoice: PDF rendering failed — ' . $e->getMessage() );
            return new \WP_Error( 'easy_invoice_pdf_failed', __( 'The PDF could not be generated.', 'easy-invoice' ) );
        }
    }

    /**
     * Render the PDF template to an HTML string.
     *
     * @param object $document Invoice or Quote model.
     * @param string $type     'invoice' or 'quote'.
     * @return string|\WP_Error
     */
    private static function buildHtml( $document, string $type ) {
        /**
         * Fires before a document's PDF markup is built, whichever layout is
         * used. Pro's Client Language switches locale here.
         *
         * @param object $document Invoice or Quote model.
         * @param string $type     'invoice' or 'quote'.
         */
        do_action( 'easy_invoice_pdf_before_build', $document, $type );

        // The chosen design first, unless the site asked for the plain layout
        // or the design cannot be rendered (an empty Pro canvas, say).
        if ( self::useDesign( $document, $type ) ) {
            $design = self::buildDesignHtml( $document, $type );
            if ( is_string( $design ) && '' !== $design ) {
                return $design;
            }
        }

        $default = easy_invoice_locate_template( 'pdf/' . $type . '.php', [ 'type' => $type ] );

        /**
         * Filter the template used for server-side PDF output.
         *
         * @param string $default  Absolute path to the template.
         * @param object $document Invoice or Quote model.
         * @param string $type     'invoice' or 'quote'.
         */
        $template = (string) apply_filters( self::TEMPLATE_FILTER, $default, $document, $type );

        if ( ! $template || ! file_exists( $template ) ) {
            return new \WP_Error( 'easy_invoice_pdf_no_template', __( 'The PDF template is missing.', 'easy-invoice' ) );
        }

        // Exposed to the template. Named to match the on-screen designs so the two
        // stay recognisably related to anyone editing both.
        $invoice   = $document;               // phpcs:ignore -- consumed by the template.
        $formatter = self::formatterFor( $document );

        ob_start();
        include $template;
        $html = (string) ob_get_clean();

        return $html !== '' ? $html : new \WP_Error( 'easy_invoice_pdf_empty', __( 'The PDF template produced no output.', 'easy-invoice' ) );
    }

    /**
     * Whether the PDF should reproduce the design chosen for the document.
     *
     * @param object $document Invoice or Quote model.
     * @param string $type     'invoice' or 'quote'.
     * @return bool
     */
    public static function useDesign( $document, string $type ): bool {
        $mode = (string) get_option( 'easy_invoice_pdf_layout', 'design' );
        /**
         * Filter whether the PDF reproduces the on-screen design ('design') or
         * uses the plain print layout ('plain').
         *
         * @param bool   $use      True to render the selected design.
         * @param object $document Invoice or Quote model.
         * @param string $type     'invoice' or 'quote'.
         */
        return (bool) apply_filters( 'easy_invoice_pdf_use_design', 'plain' !== $mode, $document, $type );
    }

    /**
     * Render the document's on-screen design for dompdf.
     *
     * The design templates are the same files the public page includes; what
     * differs is the frame: no page chrome, a print stylesheet that maps their
     * flex/grid layout onto tables, and custom properties resolved to values.
     * Everything the plain PDF adds after the document — attachments, the
     * signature block, e-invoice notes — is fired here as well.
     *
     * @param object $document Invoice or Quote model.
     * @param string $type     'invoice' or 'quote'.
     * @return string HTML, or '' when the design produced nothing usable.
     */
    private static function buildDesignHtml( $document, string $type ): string {
        $is_quote = ( 'quote' === $type );
        $design   = is_callable( [ $document, 'getTemplate' ] ) ? (string) $document->getTemplate() : '';
        $file     = function_exists( 'easy_invoice_design_template' ) ? easy_invoice_design_template( $type, $design ) : '';
        if ( '' === $file || ! file_exists( $file ) ) {
            return '';
        }

        // The variables the designs read; identical to templates/document/single.php.
        $invoice       = $is_quote ? null : $document;      // phpcs:ignore -- consumed by the template.
        $quote         = $is_quote ? $document : null;      // phpcs:ignore -- consumed by the template.
        $formatter     = self::formatterFor( $document );   // phpcs:ignore -- consumed by the template.
        $text_settings = $is_quote                          // phpcs:ignore -- consumed by the template.
            ? \EasyInvoice\Helpers\TemplateTextHelper::getQuoteTextSettings()
            : \EasyInvoice\Helpers\TemplateTextHelper::getInvoiceTextSettings();
        $company_info  = \EasyInvoice\Helpers\TemplateTextHelper::getCompanyInfo(); // phpcs:ignore -- consumed by the template.
        $ei_pdf_type   = $type;                             // phpcs:ignore -- consumed by hooks.

        // Some design hooks (Pro's Template Builder among them) read the
        // document from the global post, as they would on the public page.
        // Emails and cron have no such post, so stand it up for the render.
        global $post;
        $previous_post = $post;
        $document_post = get_post( (int) $document->getId() );
        if ( $document_post instanceof \WP_Post ) {
            $post = $document_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restored below.
            setup_postdata( $post );
        }

        self::$rendering = true;
        ob_start();
        try {
            include $file;
        } catch ( \Throwable $e ) {
            ob_end_clean();
            self::$rendering = false;
            $post = $previous_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
            error_log( 'Easy Invoice: design PDF failed, using the plain layout — ' . $e->getMessage() );
            return '';
        }
        $body = (string) ob_get_clean();
        self::$rendering = false;
        $post = $previous_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
        if ( $previous_post instanceof \WP_Post ) {
            setup_postdata( $previous_post );
        }

        // A design that did not draw the line-item table (the hook-driven
        // "default" canvas with nothing registered on it) is not a document.
        $has_items  = false !== strpos( $body, $is_quote ? 'quote-items' : 'invoice-items' );
        $has_canvas = false !== strpos( $body, 'canvas-element' ); // Pro Template Builder output.
        if ( ! $has_items && ! $has_canvas ) {
            return '';
        }

        // Payment position, unless the design already prints one.
        $extra = '';
        if ( ! $is_quote && false === strpos( $body, 'partial-payments-breakdown' ) && false === strpos( $body, 'invoice-balance-due' ) ) {
            $invoice_id = (int) $document->getId();
            $paid       = self::amountPaid( $invoice_id );
            $credited   = InvoiceBalance::credited( $invoice_id );
            if ( $paid > 0 || $credited > 0 ) {
                $total  = is_callable( [ $document, 'getTotal' ] ) ? (float) $document->getTotal() : 0.0;
                $extra .= '<table class="ei-pdf-paid">';
                foreach ( CreditNote::forInvoice( $invoice_id ) as $credit_id ) {
                    $credit_amount = (float) get_post_meta( $credit_id, '_easy_invoice_total', true );
                    if ( $credit_amount <= 0 ) {
                        continue;
                    }
                    /* translators: %s: credit note number. */
                    $extra .= '<tr><td>' . esc_html( sprintf( __( 'Credit note %s', 'easy-invoice' ), (string) get_post_meta( $credit_id, '_easy_invoice_number', true ) ) ) . '</td><td class="v">-' . esc_html( $formatter->format( $credit_amount ) ) . '</td></tr>';
                }
                if ( $paid > 0 ) {
                    $extra .= '<tr><td>' . esc_html__( 'Paid', 'easy-invoice' ) . '</td><td class="v">-' . esc_html( $formatter->format( $paid ) ) . '</td></tr>';
                }
                $extra .= '<tr class="grand"><td>' . esc_html__( 'Balance due', 'easy-invoice' ) . '</td><td class="v">' . esc_html( $formatter->format( max( 0, $total - $paid - $credited ) ) ) . '</td></tr></table>';
            }
        }

        ob_start();
        /** This action is documented in templates/pdf/document.php */
        do_action( 'easy_invoice_pdf_after_notes', $document, $type );
        $after = (string) ob_get_clean();

        $compat = (string) file_get_contents( EASY_INVOICE_PLUGIN_DIR . 'assets/css/pdf-design.css' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local stylesheet.
        /**
         * Filter the stylesheet that adapts the on-screen designs to dompdf.
         *
         * @param string $compat   CSS.
         * @param string $design   Design slug.
         * @param string $type     'invoice' or 'quote'.
         */
        $compat = (string) apply_filters( 'easy_invoice_pdf_design_css', $compat, $design, $type );

        // A Template Builder canvas is laid out for a full A4 page at 96 dpi
        // (794 × 1123 px, its own margin inside); give it the whole sheet.
        if ( $has_canvas ) {
            $compat .= "\n@page { margin: 0; }\nbody.easy-invoice-pdf--canvas #canvas, body.easy-invoice-pdf--canvas .template { position: relative !important; width: 794px !important; height: auto !important; min-height: 0 !important; padding: 0 !important; margin: 0 !important; }\nbody.easy-invoice-pdf--canvas .canvas-element [style*=\"display: flex\"] { display: table !important; width: 100%; } body.easy-invoice-pdf--canvas .canvas-element [style*=\"display: flex\"] > * { display: table-cell !important; } body.easy-invoice-pdf--canvas .canvas-element [style*=\"display: flex\"] > *:last-child { text-align: right; }\n";
        }

        $html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>' . esc_html( is_callable( [ $document, 'getNumber' ] ) ? $document->getNumber() : '' ) . '</title>'
            . '<style>' . $compat . '</style></head>'
            . '<body class="easy-invoice-pdf easy-invoice-pdf--design' . ( $has_canvas ? ' easy-invoice-pdf--canvas' : '' ) . '"><div class="' . esc_attr( $type ) . '-content">'
            . $body . $extra . $after . '</div></body></html>';

        // Resolved over the whole document so the compat sheet can refer to the
        // design's own palette (its :root block lives in the body). The compat
        // sheet's own :root wins: it sits in the head, so document order would
        // otherwise hand every variable back to the design.
        $html = self::resolveCssVariables( $html, self::rootVariables( $compat ) );

        /**
         * Filter the complete HTML of a design-based PDF before dompdf sees it.
         *
         * @param string $html     HTML document.
         * @param object $document Invoice or Quote model.
         * @param string $type     'invoice' or 'quote'.
         */
        return (string) apply_filters( 'easy_invoice_pdf_design_html', $html, $document, $type );
    }

    /**
     * Replace `var(--name)` with the values declared in the markup's own
     * `:root { --name: value }` blocks. dompdf does not implement custom
     * properties; without this every colour and spacing in a design is lost.
     *
     * @param string $html Markup with inline <style> blocks.
     * @return string
     */
    /**
     * The custom properties declared in every `:root` block of a stylesheet or
     * document, last declaration winning.
     *
     * @param string $css CSS or HTML.
     * @return array<string, string>
     */
    private static function rootVariables( string $css ): array {
        $vars = [];
        if ( preg_match_all( '/:root\s*\{([^}]*)\}/', $css, $blocks ) ) {
            foreach ( $blocks[1] as $block ) {
                foreach ( explode( ';', $block ) as $declaration ) {
                    if ( preg_match( '/--([a-zA-Z0-9\-_]+)\s*:\s*(.+)$/', trim( $declaration ), $m ) ) {
                        $vars[ $m[1] ] = trim( $m[2] );
                    }
                }
            }
        }
        return $vars;
    }

    public static function resolveCssVariables( string $html, array $overrides = [] ): string {
        if ( false === strpos( $html, 'var(--' ) ) {
            return $html;
        }
        $vars = self::rootVariables( $html );
        foreach ( $overrides as $name => $value ) {
            $vars[ $name ] = $value;
        }
        // Values may reference other variables; three passes cover any sane chain.
        for ( $i = 0; $i < 3 && false !== strpos( $html, 'var(--' ); $i++ ) {
            $html = (string) preg_replace_callback(
                '/var\(\s*--([a-zA-Z0-9\-_]+)\s*(?:,\s*([^()]+))?\)/',
                static function ( $m ) use ( $vars ) {
                    return $vars[ $m[1] ] ?? ( isset( $m[2] ) ? trim( $m[2] ) : 'inherit' );
                },
                $html
            );
        }
        return $html;
    }

    /**
     * Money received against an invoice: completed payments only.
     *
     * @param int $invoice_id Invoice.
     * @return float
     */
    public static function amountPaid( int $invoice_id ): float {
        $ids = get_posts( [
            'post_type'      => 'easy_invoice_payment',
            'post_status'    => 'publish',
            'numberposts'    => -1,
            'fields'         => 'ids',
            'meta_key'       => '_invoice_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
            'meta_value'     => $invoice_id,   // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
        ] );
        $paid = 0.0;
        foreach ( (array) $ids as $pid ) {
            $status = strtolower( (string) get_post_meta( $pid, '_status', true ) );
            if ( in_array( $status, [ 'completed', 'complete', 'paid', 'success', 'succeeded' ], true ) ) {
                $paid += (float) get_post_meta( $pid, '_amount', true );
            }
        }
        /**
         * Filter the amount shown as paid on a PDF.
         *
         * @param float $paid       Sum of completed payments.
         * @param int   $invoice_id Invoice.
         */
        return (float) apply_filters( 'easy_invoice_pdf_amount_paid', round( $paid, 2 ), $invoice_id );
    }

    /**
     * Currency formatter for a document, matching what the on-screen designs use.
     *
     * @param object $document Invoice or Quote model.
     * @return object
     */
    private static function formatterFor( $document ) {
        if ( class_exists( '\EasyInvoice\Helpers\InvoiceFormatter' ) ) {
            return new \EasyInvoice\Helpers\InvoiceFormatter( $document );
        }

        // Never let a missing helper stop a render; fall back to a plain number.
        return new class {
            public function format( $amount ) {
                return number_format( (float) $amount, 2 );
            }
        };
    }

    /**
     * dompdf configuration.
     *
     * @return \Dompdf\Options
     */
    private static function options() {
        $options = new \Dompdf\Options();

        // DejaVu covers Latin, Greek, Cyrillic and common symbols, so invoices in
        // most of this plugin's markets render without tofu. It ships with dompdf.
        $options->set( 'defaultFont', 'DejaVu Sans' );

        // The company logo is normally an attachment on this same site, so images
        // have to be fetchable. dompdf resolves same-origin http(s) URLs with this
        // on; it stays off for anything else because a template is user-editable
        // and remote fetching from one is an SSRF surface.
        $options->set( 'isRemoteEnabled', (bool) apply_filters( 'easy_invoice_pdf_allow_remote_assets', true ) );

        // Templates are plugin code, not user input, but there is no reason for
        // the renderer to be able to execute PHP even so.
        $options->set( 'isPhpEnabled', false );
        $options->set( 'isHtml5ParserEnabled', true );

        $upload = wp_upload_dir();
        if ( ! empty( $upload['basedir'] ) && wp_is_writable( $upload['basedir'] ) ) {
            $options->set( 'tempDir', $upload['basedir'] );
            $options->set( 'fontDir', trailingslashit( $upload['basedir'] ) . 'easy-invoice-fonts' );
            $options->set( 'fontCache', trailingslashit( $upload['basedir'] ) . 'easy-invoice-fonts' );
        }

        /**
         * Filter the dompdf options object before rendering.
         *
         * @param \Dompdf\Options $options
         */
        return apply_filters( 'easy_invoice_pdf_options_object', $options );
    }

    /**
     * Paper size for generated PDFs.
     *
     * Defaults to A4, which is correct for every market where e-invoicing is
     * mandated; US installs can switch to Letter.
     *
     * @return string
     */
    private static function paperSize(): string {
        $size = get_option( 'easy_invoice_pdf_paper_size', 'a4' );
        $size = is_string( $size ) ? strtolower( $size ) : 'a4';

        return in_array( $size, [ 'a4', 'letter', 'legal' ], true ) ? $size : 'a4';
    }

    /**
     * Render a document and write it to a temporary file, for use as an email
     * attachment.
     *
     * wp_mail() takes file paths, not bytes, so anything that wants to attach a
     * PDF needs it on disk. The caller is responsible for deleting the file once
     * wp_mail() has returned — see EmailManager, which does this on
     * `phpmailer_init` teardown.
     *
     * @param object $document Invoice or Quote model.
     * @param string $type     'invoice' or 'quote'.
     * @return string|\WP_Error Absolute path to the written file.
     */
    public static function renderToFile( $document, string $type = 'invoice' ) {
        $pdf = 'quote' === $type ? self::renderQuote( $document ) : self::renderInvoice( $document );
        if ( is_wp_error( $pdf ) ) {
            return $pdf;
        }

        $number = '';
        if ( is_callable( [ $document, 'getNumber' ] ) ) {
            $number = (string) $document->getNumber();
        }
        $name = sanitize_file_name( ( $number !== '' ? $number : $type ) . '.pdf' );

        $dir = get_temp_dir();
        if ( ! $dir || ! wp_is_writable( $dir ) ) {
            return new \WP_Error( 'easy_invoice_pdf_no_tempdir', __( 'No writable temporary directory is available for the PDF.', 'easy-invoice' ) );
        }

        // wp_unique_filename keeps concurrent sends from overwriting each other.
        $path = trailingslashit( $dir ) . wp_unique_filename( $dir, $name );

        if ( false === file_put_contents( $path, $pdf ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
            return new \WP_Error( 'easy_invoice_pdf_write_failed', __( 'The PDF could not be written to disk.', 'easy-invoice' ) );
        }

        return $path;
    }
}

```
