user_id ); $s = DevisQR_Settings::resolved(); /** * Filter the company block printed on the PDF. * * @param array $company Company data. * @param object $quote Quote. */ $company = apply_filters( 'devisqr_pdf_company_info', array( 'name' => $s['company_name'], 'address' => $s['company_address'], 'postcode' => $s['company_postcode'], 'city' => $s['company_city'], 'phone' => $s['company_phone'], 'email' => $s['company_email'], 'registration' => $s['company_registration'], 'vat' => $s['company_vat'], ), $quote ); try { $pdf = new DevisQR_PDF_Writer(); $pdf->AddPage(); $pw = $pdf->GetPageWidth(); $left = 14; $right = $pw - 14; $content_w = $right - $left; $lh = 5; $dash = str_repeat( '- ', 45 ); $name_upper = DevisQR_Helpers::upper( $company['name'] ); $logo_path = self::logo_path(); // ===== Header: logo + name ===== $pdf->SetFont( 'courier', 'B', 16 ); $pdf->SetTextColor( 0, 0, 0 ); if ( $logo_path ) { $logo_size = 18; $name_w = $pdf->GetStringWidth( $name_upper ); $total_w = $logo_size + 4 + $name_w; $start_x = max( $left, ( $pw - $total_w ) / 2 ); $pdf->AddImage( $logo_path, $start_x, 10, $logo_size, $logo_size ); $pdf->SetXY( $start_x + $logo_size + 4, 14 ); $pdf->Cell( $name_w + 5, 8, $name_upper, 0, 1, 'L' ); } else { $pdf->SetXY( $left, 14 ); $pdf->Cell( $content_w, 8, $name_upper, 0, 1, 'C' ); } // Company address. $pdf->SetFont( 'courier', '', 8 ); $pdf->SetTextColor( 60, 60, 60 ); $y = 30; foreach ( self::company_lines( $company, false ) as $line ) { $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, $lh, $line, 0, 1, 'C' ); $y += $lh; } // Dashed line. $y += 3; $pdf->SetTextColor( 0, 0, 0 ); $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, $lh, $dash, 0, 1, 'C' ); $y += $lh + 1; // ===== Title ===== $pdf->SetFont( 'courier', 'B', 14 ); $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, 7, DevisQR_Helpers::upper( __( 'Quote', 'nuxwp-devis' ) ), 0, 1, 'C' ); $y += 9; $pdf->SetFont( 'courier', 'B', 9 ); $pdf->SetXY( $left, $y ); /* translators: 1: quote number, 2: date */ $pdf->Cell( $content_w, $lh, sprintf( __( '%1$s — %2$s', 'nuxwp-devis' ), $quote->quote_number, DevisQR_Helpers::date_text( $quote->created_at ) ), 0, 1, 'C' ); $y += $lh; // Status badge (not for pending). $badge_colors = array( 'quoted' => array( 13, 71, 161 ), 'accepted' => array( 5, 150, 105 ), 'rejected' => array( 183, 28, 28 ), 'expired' => array( 230, 81, 0 ), 'converted' => array( 124, 58, 237 ), ); if ( isset( $badge_colors[ $quote->status ] ) ) { $labels = DevisQR_Helpers::status_labels(); $c = $badge_colors[ $quote->status ]; $pdf->SetFont( 'courier', 'B', 10 ); $pdf->SetTextColor( $c[0], $c[1], $c[2] ); $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, $lh, '>>> ' . DevisQR_Helpers::upper( $labels[ $quote->status ] ) . ' <<<', 0, 1, 'C' ); $pdf->SetTextColor( 0, 0, 0 ); $y += $lh; } if ( $quote->expires_at ) { $pdf->SetFont( 'courier', '', 8 ); $pdf->SetXY( $left, $y ); /* translators: %s: date */ $pdf->Cell( $content_w, $lh, sprintf( __( 'Valid until %s', 'nuxwp-devis' ), DevisQR_Helpers::date_text( $quote->expires_at ) ), 0, 1, 'C' ); $y += $lh; } $y += 2; $pdf->SetFont( 'courier', '', 8 ); $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, $lh, $dash, 0, 1, 'C' ); $y += $lh + 2; // ===== Items ===== $x_label = __( 'x', 'nuxwp-devis' ); $tax_label = __( 'Tax', 'nuxwp-devis' ); $ht_label = __( 'excl. tax', 'nuxwp-devis' ); foreach ( $items as $item ) { $discount = isset( $item->discount_percent ) ? (float) $item->discount_percent : 0; $name = $item->product_name; if ( mb_strlen( $name ) > 55 ) { $name = mb_substr( $name, 0, 52 ) . '...'; } $pdf->SetFont( 'courier', 'B', 8.5 ); $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, $lh, $name, 0, 1, 'L' ); $y += $lh; $pdf->SetFont( 'courier', '', 8 ); $ref = $item->product_sku ? $item->product_sku : '---'; $qty = DevisQR_Helpers::qty_text( $item->quantity ); $global_f = 1 - ( (float) ( isset( $quote->discount_percent ) ? $quote->discount_percent : 0 ) ) / 100; $unit_shown = round( (float) $item->unit_price_ttc * ( 1 - $discount / 100 ) * $global_f, 2 ); $price_line = $ref . str_repeat( ' ', 6 ) . $qty . ' ' . $x_label . ' ' . DevisQR_Helpers::price_text( $unit_shown ) . ' = ' . DevisQR_Helpers::price_text( $item->line_total_ttc ); $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, $lh, $price_line, 0, 1, 'L' ); $y += $lh; if ( $discount > 0 ) { $pdf->SetFont( 'courier', 'B', 7.5 ); $pdf->SetTextColor( 183, 28, 28 ); $pdf->SetXY( $left, $y ); /* translators: 1: discount percent, 2: original unit price */ $pdf->Cell( $content_w, 3.5, sprintf( __( 'Discount -%1$s (orig. unit price: %2$s)', 'nuxwp-devis' ), DevisQR_Helpers::percent_text( $discount ), DevisQR_Helpers::price_text( $item->unit_price_ttc ) ), 0, 1, 'L' ); $pdf->SetTextColor( 0, 0, 0 ); $y += 4; } $pdf->SetFont( 'courier', '', 7 ); $pdf->SetTextColor( 100, 100, 100 ); $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, 3.5, $tax_label . ' ' . DevisQR_Helpers::percent_text( $item->tax_rate ) . ' | ' . $ht_label . ': ' . DevisQR_Helpers::price_text( $item->line_total_ht ), 0, 1, 'L' ); $pdf->SetTextColor( 0, 0, 0 ); $y += 5; if ( $pdf->NeedNewPage( 40 ) ) { $pdf->AddPage(); $y = 20; $pdf->SetFont( 'courier', '', 8 ); } } $y += 1; $pdf->SetFont( 'courier', '', 8 ); $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, $lh, $dash, 0, 1, 'C' ); $y += $lh + 2; // ===== Totals ===== $label_w = 95; $val_w = $content_w - $label_w; $pdf->SetFont( 'courier', 'B', 10 ); $pdf->SetXY( $left, $y ); $pdf->Cell( $label_w, 6, DevisQR_Helpers::upper( __( 'Total incl. tax', 'nuxwp-devis' ) ), 0, 0, 'L' ); $pdf->Cell( $val_w, 6, DevisQR_Helpers::price_text( $quote->total_ttc ), 0, 1, 'R' ); $y += 7; $rates_seen = array(); foreach ( $items as $it ) { $rates_seen[ number_format( (float) $it->tax_rate, 2, '.', '' ) ] = true; } $tax_total_label = 1 === count( $rates_seen ) /* translators: %s: tax rate */ ? sprintf( __( 'Incl. tax (%s)', 'nuxwp-devis' ), DevisQR_Helpers::percent_text( (float) key( $rates_seen ) ) ) : __( 'Incl. tax', 'nuxwp-devis' ); $pdf->SetFont( 'courier', '', 9 ); $pdf->SetXY( $left, $y ); $pdf->Cell( $label_w, $lh, $tax_total_label, 0, 0, 'L' ); $pdf->Cell( $val_w, $lh, DevisQR_Helpers::price_text( $quote->total_tax ), 0, 1, 'R' ); $y += $lh + 1; if ( ! empty( $quote->discount_percent ) && (float) $quote->discount_percent > 0 ) { $pdf->SetXY( $left, $y ); $pdf->Cell( $label_w, $lh, __( 'Global discount applied', 'nuxwp-devis' ), 0, 0, 'L' ); $pdf->Cell( $val_w, $lh, '-' . DevisQR_Helpers::percent_text( $quote->discount_percent ), 0, 1, 'R' ); $y += $lh + 1; } $pdf->SetXY( $left, $y ); $pdf->Cell( $label_w, $lh, DevisQR_Helpers::upper( __( 'Total excl. tax', 'nuxwp-devis' ) ), 0, 0, 'L' ); $pdf->Cell( $val_w, $lh, DevisQR_Helpers::price_text( $quote->total_ht ), 0, 1, 'R' ); $y += $lh + 1; $pdf->SetXY( $left, $y ); $pdf->Cell( $label_w, $lh, __( 'Number of lines', 'nuxwp-devis' ), 0, 0, 'L' ); $pdf->Cell( $val_w, $lh, (string) count( $items ), 0, 1, 'R' ); $y += $lh; $y += 2; $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, $lh, $dash, 0, 1, 'C' ); $y += $lh + 2; // ===== Customer ===== $pdf->SetFont( 'courier', '', 8 ); $cust_company = $user ? get_user_meta( $user->ID, 'billing_company', true ) : ''; $cust_name = $user ? DevisQR_Helpers::upper( $user->display_name ) : ''; $cust_email = $user ? $user->user_email : ''; $cust_addr1 = $user ? get_user_meta( $user->ID, 'billing_address_1', true ) : ''; $cust_post = $user ? get_user_meta( $user->ID, 'billing_postcode', true ) : ''; $cust_city = $user ? DevisQR_Helpers::upper( get_user_meta( $user->ID, 'billing_city', true ) ) : ''; $cust_phone = $user ? get_user_meta( $user->ID, 'billing_phone', true ) : ''; $pdf->SetXY( $left, $y ); /* translators: %s: customer account ID */ $pdf->Cell( $content_w, $lh, sprintf( __( 'Customer: %s', 'nuxwp-devis' ), $user ? '#' . $user->ID : '' ), 0, 1, 'L' ); $y += $lh; if ( $cust_company ) { $pdf->SetFont( 'courier', 'B', 8 ); $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, $lh, DevisQR_Helpers::upper( $cust_company ), 0, 1, 'L' ); $y += $lh; $pdf->SetFont( 'courier', '', 8 ); } foreach ( array_filter( array( $cust_name, $cust_addr1, trim( $cust_post . ' ' . $cust_city ), $cust_email ) ) as $line ) { $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, $lh, $line, 0, 1, 'L' ); $y += $lh; } if ( $cust_phone ) { $pdf->SetXY( $left, $y ); /* translators: %s: phone number */ $pdf->Cell( $content_w, $lh, sprintf( __( 'Phone: %s', 'nuxwp-devis' ), $cust_phone ), 0, 1, 'L' ); $y += $lh; } $y += 3; $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, $lh, $dash, 0, 1, 'C' ); $y += $lh + 2; // ===== Footer note ===== $pdf->SetFont( 'courier', '', 7.5 ); $pdf->SetTextColor( 60, 60, 60 ); foreach ( self::wrap_text( $s['pdf_footer_note'], 48 ) as $line ) { $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, 4, $line, 0, 1, 'C' ); $y += 4; } $y += 2; if ( $quote->expires_at ) { $pdf->SetXY( $left, $y ); /* translators: %s: date */ $pdf->Cell( $content_w, 4, sprintf( __( 'Offer valid until %s', 'nuxwp-devis' ), DevisQR_Helpers::date_text( $quote->expires_at ) ), 0, 1, 'C' ); $y += 6; } $pdf->SetXY( $left, $y ); $pdf->SetTextColor( 0, 0, 0 ); $pdf->Cell( $content_w, $lh, $dash, 0, 1, 'C' ); $y += $lh + 2; // ===== Company footer ===== $pdf->SetFont( 'courier', '', 7.5 ); $pdf->SetTextColor( 60, 60, 60 ); $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, 4, $name_upper, 0, 1, 'C' ); $y += 4; foreach ( self::company_lines( $company, true ) as $line ) { $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, 4, $line, 0, 1, 'C' ); $y += 4; } $y += 2; $pdf->SetXY( $left, $y ); $pdf->SetTextColor( 0, 0, 0 ); $pdf->Cell( $content_w, $lh, $dash, 0, 1, 'C' ); $y += $lh + 3; $pdf->SetFont( 'courier', 'B', 9 ); $pdf->SetXY( $left, $y ); $pdf->Cell( $content_w, $lh, $quote->quote_number, 0, 1, 'C' ); // ===== Save ===== DevisQR_Database::ensure_storage(); // Unguessable filename (the stored name is the only reference); drop the previous file. DevisQR_Database::delete_storage_file( $quote->pdf_path, 'pdf' ); $filename = 'quote-' . sanitize_file_name( $quote->quote_number ) . '-' . wp_generate_password( 16, false, false ) . '.pdf'; $pdf->SaveToFile( DevisQR_Database::storage_dir() . '/' . $filename ); return $filename; } catch ( \Throwable $e ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[NuxWP Devis] PDF error: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } return false; } } /** * Company lines for header (address, phone, e-mail) or footer (+ legal IDs). * * @param array $company Company. * @param bool $footer Footer variant. * @return array */ private static function company_lines( $company, $footer ) { $lines = array(); if ( $company['address'] ) { $lines[] = $company['address']; } if ( $company['postcode'] || $company['city'] ) { $lines[] = trim( $company['postcode'] . ' ' . $company['city'] ); } if ( $footer ) { if ( $company['registration'] ) { /* translators: %s: company registration number */ $lines[] = sprintf( __( 'Reg. no.: %s', 'nuxwp-devis' ), $company['registration'] ); } if ( $company['vat'] ) { /* translators: %s: VAT number */ $lines[] = sprintf( __( 'VAT no.: %s', 'nuxwp-devis' ), $company['vat'] ); } } if ( $company['phone'] ) { /* translators: %s: phone number */ $lines[] = sprintf( __( 'Phone: %s', 'nuxwp-devis' ), $company['phone'] ); } if ( ! $footer && $company['email'] ) { $lines[] = $company['email']; } return $lines; } /** * Logo file path (see DevisQR_Helpers::logo()). * * @return string */ private static function logo_path() { $logo = DevisQR_Helpers::logo(); return $logo ? $logo['path'] : ''; } /** * Naive word wrap for the monospace footer. * * @param string $text Text. * @param int $width Chars per line. * @return array */ private static function wrap_text( $text, $width ) { $lines = array(); foreach ( preg_split( '/\r\n|\r|\n/', (string) $text ) as $para ) { $words = preg_split( '/\s+/', trim( $para ) ); $line = ''; foreach ( $words as $w ) { if ( '' === $line ) { $line = $w; } elseif ( mb_strlen( $line . ' ' . $w ) <= $width ) { $line .= ' ' . $w; } else { $lines[] = $line; $line = $w; } } if ( '' !== $line ) { $lines[] = $line; } } return $lines; } }