mirror of
				https://github.com/invoiceninja/invoiceninja.git
				synced 2025-10-31 09:57:33 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			408 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			408 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?php
 | |
| 
 | |
| /**
 | |
|  * Invoice Ninja (https://invoiceninja.com).
 | |
|  *
 | |
|  * @link https://github.com/invoiceninja/invoiceninja source repository
 | |
|  *
 | |
|  * @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
 | |
|  *
 | |
|  * @license https://www.elastic.co/licensing/elastic-license
 | |
|  */
 | |
| 
 | |
| namespace App\Services\PdfMaker\Designs\Utilities;
 | |
| 
 | |
| use App\Utils\Traits\MakesHash;
 | |
| use DOMDocument;
 | |
| use DOMXPath;
 | |
| use Exception;
 | |
| use League\CommonMark\CommonMarkConverter;
 | |
| 
 | |
| trait DesignHelpers
 | |
| {
 | |
|     use MakesHash;
 | |
| 
 | |
|     public $document;
 | |
| 
 | |
|     public $xpath;
 | |
| 
 | |
|     public function setup(): self
 | |
|     {
 | |
|         $this->syncPdfVariables();
 | |
| 
 | |
|         if (isset($this->context['client'])) {
 | |
|             $this->client = $this->context['client'];
 | |
|         }
 | |
| 
 | |
|         if (isset($this->context['entity'])) {
 | |
|             $this->entity = $this->context['entity'];
 | |
|         }
 | |
| 
 | |
|         if (isset($this->context['invoices'])) {
 | |
|             $this->invoices = $this->context['invoices'];
 | |
|             
 | |
|             if ($this->invoices->count() >= 1) {
 | |
|                 $this->entity = $this->invoices->first();
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         if (isset($this->context['payments'])) {
 | |
|             $this->payments = $this->context['payments'];
 | |
|         }
 | |
| 
 | |
|         if (isset($this->context['aging'])) {
 | |
|             $this->aging = $this->context['aging'];
 | |
|         }
 | |
| 
 | |
|         $this->document();
 | |
| 
 | |
|         return $this;
 | |
|     }
 | |
| 
 | |
|     protected function syncPdfVariables(): void
 | |
|     {
 | |
|         $default = (array) \App\DataMapper\CompanySettings::getEntityVariableDefaults();
 | |
|         $variables = $this->context['pdf_variables'];
 | |
| 
 | |
|         foreach ($default as $property => $value) {
 | |
|             if (array_key_exists($property, $variables)) {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             $variables[$property] = $value;
 | |
|         }
 | |
| 
 | |
|         $this->context['pdf_variables'] = $variables;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Initialize local dom document instance. Used for getting raw HTML out of template.
 | |
|      *
 | |
|      * @return $this
 | |
|      */
 | |
|     public function document(): self
 | |
|     {
 | |
|         $document = new DOMDocument();
 | |
| 
 | |
|         $document->validateOnParse = true;
 | |
|         @$document->loadHTML($this->html());
 | |
| 
 | |
|         $this->document = $document;
 | |
|         $this->xpath = new DOMXPath($document);
 | |
| 
 | |
|         return $this;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Get specific section HTML.
 | |
|      *
 | |
|      * @param string $section
 | |
|      * @param bool $id
 | |
|      * @return null|string
 | |
|      */
 | |
|     public function getSectionHTML(string $section, $id = true): ?string
 | |
|     {
 | |
|         if ($id) {
 | |
|             $element = $this->document->getElementById($section);
 | |
|         } else {
 | |
|             $elements = $this->document->getElementsByTagName($section);
 | |
|             $element = $elements[0];
 | |
|         }
 | |
| 
 | |
|         $document = new DOMDocument();
 | |
|         $document->preserveWhiteSpace = false;
 | |
|         $document->formatOutput = true;
 | |
| 
 | |
|         if ($element) {
 | |
|             $document->appendChild(
 | |
|                 $document->importNode($element, true)
 | |
|             );
 | |
| 
 | |
|             $html = $document->saveHTML();
 | |
| 
 | |
|             return str_replace('%24', '$', $html);
 | |
|         }
 | |
| 
 | |
|         return '';
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * This method will help us decide either we show
 | |
|      * one "tax rate" column in the table or 3 custom tax rates.
 | |
|      *
 | |
|      * Logic below will help us calculate that & inject the result in the
 | |
|      * global state of the $context (design state).
 | |
|      *
 | |
|      * @param string $type "product" or "task"
 | |
|      * @return void
 | |
|      */
 | |
|     public function processTaxColumns(string $type): void
 | |
|     {
 | |
|         if ($type == 'product') {
 | |
|             $type_id = 1;
 | |
|         }
 | |
| 
 | |
|         if ($type == 'task') {
 | |
|             $type_id = 2;
 | |
|         }
 | |
| 
 | |
|         // At the moment we pass "task" or "product" as type.
 | |
|         // However, "pdf_variables" contains "$task.tax" or "$product.tax" <-- Notice the dollar sign.
 | |
|         // This sprintf() will help us convert "task" or "product" into "$task" or "$product" without
 | |
|         // evaluating the variable.
 | |
| 
 | |
|         if (in_array(sprintf('%s%s.tax', '$', $type), (array)$this->context['pdf_variables']["{$type}_columns"])) {
 | |
|             $line_items = collect($this->entity->line_items)->filter(function ($item) use ($type_id) {
 | |
|                 return $item->type_id = $type_id;
 | |
|             });
 | |
| 
 | |
|             $tax1 = $line_items->where('tax_name1', '<>', '')->where('type_id', $type_id)->count();
 | |
|             $tax2 = $line_items->where('tax_name2', '<>', '')->where('type_id', $type_id)->count();
 | |
|             $tax3 = $line_items->where('tax_name3', '<>', '')->where('type_id', $type_id)->count();
 | |
| 
 | |
|             $taxes = [];
 | |
| 
 | |
|             if ($tax1 > 0) {
 | |
|                 array_push($taxes, sprintf('%s%s.tax_rate1', '$', $type));
 | |
|             }
 | |
| 
 | |
|             if ($tax2 > 0) {
 | |
|                 array_push($taxes, sprintf('%s%s.tax_rate2', '$', $type));
 | |
|             }
 | |
| 
 | |
|             if ($tax3 > 0) {
 | |
|                 array_push($taxes, sprintf('%s%s.tax_rate3', '$', $type));
 | |
|             }
 | |
| 
 | |
|             $key = array_search(sprintf('%s%s.tax', '$', $type), $this->context['pdf_variables']["{$type}_columns"], true);
 | |
| 
 | |
|             if ($key) {
 | |
|                 array_splice($this->context['pdf_variables']["{$type}_columns"], $key, 1, $taxes);
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Calculates the remaining colspans.
 | |
|      *
 | |
|      * @param int $taken
 | |
|      * @return int
 | |
|      */
 | |
|     public function calculateColspan(int $taken): int
 | |
|     {
 | |
|         $total = (int)count($this->context['pdf_variables']['product_columns']);
 | |
| 
 | |
|         return (int)$total - $taken;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Return "true" or "false" based on null or empty check.
 | |
|      * We need to return false as string because of HTML parsing.
 | |
|      *
 | |
|      * @param mixed $property
 | |
|      * @return string
 | |
|      */
 | |
|     public function toggleHiddenProperty($property): string
 | |
|     {
 | |
|         if (is_null($property)) {
 | |
|             return 'false';
 | |
|         }
 | |
| 
 | |
|         if (empty($property)) {
 | |
|             return 'false';
 | |
|         }
 | |
| 
 | |
|         return 'true';
 | |
|     }
 | |
| 
 | |
|     public function sharedFooterElements()
 | |
|     {
 | |
|         // We want to show headers for statements, no exceptions.
 | |
|         $statements = "
 | |
|             document.querySelectorAll('#statement-invoice-table > thead > tr > th, #statement-payment-table > thead > tr > th, #statement-aging-table > thead > tr > th').forEach(t => {
 | |
|                 t.hidden = false;
 | |
|             });
 | |
|         ";
 | |
|     
 | |
|         // Unminified version, just for the reference.
 | |
|         // By default all table headers are hidden with HTML `hidden` property.
 | |
|         // This will check for table data values & if they're not empty it will remove hidden from the column itself.
 | |
| 
 | |
|         /*
 | |
| document.addEventListener('DOMContentLoaded', function() {
 | |
|     document.querySelectorAll("#product-table > tbody > tr > td, #task-table > tbody > tr > td, #delivery-note-table > tbody > tr > td").forEach(e => {
 | |
|         if ("" !== e.innerText) {
 | |
|             let t = e.getAttribute("data-ref").slice(0, -3);
 | |
|             document.querySelector(`th[data-ref="${t}-th"]`).removeAttribute("hidden");
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     document.querySelectorAll("#product-table > tbody > tr > td, #task-table > tbody > tr > td, #delivery-note-table > tbody > tr > td").forEach(e => {
 | |
|         let t = e.getAttribute("data-ref").slice(0, -3);
 | |
|         t = document.querySelector(`th[data-ref="${t}-th"]`);
 | |
| 
 | |
|         if (!t.hasAttribute('hidden')) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         if ("" == e.innerText) {
 | |
|             e.setAttribute('hidden', 'true');
 | |
|         }
 | |
|     });
 | |
| }, false);
 | |
|         */
 | |
| 
 | |
|         $javascript = 'document.addEventListener("DOMContentLoaded",function(){document.querySelectorAll("#product-table > tbody > tr > td, #task-table > tbody > tr > td, #delivery-note-table > tbody > tr > td").forEach(t=>{if(""!==t.innerText){let e=t.getAttribute("data-ref").slice(0,-3);document.querySelector(`th[data-ref="${e}-th"]`).removeAttribute("hidden")}}),document.querySelectorAll("#product-table > tbody > tr > td, #task-table > tbody > tr > td, #delivery-note-table > tbody > tr > td").forEach(t=>{let e=t.getAttribute("data-ref").slice(0,-3);(e=document.querySelector(`th[data-ref="${e}-th"]`)).hasAttribute("hidden")&&""==t.innerText&&t.setAttribute("hidden","true")})},!1);';
 | |
| 
 | |
|         // Previously we've been decoding the HTML on the backend and XML parsing isn't good options because it requires,
 | |
|         // strict & valid HTML to even output/decode. Decoding is now done on the frontend with this piece of Javascript.
 | |
| 
 | |
|         /**
 | |
|         document.addEventListener('DOMContentLoaded', function() {
 | |
|             document.querySelectorAll(`[data-state="encoded-html"]`).forEach((element) => element.innerHTML = element.innerText)
 | |
|         }, false);
 | |
|          */
 | |
| 
 | |
|         $html_decode = 'document.addEventListener("DOMContentLoaded",function(){document.querySelectorAll(`[data-state="encoded-html"]`).forEach(e=>e.innerHTML=e.innerText)},!1);';
 | |
| 
 | |
|         return ['element' => 'div', 'elements' => [
 | |
|             ['element' => 'script', 'content' => $statements],
 | |
|             ['element' => 'script', 'content' => $javascript],
 | |
|             ['element' => 'script', 'content' => $html_decode],
 | |
|         ]];
 | |
|     }
 | |
| 
 | |
|     public function entityVariableCheck(string $variable): bool
 | |
|     {
 | |
|         // Extract $invoice.date => date
 | |
|         // so we can append date as $entity->date and not $entity->$invoice.date;
 | |
| 
 | |
|         // When it comes to invoice balance, we'll always show it.
 | |
|         if ($variable == '$invoice.total') {
 | |
|             return false;
 | |
|         }
 | |
| 
 | |
|         // Some variables don't map 1:1 to table columns. This gives us support for such cases.        
 | |
|         $aliases = [
 | |
|             '$quote.balance_due' => 'partial',
 | |
|         ];
 | |
| 
 | |
|         try {
 | |
|             $_variable = explode('.', $variable)[1];
 | |
|         } catch (Exception $e) {
 | |
|             throw new Exception('Company settings seems to be broken. Missing $entity.variable type.');
 | |
|         }
 | |
| 
 | |
|         if (\in_array($variable, \array_keys($aliases))) {
 | |
|             $_variable = $aliases[$variable];
 | |
|         }
 | |
| 
 | |
|         if (is_null($this->entity->{$_variable})) {
 | |
|             return true;
 | |
|         }
 | |
| 
 | |
|         if (empty($this->entity->{$_variable})) {
 | |
|             return true;
 | |
|         }
 | |
| 
 | |
|         return false;
 | |
|     }
 | |
| 
 | |
|     public function composeFromPartials(array $partials)
 | |
|     {
 | |
|         $html = '';
 | |
| 
 | |
|         $html .= $partials['includes'];
 | |
|         $html .= $partials['header'];
 | |
|         $html .= $partials['body'];
 | |
|         $html .= $partials['footer'];
 | |
| 
 | |
|         return $html;
 | |
|     }
 | |
| 
 | |
|     public function processCustomColumns(string $type): void
 | |
|     {
 | |
|         $custom_columns = [];
 | |
| 
 | |
|         foreach ((array)$this->client->company->custom_fields as $field => $value) {
 | |
|             info($field);
 | |
| 
 | |
|             if (\Illuminate\Support\Str::startsWith($field, $type)) {
 | |
|                 $custom_columns[] = '$' . $type . '.' . $field;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         $key = array_search(sprintf('%s%s.description', '$', $type), $this->context['pdf_variables']["{$type}_columns"], true);
 | |
| 
 | |
|         if ($key) {
 | |
|             array_splice($this->context['pdf_variables']["{$type}_columns"], $key + 1, 0, $custom_columns);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public function getCustomFieldValue(string $field): string
 | |
|     {
 | |
|         // In custom_fields column we store fields like: company1-4,
 | |
|         // while in settings, they're stored in custom_value1-4 format.
 | |
|         // That's why we need this mapping.
 | |
| 
 | |
|         $fields = [
 | |
|             'company1' => 'custom_value1',
 | |
|             'company2' => 'custom_value2',
 | |
|             'company3' => 'custom_value3',
 | |
|             'company4' => 'custom_value4',
 | |
|         ];
 | |
| 
 | |
|         if (!array_key_exists($field, $fields)) {
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         if ($this->client->company->custom_fields && !property_exists($this->client->company->custom_fields, $field)) {
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         $value = $this->client->company->getSetting($fields[$field]);
 | |
| 
 | |
|         return (new \App\Utils\Helpers)->formatCustomFieldValue(
 | |
|             $this->client->company->custom_fields,
 | |
|             $field,
 | |
|             $value,
 | |
|             $this->client
 | |
|         );
 | |
|     }
 | |
| 
 | |
|     public static function parseMarkdownToHtml(string $markdown): ?string
 | |
|     {
 | |
|         // Use setting to determinate if parsing should be done.
 | |
|         // 'parse_markdown_on_pdfs'
 | |
| 
 | |
|         $converter = new CommonMarkConverter([
 | |
|             'allow_unsafe_links' => false,
 | |
|         ]);
 | |
| 
 | |
|         return $converter->convert($markdown);
 | |
|     }
 | |
| 
 | |
|     // public function processMarkdownOnLineItems(array &$items): void
 | |
|     // {
 | |
|     //     foreach ($items as $key => $item) {
 | |
|     //         foreach ($item as $variable => $value) {
 | |
|     //             $item[$variable] = DesignHelpers::parseMarkdownToHtml($value ?? '');
 | |
|     //         }
 | |
| 
 | |
|     //         $items[$key] = $item;
 | |
|     //     }
 | |
|     // }
 | |
| 
 | |
|     public function processNewLines(array &$items): void
 | |
|     {
 | |
|         foreach ($items as $key => $item) {
 | |
|             foreach ($item as $variable => $value) {
 | |
|                // $item[$variable] = nl2br($value, true);
 | |
|                $item[$variable] = str_replace( "\n", '<br>', $value);
 | |
|             }
 | |
| 
 | |
|             $items[$key] = $item;
 | |
|         }
 | |
|     }
 | |
| }
 |