PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.0
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.0
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / includes / Services / PdfRenderer.php

PdfRenderer.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.0, at includes/Services/PdfRenderer.php

567 lines 25.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Server-side PDF rendering.
4 *
5 * @package Easy_Invoice
6 * @subpackage Services
7 */
8
9 namespace EasyInvoice\Services;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Renders an invoice or quote to a real PDF on the server.
17 *
18 * Why this exists
19 * ---------------
20 * Until now every PDF this plugin produced was made in the visitor's browser:
21 * html2canvas rasterised the document view and jsPDF wrapped the resulting bitmap.
22 * That has four consequences the plugin has been living with.
23 *
24 * 1. The "PDF" is a picture. No selectable text, no search, no accessibility,
25 * and a file two orders of magnitude larger than it needs to be — a real
26 * invoice renders here at a few kilobytes against ~180 KB for the screenshot.
27 * 2. The server never holds the document, so nothing can be attached to an
28 * email. That is why the Email Enhancements addon could not offer
29 * "PDF attached" and why the claim had to be removed from its description.
30 * 3. Nothing scheduled — a recurring run, a payment reminder — can carry a PDF,
31 * because there is no browser present when cron fires.
32 * 4. Structured e-invoicing (Factur-X, ZUGFeRD) requires PDF/A-3 with XML
33 * embedded inside the file. You cannot embed anything in a screenshot.
34 *
35 * This class fixes the root cause. It is deliberately additive: the browser path
36 * is untouched and remains the default for the on-screen download button, so no
37 * existing behaviour changes. New capabilities are built on this instead.
38 *
39 * On templates
40 * ------------
41 * The on-screen designs in templates/invoice-templates/ cannot be reused here.
42 * They are laid out with flexbox, and dompdf implements CSS 2.1 — it ignores
43 * `display: flex` entirely and stacks the children, which turns a two-column
44 * header into two stacked rows. This was verified rather than assumed. PDF output
45 * therefore has its own template, laid out with tables, which every PDF engine
46 * agrees on. `easy_invoice_pdf_template_path` overrides it.
47 */
48 class PdfRenderer {
49
50 /** @var bool True while a design template is being rendered for dompdf. */
51 private static $rendering = false;
52
53 /**
54 * Whether markup is currently being produced for the PDF engine — hooks
55 * that print differently for print (inline images, no links) check this.
56 *
57 * @return bool
58 */
59 public static function isRendering(): bool {
60 return self::$rendering;
61 }
62
63 /** Filter name for overriding the template file. */
64 const TEMPLATE_FILTER = 'easy_invoice_pdf_template_path';
65
66 /**
67 * Is server-side rendering possible on this install?
68 *
69 * dompdf is a Composer dependency, but a site owner who deploys by copying
70 * files around can end up without vendor/. Callers use this to fall back to
71 * the browser path rather than fatal.
72 *
73 * @return bool
74 */
75 public static function isAvailable(): bool {
76 return class_exists( '\Dompdf\Dompdf' );
77 }
78
79 /**
80 * Render an invoice to PDF bytes.
81 *
82 * @param \EasyInvoice\Models\Invoice $invoice Invoice to render.
83 * @return string|\WP_Error PDF bytes, or an error if rendering is impossible.
84 */
85 public static function renderInvoice( $invoice ) {
86 return self::render( $invoice, 'invoice' );
87 }
88
89 /**
90 * Render a quote to PDF bytes.
91 *
92 * @param \EasyInvoice\Models\Quote $quote Quote to render.
93 * @return string|\WP_Error PDF bytes, or an error if rendering is impossible.
94 */
95 public static function renderQuote( $quote ) {
96 return self::render( $quote, 'quote' );
97 }
98
99 /**
100 * Shared rendering path.
101 *
102 * @param object $document Invoice or Quote model.
103 * @param string $type 'invoice' or 'quote'.
104 * @return string|\WP_Error
105 */
106 private static function render( $document, string $type ) {
107 if ( ! self::isAvailable() ) {
108 return new \WP_Error(
109 'easy_invoice_pdf_unavailable',
110 __( 'Server-side PDF rendering is unavailable because the PDF library is missing.', 'easy-invoice' )
111 );
112 }
113
114 if ( ! is_object( $document ) ) {
115 return new \WP_Error( 'easy_invoice_pdf_no_document', __( 'No document to render.', 'easy-invoice' ) );
116 }
117
118 $html = self::buildHtml( $document, $type );
119 if ( is_wp_error( $html ) ) {
120 return $html;
121 }
122
123 /**
124 * Filter the complete HTML of a document PDF — design or plain layout —
125 * before dompdf renders it. Pro's PDF Toolkit lays its watermark here.
126 *
127 * @param string $html HTML document.
128 * @param object $document Invoice or Quote model.
129 * @param string $type 'invoice' or 'quote'.
130 */
131 $html = (string) apply_filters( 'easy_invoice_pdf_html', $html, $document, $type );
132
133 return self::fromHtml( $html );
134 }
135
136 /**
137 * Turn a complete HTML document into PDF bytes.
138 *
139 * Public because the renderer is useful to callers that have already
140 * produced their own markup — Pro's PDF Toolkit composes a watermark layer
141 * over the document HTML and had nowhere to send the result, so its endpoint
142 * returned "not implemented" even after server-side rendering existed here.
143 * One dompdf configuration, shared, rather than a second one that drifts.
144 *
145 * @param string $html Complete HTML document.
146 * @return string|\WP_Error PDF bytes.
147 */
148 public static function fromHtml( string $html ) {
149 if ( ! self::isAvailable() ) {
150 return new \WP_Error(
151 'easy_invoice_pdf_unavailable',
152 __( 'Server-side PDF rendering is unavailable because the PDF library is missing.', 'easy-invoice' )
153 );
154 }
155
156 if ( '' === trim( $html ) ) {
157 return new \WP_Error( 'easy_invoice_pdf_empty', __( 'There is nothing to render.', 'easy-invoice' ) );
158 }
159
160 // Rendering a page of HTML to PDF needs headroom beyond the 40 MB front-end
161 // default; use the same ceiling WordPress gives image editing.
162 wp_raise_memory_limit( 'admin' );
163
164 try {
165 $dompdf = new \Dompdf\Dompdf( self::options() );
166 $dompdf->loadHtml( $html, 'UTF-8' );
167 $dompdf->setPaper( self::paperSize(), 'portrait' );
168 $dompdf->render();
169
170 $output = $dompdf->output();
171 if ( ! is_string( $output ) || strncmp( $output, '%PDF-', 5 ) !== 0 ) {
172 return new \WP_Error( 'easy_invoice_pdf_bad_output', __( 'The PDF library returned an unreadable file.', 'easy-invoice' ) );
173 }
174
175 return $output;
176 } catch ( \Throwable $e ) {
177 // A malformed template or an unreachable asset should degrade to the
178 // browser path, never take down the request that asked for the PDF.
179 error_log( 'Easy Invoice: PDF rendering failed — ' . $e->getMessage() );
180 return new \WP_Error( 'easy_invoice_pdf_failed', __( 'The PDF could not be generated.', 'easy-invoice' ) );
181 }
182 }
183
184 /**
185 * Render the PDF template to an HTML string.
186 *
187 * @param object $document Invoice or Quote model.
188 * @param string $type 'invoice' or 'quote'.
189 * @return string|\WP_Error
190 */
191 private static function buildHtml( $document, string $type ) {
192 /**
193 * Fires before a document's PDF markup is built, whichever layout is
194 * used. Pro's Client Language switches locale here.
195 *
196 * @param object $document Invoice or Quote model.
197 * @param string $type 'invoice' or 'quote'.
198 */
199 do_action( 'easy_invoice_pdf_before_build', $document, $type );
200
201 // The chosen design first, unless the site asked for the plain layout
202 // or the design cannot be rendered (an empty Pro canvas, say).
203 if ( self::useDesign( $document, $type ) ) {
204 $design = self::buildDesignHtml( $document, $type );
205 if ( is_string( $design ) && '' !== $design ) {
206 return $design;
207 }
208 }
209
210 $default = easy_invoice_locate_template( 'pdf/' . $type . '.php', [ 'type' => $type ] );
211
212 /**
213 * Filter the template used for server-side PDF output.
214 *
215 * @param string $default Absolute path to the template.
216 * @param object $document Invoice or Quote model.
217 * @param string $type 'invoice' or 'quote'.
218 */
219 $template = (string) apply_filters( self::TEMPLATE_FILTER, $default, $document, $type );
220
221 if ( ! $template || ! file_exists( $template ) ) {
222 return new \WP_Error( 'easy_invoice_pdf_no_template', __( 'The PDF template is missing.', 'easy-invoice' ) );
223 }
224
225 // Exposed to the template. Named to match the on-screen designs so the two
226 // stay recognisably related to anyone editing both.
227 $invoice = $document; // phpcs:ignore -- consumed by the template.
228 $formatter = self::formatterFor( $document );
229
230 ob_start();
231 include $template;
232 $html = (string) ob_get_clean();
233
234 return $html !== '' ? $html : new \WP_Error( 'easy_invoice_pdf_empty', __( 'The PDF template produced no output.', 'easy-invoice' ) );
235 }
236
237 /**
238 * Whether the PDF should reproduce the design chosen for the document.
239 *
240 * @param object $document Invoice or Quote model.
241 * @param string $type 'invoice' or 'quote'.
242 * @return bool
243 */
244 public static function useDesign( $document, string $type ): bool {
245 $mode = (string) get_option( 'easy_invoice_pdf_layout', 'design' );
246 /**
247 * Filter whether the PDF reproduces the on-screen design ('design') or
248 * uses the plain print layout ('plain').
249 *
250 * @param bool $use True to render the selected design.
251 * @param object $document Invoice or Quote model.
252 * @param string $type 'invoice' or 'quote'.
253 */
254 return (bool) apply_filters( 'easy_invoice_pdf_use_design', 'plain' !== $mode, $document, $type );
255 }
256
257 /**
258 * Render the document's on-screen design for dompdf.
259 *
260 * The design templates are the same files the public page includes; what
261 * differs is the frame: no page chrome, a print stylesheet that maps their
262 * flex/grid layout onto tables, and custom properties resolved to values.
263 * Everything the plain PDF adds after the document — attachments, the
264 * signature block, e-invoice notes — is fired here as well.
265 *
266 * @param object $document Invoice or Quote model.
267 * @param string $type 'invoice' or 'quote'.
268 * @return string HTML, or '' when the design produced nothing usable.
269 */
270 private static function buildDesignHtml( $document, string $type ): string {
271 $is_quote = ( 'quote' === $type );
272 $design = is_callable( [ $document, 'getTemplate' ] ) ? (string) $document->getTemplate() : '';
273 $file = function_exists( 'easy_invoice_design_template' ) ? easy_invoice_design_template( $type, $design ) : '';
274 if ( '' === $file || ! file_exists( $file ) ) {
275 return '';
276 }
277
278 // The variables the designs read; identical to templates/document/single.php.
279 $invoice = $is_quote ? null : $document; // phpcs:ignore -- consumed by the template.
280 $quote = $is_quote ? $document : null; // phpcs:ignore -- consumed by the template.
281 $formatter = self::formatterFor( $document ); // phpcs:ignore -- consumed by the template.
282 $text_settings = $is_quote // phpcs:ignore -- consumed by the template.
283 ? \EasyInvoice\Helpers\TemplateTextHelper::getQuoteTextSettings()
284 : \EasyInvoice\Helpers\TemplateTextHelper::getInvoiceTextSettings();
285 $company_info = \EasyInvoice\Helpers\TemplateTextHelper::getCompanyInfo(); // phpcs:ignore -- consumed by the template.
286 $ei_pdf_type = $type; // phpcs:ignore -- consumed by hooks.
287
288 // Some design hooks (Pro's Template Builder among them) read the
289 // document from the global post, as they would on the public page.
290 // Emails and cron have no such post, so stand it up for the render.
291 global $post;
292 $previous_post = $post;
293 $document_post = get_post( (int) $document->getId() );
294 if ( $document_post instanceof \WP_Post ) {
295 $post = $document_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restored below.
296 setup_postdata( $post );
297 }
298
299 self::$rendering = true;
300 ob_start();
301 try {
302 include $file;
303 } catch ( \Throwable $e ) {
304 ob_end_clean();
305 self::$rendering = false;
306 $post = $previous_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
307 error_log( 'Easy Invoice: design PDF failed, using the plain layout — ' . $e->getMessage() );
308 return '';
309 }
310 $body = (string) ob_get_clean();
311 self::$rendering = false;
312 $post = $previous_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
313 if ( $previous_post instanceof \WP_Post ) {
314 setup_postdata( $previous_post );
315 }
316
317 // A design that did not draw the line-item table (the hook-driven
318 // "default" canvas with nothing registered on it) is not a document.
319 $has_items = false !== strpos( $body, $is_quote ? 'quote-items' : 'invoice-items' );
320 $has_canvas = false !== strpos( $body, 'canvas-element' ); // Pro Template Builder output.
321 if ( ! $has_items && ! $has_canvas ) {
322 return '';
323 }
324
325 // Payment position, unless the design already prints one.
326 $extra = '';
327 if ( ! $is_quote && false === strpos( $body, 'partial-payments-breakdown' ) && false === strpos( $body, 'invoice-balance-due' ) ) {
328 $invoice_id = (int) $document->getId();
329 $paid = self::amountPaid( $invoice_id );
330 $credited = InvoiceBalance::credited( $invoice_id );
331 if ( $paid > 0 || $credited > 0 ) {
332 $total = is_callable( [ $document, 'getTotal' ] ) ? (float) $document->getTotal() : 0.0;
333 $extra .= '<table class="ei-pdf-paid">';
334 foreach ( CreditNote::forInvoice( $invoice_id ) as $credit_id ) {
335 $credit_amount = (float) get_post_meta( $credit_id, '_easy_invoice_total', true );
336 if ( $credit_amount <= 0 ) {
337 continue;
338 }
339 /* translators: %s: credit note number. */
340 $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>';
341 }
342 if ( $paid > 0 ) {
343 $extra .= '<tr><td>' . esc_html__( 'Paid', 'easy-invoice' ) . '</td><td class="v">-' . esc_html( $formatter->format( $paid ) ) . '</td></tr>';
344 }
345 $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>';
346 }
347 }
348
349 ob_start();
350 /** This action is documented in templates/pdf/document.php */
351 do_action( 'easy_invoice_pdf_after_notes', $document, $type );
352 $after = (string) ob_get_clean();
353
354 $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.
355 /**
356 * Filter the stylesheet that adapts the on-screen designs to dompdf.
357 *
358 * @param string $compat CSS.
359 * @param string $design Design slug.
360 * @param string $type 'invoice' or 'quote'.
361 */
362 $compat = (string) apply_filters( 'easy_invoice_pdf_design_css', $compat, $design, $type );
363
364 // A Template Builder canvas is laid out for a full A4 page at 96 dpi
365 // (794 × 1123 px, its own margin inside); give it the whole sheet.
366 if ( $has_canvas ) {
367 $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";
368 }
369
370 $html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>' . esc_html( is_callable( [ $document, 'getNumber' ] ) ? $document->getNumber() : '' ) . '</title>'
371 . '<style>' . $compat . '</style></head>'
372 . '<body class="easy-invoice-pdf easy-invoice-pdf--design' . ( $has_canvas ? ' easy-invoice-pdf--canvas' : '' ) . '"><div class="' . esc_attr( $type ) . '-content">'
373 . $body . $extra . $after . '</div></body></html>';
374
375 // Resolved over the whole document so the compat sheet can refer to the
376 // design's own palette (its :root block lives in the body).
377 $html = self::resolveCssVariables( $html );
378
379 /**
380 * Filter the complete HTML of a design-based PDF before dompdf sees it.
381 *
382 * @param string $html HTML document.
383 * @param object $document Invoice or Quote model.
384 * @param string $type 'invoice' or 'quote'.
385 */
386 return (string) apply_filters( 'easy_invoice_pdf_design_html', $html, $document, $type );
387 }
388
389 /**
390 * Replace `var(--name)` with the values declared in the markup's own
391 * `:root { --name: value }` blocks. dompdf does not implement custom
392 * properties; without this every colour and spacing in a design is lost.
393 *
394 * @param string $html Markup with inline <style> blocks.
395 * @return string
396 */
397 public static function resolveCssVariables( string $html ): string {
398 if ( false === strpos( $html, 'var(--' ) ) {
399 return $html;
400 }
401 $vars = [];
402 if ( preg_match_all( '/:root\s*\{([^}]*)\}/', $html, $blocks ) ) {
403 foreach ( $blocks[1] as $block ) {
404 foreach ( explode( ';', $block ) as $declaration ) {
405 if ( preg_match( '/--([a-zA-Z0-9\-_]+)\s*:\s*(.+)$/', trim( $declaration ), $m ) ) {
406 $vars[ $m[1] ] = trim( $m[2] );
407 }
408 }
409 }
410 }
411 // Values may reference other variables; three passes cover any sane chain.
412 for ( $i = 0; $i < 3 && false !== strpos( $html, 'var(--' ); $i++ ) {
413 $html = (string) preg_replace_callback(
414 '/var\(\s*--([a-zA-Z0-9\-_]+)\s*(?:,\s*([^()]+))?\)/',
415 static function ( $m ) use ( $vars ) {
416 return $vars[ $m[1] ] ?? ( isset( $m[2] ) ? trim( $m[2] ) : 'inherit' );
417 },
418 $html
419 );
420 }
421 return $html;
422 }
423
424 /**
425 * Money received against an invoice: completed payments only.
426 *
427 * @param int $invoice_id Invoice.
428 * @return float
429 */
430 public static function amountPaid( int $invoice_id ): float {
431 $ids = get_posts( [
432 'post_type' => 'easy_invoice_payment',
433 'post_status' => 'publish',
434 'numberposts' => -1,
435 'fields' => 'ids',
436 'meta_key' => '_invoice_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
437 'meta_value' => $invoice_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
438 ] );
439 $paid = 0.0;
440 foreach ( (array) $ids as $pid ) {
441 $status = strtolower( (string) get_post_meta( $pid, '_status', true ) );
442 if ( in_array( $status, [ 'completed', 'complete', 'paid', 'success', 'succeeded' ], true ) ) {
443 $paid += (float) get_post_meta( $pid, '_amount', true );
444 }
445 }
446 /**
447 * Filter the amount shown as paid on a PDF.
448 *
449 * @param float $paid Sum of completed payments.
450 * @param int $invoice_id Invoice.
451 */
452 return (float) apply_filters( 'easy_invoice_pdf_amount_paid', round( $paid, 2 ), $invoice_id );
453 }
454
455 /**
456 * Currency formatter for a document, matching what the on-screen designs use.
457 *
458 * @param object $document Invoice or Quote model.
459 * @return object
460 */
461 private static function formatterFor( $document ) {
462 if ( class_exists( '\EasyInvoice\Helpers\InvoiceFormatter' ) ) {
463 return new \EasyInvoice\Helpers\InvoiceFormatter( $document );
464 }
465
466 // Never let a missing helper stop a render; fall back to a plain number.
467 return new class {
468 public function format( $amount ) {
469 return number_format( (float) $amount, 2 );
470 }
471 };
472 }
473
474 /**
475 * dompdf configuration.
476 *
477 * @return \Dompdf\Options
478 */
479 private static function options() {
480 $options = new \Dompdf\Options();
481
482 // DejaVu covers Latin, Greek, Cyrillic and common symbols, so invoices in
483 // most of this plugin's markets render without tofu. It ships with dompdf.
484 $options->set( 'defaultFont', 'DejaVu Sans' );
485
486 // The company logo is normally an attachment on this same site, so images
487 // have to be fetchable. dompdf resolves same-origin http(s) URLs with this
488 // on; it stays off for anything else because a template is user-editable
489 // and remote fetching from one is an SSRF surface.
490 $options->set( 'isRemoteEnabled', (bool) apply_filters( 'easy_invoice_pdf_allow_remote_assets', true ) );
491
492 // Templates are plugin code, not user input, but there is no reason for
493 // the renderer to be able to execute PHP even so.
494 $options->set( 'isPhpEnabled', false );
495 $options->set( 'isHtml5ParserEnabled', true );
496
497 $upload = wp_upload_dir();
498 if ( ! empty( $upload['basedir'] ) && wp_is_writable( $upload['basedir'] ) ) {
499 $options->set( 'tempDir', $upload['basedir'] );
500 $options->set( 'fontDir', trailingslashit( $upload['basedir'] ) . 'easy-invoice-fonts' );
501 $options->set( 'fontCache', trailingslashit( $upload['basedir'] ) . 'easy-invoice-fonts' );
502 }
503
504 /**
505 * Filter the dompdf options object before rendering.
506 *
507 * @param \Dompdf\Options $options
508 */
509 return apply_filters( 'easy_invoice_pdf_options_object', $options );
510 }
511
512 /**
513 * Paper size for generated PDFs.
514 *
515 * Defaults to A4, which is correct for every market where e-invoicing is
516 * mandated; US installs can switch to Letter.
517 *
518 * @return string
519 */
520 private static function paperSize(): string {
521 $size = get_option( 'easy_invoice_pdf_paper_size', 'a4' );
522 $size = is_string( $size ) ? strtolower( $size ) : 'a4';
523
524 return in_array( $size, [ 'a4', 'letter', 'legal' ], true ) ? $size : 'a4';
525 }
526
527 /**
528 * Render a document and write it to a temporary file, for use as an email
529 * attachment.
530 *
531 * wp_mail() takes file paths, not bytes, so anything that wants to attach a
532 * PDF needs it on disk. The caller is responsible for deleting the file once
533 * wp_mail() has returned — see EmailManager, which does this on
534 * `phpmailer_init` teardown.
535 *
536 * @param object $document Invoice or Quote model.
537 * @param string $type 'invoice' or 'quote'.
538 * @return string|\WP_Error Absolute path to the written file.
539 */
540 public static function renderToFile( $document, string $type = 'invoice' ) {
541 $pdf = 'quote' === $type ? self::renderQuote( $document ) : self::renderInvoice( $document );
542 if ( is_wp_error( $pdf ) ) {
543 return $pdf;
544 }
545
546 $number = '';
547 if ( is_callable( [ $document, 'getNumber' ] ) ) {
548 $number = (string) $document->getNumber();
549 }
550 $name = sanitize_file_name( ( $number !== '' ? $number : $type ) . '.pdf' );
551
552 $dir = get_temp_dir();
553 if ( ! $dir || ! wp_is_writable( $dir ) ) {
554 return new \WP_Error( 'easy_invoice_pdf_no_tempdir', __( 'No writable temporary directory is available for the PDF.', 'easy-invoice' ) );
555 }
556
557 // wp_unique_filename keeps concurrent sends from overwriting each other.
558 $path = trailingslashit( $dir ) . wp_unique_filename( $dir, $name );
559
560 if ( false === file_put_contents( $path, $pdf ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
561 return new \WP_Error( 'easy_invoice_pdf_write_failed', __( 'The PDF could not be written to disk.', 'easy-invoice' ) );
562 }
563
564 return $path;
565 }
566 }
567