From a821a1fa479c0d647f7c617aedbf4b73ac0feba9 Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Fri, 3 Jul 2026 12:23:47 +0200 Subject: [PATCH 01/10] =?UTF-8?q?feat(pdf):=20a=C3=B1ade=20librer=C3=ADa?= =?UTF-8?q?=20de=20PDFs=20din=C3=A1micos=20con=20constructor=20de=20bloque?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Lib/PDF/Dynamic/BlockInterface.php | 28 ++ Core/Lib/PDF/Dynamic/Blocks/AbstractBlock.php | 47 ++++ Core/Lib/PDF/Dynamic/Blocks/ColumnsBlock.php | 59 ++++ .../PDF/Dynamic/Blocks/CompanyHeaderBlock.php | 109 ++++++++ .../Dynamic/Blocks/DualColumnTableBlock.php | 46 ++++ Core/Lib/PDF/Dynamic/Blocks/ImageBlock.php | 75 ++++++ .../Lib/PDF/Dynamic/Blocks/PageBreakBlock.php | 31 +++ Core/Lib/PDF/Dynamic/Blocks/RawHtmlBlock.php | 47 ++++ Core/Lib/PDF/Dynamic/Blocks/SpacerBlock.php | 36 +++ Core/Lib/PDF/Dynamic/Blocks/TableBlock.php | 73 +++++ Core/Lib/PDF/Dynamic/Blocks/TextBlock.php | 39 +++ Core/Lib/PDF/Dynamic/Blocks/TitleBlock.php | 47 ++++ Core/Lib/PDF/Dynamic/PDFBuilder.php | 253 ++++++++++++++++++ Core/Lib/PDF/Dynamic/PDFPreviewTrait.php | 81 ++++++ Core/Lib/PDF/Dynamic/PDFStyles.php | 100 +++++++ 15 files changed, 1071 insertions(+) create mode 100644 Core/Lib/PDF/Dynamic/BlockInterface.php create mode 100644 Core/Lib/PDF/Dynamic/Blocks/AbstractBlock.php create mode 100644 Core/Lib/PDF/Dynamic/Blocks/ColumnsBlock.php create mode 100644 Core/Lib/PDF/Dynamic/Blocks/CompanyHeaderBlock.php create mode 100644 Core/Lib/PDF/Dynamic/Blocks/DualColumnTableBlock.php create mode 100644 Core/Lib/PDF/Dynamic/Blocks/ImageBlock.php create mode 100644 Core/Lib/PDF/Dynamic/Blocks/PageBreakBlock.php create mode 100644 Core/Lib/PDF/Dynamic/Blocks/RawHtmlBlock.php create mode 100644 Core/Lib/PDF/Dynamic/Blocks/SpacerBlock.php create mode 100644 Core/Lib/PDF/Dynamic/Blocks/TableBlock.php create mode 100644 Core/Lib/PDF/Dynamic/Blocks/TextBlock.php create mode 100644 Core/Lib/PDF/Dynamic/Blocks/TitleBlock.php create mode 100644 Core/Lib/PDF/Dynamic/PDFBuilder.php create mode 100644 Core/Lib/PDF/Dynamic/PDFPreviewTrait.php create mode 100644 Core/Lib/PDF/Dynamic/PDFStyles.php diff --git a/Core/Lib/PDF/Dynamic/BlockInterface.php b/Core/Lib/PDF/Dynamic/BlockInterface.php new file mode 100644 index 0000000000..a818690ad0 --- /dev/null +++ b/Core/Lib/PDF/Dynamic/BlockInterface.php @@ -0,0 +1,28 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic; + +/** + * Interface for every block that can be added to a dynamic PDF document. + */ +interface BlockInterface +{ + public function render(): string; +} diff --git a/Core/Lib/PDF/Dynamic/Blocks/AbstractBlock.php b/Core/Lib/PDF/Dynamic/Blocks/AbstractBlock.php new file mode 100644 index 0000000000..26eb1c75fb --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/AbstractBlock.php @@ -0,0 +1,47 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +use FacturaScripts\Core\Lib\PDF\Dynamic\BlockInterface; + +/** + * Base class for all dynamic PDF blocks. + */ +abstract class AbstractBlock implements BlockInterface +{ + /** @var string */ + protected $cssClass = ''; + + public function setCssClass(string $cssClass): self + { + $this->cssClass = $cssClass; + return $this; + } + + protected function css(string $base): string + { + return trim($base . ' ' . $this->cssClass); + } + + protected function escape(?string $text): string + { + return htmlspecialchars($text ?? '', ENT_QUOTES, 'UTF-8'); + } +} diff --git a/Core/Lib/PDF/Dynamic/Blocks/ColumnsBlock.php b/Core/Lib/PDF/Dynamic/Blocks/ColumnsBlock.php new file mode 100644 index 0000000000..4729f2a1f6 --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/ColumnsBlock.php @@ -0,0 +1,59 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +use FacturaScripts\Core\Lib\PDF\Dynamic\BlockInterface; + +/** + * Row with N columns, each one containing a list of blocks. Optional widths in percentage. + */ +class ColumnsBlock extends AbstractBlock +{ + /** @var BlockInterface[][] */ + protected $columns; + + /** @var array */ + protected $widths; + + public function __construct(array $columnsOfBlocks, array $widths = []) + { + $this->columns = $columnsOfBlocks; + $this->widths = $widths; + } + + public function render(): string + { + $html = '
'; + foreach (array_values($this->columns) as $num => $blocks) { + $style = isset($this->widths[$num]) ? + ' style="flex: 0 0 ' . (float)$this->widths[$num] . '%;"' : + ''; + $html .= ''; + foreach ($blocks as $block) { + if ($block instanceof BlockInterface) { + $html .= $block->render(); + } + } + $html .= '
'; + } + + return $html . ''; + } +} diff --git a/Core/Lib/PDF/Dynamic/Blocks/CompanyHeaderBlock.php b/Core/Lib/PDF/Dynamic/Blocks/CompanyHeaderBlock.php new file mode 100644 index 0000000000..4680acd7d7 --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/CompanyHeaderBlock.php @@ -0,0 +1,109 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +use FacturaScripts\Core\Model\Empresa; +use FacturaScripts\Dinamic\Model\AttachedFile; + +/** + * Company header: logo (from the company attached file, embedded as base64) + * plus name, address and fiscal data. The logo can go left, right or centered. + */ +class CompanyHeaderBlock extends AbstractBlock +{ + /** @var Empresa */ + protected $empresa; + + /** @var string */ + protected $logoAlign; + + public function __construct(Empresa $empresa, string $logoAlign = 'left') + { + $this->empresa = $empresa; + $this->logoAlign = in_array($logoAlign, ['center', 'right']) ? $logoAlign : 'left'; + } + + public function render(): string + { + $logo = $this->renderLogo(); + $data = '
' + . '
' . $this->escape($this->empresa->nombre) . '
' + . '
' . $this->escape($this->empresa->cifnif) . '
' + . '
' . $this->escape($this->combineAddress()) . '
' + . '
' . $this->escape($this->combineContact()) . '
' + . '
'; + + if (empty($logo)) { + return '
' . $data . '
'; + } + + if ($this->logoAlign === 'center') { + return '
' + . $logo . $data . '
'; + } + + $parts = $this->logoAlign === 'right' ? $data . $logo : $logo . $data; + return '
' . $parts . '
'; + } + + protected function combineAddress(): string + { + $parts = []; + foreach ([$this->empresa->direccion, $this->empresa->codpostal, $this->empresa->ciudad, $this->empresa->provincia] as $part) { + if (!empty($part)) { + $parts[] = $part; + } + } + + return implode(', ', $parts); + } + + protected function combineContact(): string + { + $parts = []; + foreach ([$this->empresa->telefono1, $this->empresa->telefono2, $this->empresa->email, $this->empresa->web] as $part) { + if (!empty($part)) { + $parts[] = $part; + } + } + + return implode(' · ', $parts); + } + + protected function renderLogo(): string + { + if (empty($this->empresa->idlogo)) { + return ''; + } + + $logoFile = new AttachedFile(); + if (false === $logoFile->load($this->empresa->idlogo) || false === $logoFile->isImage()) { + return ''; + } + + $path = $logoFile->getFullPath(); + if (false === file_exists($path)) { + return ''; + } + + $src = 'data:' . $logoFile->mimetype . ';base64,' . base64_encode(file_get_contents($path)); + return '
'; + } +} diff --git a/Core/Lib/PDF/Dynamic/Blocks/DualColumnTableBlock.php b/Core/Lib/PDF/Dynamic/Blocks/DualColumnTableBlock.php new file mode 100644 index 0000000000..e5e6916a19 --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/DualColumnTableBlock.php @@ -0,0 +1,46 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +/** + * Two column key => value table. + */ +class DualColumnTableBlock extends AbstractBlock +{ + /** @var array */ + protected $data; + + public function __construct(array $data, string $cssClass = 'table-dual') + { + $this->data = $data; + $this->cssClass = $cssClass; + } + + public function render(): string + { + $html = ''; + foreach ($this->data as $key => $value) { + $html .= '' + . ''; + } + + return $html . '
' . $this->escape((string)$key) . '' . $this->escape((string)$value) . '
'; + } +} diff --git a/Core/Lib/PDF/Dynamic/Blocks/ImageBlock.php b/Core/Lib/PDF/Dynamic/Blocks/ImageBlock.php new file mode 100644 index 0000000000..75bcac6e12 --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/ImageBlock.php @@ -0,0 +1,75 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +/** + * Image block. Local file paths are embedded as base64 data URIs so that + * html2canvas can always render them (no CORS or relative path issues). + */ +class ImageBlock extends AbstractBlock +{ + /** @var string */ + protected $src; + + /** @var ?int */ + protected $widthMm; + + /** @var string */ + protected $align; + + public function __construct(string $src, ?int $widthMm = null, string $align = 'left') + { + $this->src = $src; + $this->widthMm = $widthMm; + $this->align = in_array($align, ['center', 'right']) ? $align : 'left'; + } + + public function render(): string + { + $src = $this->resolveSrc(); + if (empty($src)) { + return ''; + } + + $style = $this->widthMm > 0 ? ' style="width: ' . $this->widthMm . 'mm;"' : ''; + return '
' + . '' + . '
'; + } + + protected function resolveSrc(): string + { + if (str_starts_with($this->src, 'data:') || filter_var($this->src, FILTER_VALIDATE_URL)) { + return $this->src; + } + + $path = file_exists($this->src) ? $this->src : FS_FOLDER . '/' . ltrim($this->src, '/'); + if (false === file_exists($path) || false === is_file($path)) { + return ''; + } + + $mime = mime_content_type($path); + if (false === str_starts_with((string)$mime, 'image/')) { + return ''; + } + + return 'data:' . $mime . ';base64,' . base64_encode(file_get_contents($path)); + } +} diff --git a/Core/Lib/PDF/Dynamic/Blocks/PageBreakBlock.php b/Core/Lib/PDF/Dynamic/Blocks/PageBreakBlock.php new file mode 100644 index 0000000000..f2cef25cce --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/PageBreakBlock.php @@ -0,0 +1,31 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +/** + * Marker block: PDFBuilder closes the current .page and opens a new one when it finds it. + */ +class PageBreakBlock extends AbstractBlock +{ + public function render(): string + { + return '
'; + } +} diff --git a/Core/Lib/PDF/Dynamic/Blocks/RawHtmlBlock.php b/Core/Lib/PDF/Dynamic/Blocks/RawHtmlBlock.php new file mode 100644 index 0000000000..4ea5b4b1d3 --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/RawHtmlBlock.php @@ -0,0 +1,47 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +/** + * Raw HTML block with basic sanitization: removes scripts, on* attributes and javascript: urls. + */ +class RawHtmlBlock extends AbstractBlock +{ + /** @var string */ + protected $html; + + public function __construct(string $html) + { + $this->html = $html; + } + + public function render(): string + { + return $this->sanitize($this->html); + } + + protected function sanitize(string $html): string + { + $html = preg_replace('/]*>.*?<\/script\s*>/is', '', $html); + $html = preg_replace('/]*\/?>/i', '', $html); + $html = preg_replace('/\son[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $html); + return preg_replace('/(href|src)\s*=\s*(["\']?)\s*javascript:[^"\'>\s]*/i', '$1=$2#', $html); + } +} diff --git a/Core/Lib/PDF/Dynamic/Blocks/SpacerBlock.php b/Core/Lib/PDF/Dynamic/Blocks/SpacerBlock.php new file mode 100644 index 0000000000..661a1eac3f --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/SpacerBlock.php @@ -0,0 +1,36 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +class SpacerBlock extends AbstractBlock +{ + /** @var int */ + protected $heightMm; + + public function __construct(int $heightMm = 5) + { + $this->heightMm = max($heightMm, 0); + } + + public function render(): string + { + return '
'; + } +} diff --git a/Core/Lib/PDF/Dynamic/Blocks/TableBlock.php b/Core/Lib/PDF/Dynamic/Blocks/TableBlock.php new file mode 100644 index 0000000000..5b264e8a1b --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/TableBlock.php @@ -0,0 +1,73 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +/** + * Table with optional header row, per-column alignments and alternating row colors. + */ +class TableBlock extends AbstractBlock +{ + /** @var array */ + protected $rows; + + /** @var array */ + protected $titles; + + /** @var array */ + protected $alignments; + + public function __construct(array $rows, array $titles = [], array $alignments = [], string $cssClass = 'table-list') + { + $this->rows = $rows; + $this->titles = $titles; + $this->alignments = $alignments; + $this->cssClass = $cssClass; + } + + public function render(): string + { + $html = ''; + + if (false === empty($this->titles)) { + $html .= ''; + foreach (array_values($this->titles) as $num => $title) { + $html .= ''; + } + $html .= ''; + } + + $html .= ''; + foreach ($this->rows as $row) { + $html .= ''; + foreach (array_values((array)$row) as $num => $cell) { + $html .= ''; + } + $html .= ''; + } + + return $html . '
' . $this->escape((string)$title) . '
' . $this->escape((string)$cell) . '
'; + } + + protected function alignment(int $num): string + { + $align = $this->alignments[$num] ?? 'left'; + return 'text-' . (in_array($align, ['center', 'right']) ? $align : 'left'); + } +} diff --git a/Core/Lib/PDF/Dynamic/Blocks/TextBlock.php b/Core/Lib/PDF/Dynamic/Blocks/TextBlock.php new file mode 100644 index 0000000000..d431b8957d --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/TextBlock.php @@ -0,0 +1,39 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +class TextBlock extends AbstractBlock +{ + /** @var string */ + protected $text; + + public function __construct(string $text, string $cssClass = '') + { + $this->text = $text; + $this->cssClass = $cssClass; + } + + public function render(): string + { + return '

' + . nl2br($this->escape($this->text)) + . '

'; + } +} diff --git a/Core/Lib/PDF/Dynamic/Blocks/TitleBlock.php b/Core/Lib/PDF/Dynamic/Blocks/TitleBlock.php new file mode 100644 index 0000000000..edecb38f5a --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/TitleBlock.php @@ -0,0 +1,47 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +class TitleBlock extends AbstractBlock +{ + /** @var string */ + protected $text; + + /** @var int */ + protected $level; + + /** @var string */ + protected $align; + + public function __construct(string $text, int $level = 1, string $align = 'left') + { + $this->text = $text; + $this->level = min(max($level, 1), 3); + $this->align = $align; + } + + public function render(): string + { + $tag = 'h' . $this->level; + return '<' . $tag . ' class="' . $this->css('title text-' . $this->align) . '">' + . $this->escape($this->text) + . ''; + } +} diff --git a/Core/Lib/PDF/Dynamic/PDFBuilder.php b/Core/Lib/PDF/Dynamic/PDFBuilder.php new file mode 100644 index 0000000000..4ab6214ade --- /dev/null +++ b/Core/Lib/PDF/Dynamic/PDFBuilder.php @@ -0,0 +1,253 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic; + +use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\PageBreakBlock; +use FacturaScripts\Core\Model\Empresa; +use FacturaScripts\Core\Tools; + +/** + * Builds a dynamic PDF document: an A4 HTML container where blocks (titles, + * texts, images, tables...) are added with a fluent interface. The resulting + * HTML embeds html2pdf.js so the browser can print it or convert it to PDF. + * + * $html = PDFBuilder::create() + * ->setTitle('my-doc') + * ->addTitle('Invoice F-2026-001') + * ->addTable($rows, $titles) + * ->getHtml(); + */ +class PDFBuilder +{ + const ORIENTATION_PORTRAIT = 'portrait'; + const ORIENTATION_LANDSCAPE = 'landscape'; + + /** @var BlockInterface[] */ + protected $blocks = []; + + /** @var string[] */ + protected $cssExtra = []; + + /** @var int */ + protected $margin = 20; + + /** @var string */ + protected $orientation = self::ORIENTATION_PORTRAIT; + + /** @var array */ + protected $styleOptions = []; + + /** @var string */ + protected $title = 'document'; + + /** @var array name => className */ + protected static $customBlocks = []; + + public static function create(): self + { + $dynClass = '\\FacturaScripts\\Dinamic\\Lib\\PDF\\Dynamic\\PDFBuilder'; + if (class_exists($dynClass) && static::class === self::class) { + return new $dynClass(); + } + + return new static(); + } + + public static function addCustomBlock(string $name, string $className): void + { + static::$customBlocks[$name] = $className; + } + + public function add(string $name, ...$args): self + { + if (isset(static::$customBlocks[$name])) { + $className = static::$customBlocks[$name]; + return $this->addBlock(new $className(...$args)); + } + + return $this->addBlock($this->newBlock($name, ...$args)); + } + + public function addBlock(BlockInterface $block): self + { + $this->blocks[] = $block; + return $this; + } + + public function addColumns(array $columnsOfBlocks, array $widths = []): self + { + return $this->add('Columns', $columnsOfBlocks, $widths); + } + + public function addCompanyHeader(Empresa $empresa, string $logoAlign = 'left'): self + { + return $this->add('CompanyHeader', $empresa, $logoAlign); + } + + public function addCss(string $css): self + { + $this->cssExtra[] = $css; + return $this; + } + + public function addDualColumnTable(array $data): self + { + return $this->add('DualColumnTable', $data); + } + + public function addHtml(string $html): self + { + return $this->add('RawHtml', $html); + } + + public function addImage(string $src, ?int $widthMm = null, string $align = 'left'): self + { + return $this->add('Image', $src, $widthMm, $align); + } + + public function addPageBreak(): self + { + return $this->add('PageBreak'); + } + + public function addSpacer(int $mm = 5): self + { + return $this->add('Spacer', $mm); + } + + public function addTable(array $rows, array $titles = [], array $alignments = [], string $cssClass = 'table-list'): self + { + return $this->add('Table', $rows, $titles, $alignments, $cssClass); + } + + public function addText(string $text, string $cssClass = ''): self + { + return $this->add('Text', $text, $cssClass); + } + + public function addTitle(string $text, int $level = 1, string $align = 'left'): self + { + return $this->add('Title', $text, $level, $align); + } + + public function getBodyHtml(): string + { + $pages = ['']; + foreach ($this->blocks as $block) { + if ($block instanceof PageBreakBlock) { + $pages[] = ''; + continue; + } + + $pages[count($pages) - 1] .= $block->render(); + } + + $html = ''; + foreach ($pages as $page) { + $html .= '
' . $page . '
'; + } + + return $html; + } + + public function getHtml(): string + { + $route = Tools::config('route', ''); + + return '' + . '' + . '' + . '' + . '' + . '' . htmlspecialchars($this->title, ENT_QUOTES, 'UTF-8') . '' + . '' + . '' + . '' + . '' + . '
' . $this->getBodyHtml() . '
' + . '' + . '' + . ''; + } + + public function setMargin(int $mm): self + { + $this->margin = max($mm, 0); + return $this; + } + + public function setOrientation(string $orientation): self + { + $this->orientation = $orientation === self::ORIENTATION_LANDSCAPE ? + self::ORIENTATION_LANDSCAPE : + self::ORIENTATION_PORTRAIT; + return $this; + } + + public function setStyleOptions(array $options): self + { + $this->styleOptions = $options; + return $this; + } + + public function setTitle(string $title): self + { + $this->title = $title; + return $this; + } + + protected function exportScript(): string + { + $fileName = json_encode($this->sanitizeFileName($this->title) . '.pdf'); + + return 'function fsPrint() { window.print(); }' + . 'function fsPdfOptions(filename) {' + . 'return {' + . 'margin: ' . $this->margin . ',' + . 'filename: filename || ' . $fileName . ',' + . 'image: {type: "jpeg", quality: 0.98},' + . 'html2canvas: {scale: 3, letterRendering: true, useCORS: true},' + . 'jsPDF: {unit: "mm", format: "a4", orientation: ' . json_encode($this->orientation) . '},' + . 'pagebreak: {mode: ["avoid-all", "css", "legacy"]}' + . '};' + . '}' + . 'function fsDownloadPdf(filename) {' + . 'return html2pdf().from(document.getElementById("FORPRINT")).set(fsPdfOptions(filename)).save();' + . '}'; + } + + protected function newBlock(string $name, ...$args): BlockInterface + { + $dynClass = '\\FacturaScripts\\Dinamic\\Lib\\PDF\\Dynamic\\Blocks\\' . $name . 'Block'; + if (class_exists($dynClass)) { + return new $dynClass(...$args); + } + + $coreClass = '\\FacturaScripts\\Core\\Lib\\PDF\\Dynamic\\Blocks\\' . $name . 'Block'; + return new $coreClass(...$args); + } + + protected function sanitizeFileName(string $name): string + { + $clean = preg_replace('/[^a-zA-Z0-9_\-]+/', '-', $name); + return trim($clean, '-') ?: 'document'; + } +} diff --git a/Core/Lib/PDF/Dynamic/PDFPreviewTrait.php b/Core/Lib/PDF/Dynamic/PDFPreviewTrait.php new file mode 100644 index 0000000000..39588110a0 --- /dev/null +++ b/Core/Lib/PDF/Dynamic/PDFPreviewTrait.php @@ -0,0 +1,81 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic; + +use FacturaScripts\Core\Lib\AssetManager; +use FacturaScripts\Core\Tools; + +/** + * Adds a 'pdf-preview' ajax action to a controller. The controller implements + * buildPdf() and adds to its execPreviousAction(): + * + * case 'pdf-preview': + * return $this->pdfPreviewAction(); + * + * On the client side, fsShowPdfPreview() (PDFViewer.js) opens the preview modal. + */ +trait PDFPreviewTrait +{ + /** + * Builds and returns the document to preview. + */ + abstract protected function buildPdf(): PDFBuilder; + + /** + * Returns the onclick js code for a 'js' type button that opens the preview modal. + */ + protected function pdfPreviewButtonJs($code, string $filename = ''): string + { + $i18n = Tools::lang(); + $labels = [ + 'title' => $i18n->trans('pdf-preview'), + 'print' => $i18n->trans('print'), + 'download' => $i18n->trans('download-pdf'), + 'generating' => $i18n->trans('generating-pdf'), + ]; + + $js = 'fsShowPdfPreview({code: ' . json_encode((string)$code) . '}, ' + . json_encode($filename) . ', ' . json_encode($labels) . ')'; + + // the button renders this inside onclick="...", so double quotes must be html-escaped + return htmlspecialchars($js, ENT_COMPAT, 'UTF-8'); + } + + protected function loadPdfViewerAssets(): void + { + AssetManager::addJs(Tools::config('route') . '/Dinamic/Assets/JS/PDFViewer.js'); + } + + protected function pdfPreviewAction(): bool + { + $this->setTemplate(false); + + if (false === $this->permissions->allowExport) { + $this->response->setHttpCode(403); + $this->response->setContent(Tools::lang()->trans('access-denied')); + $this->response->send(); + return false; + } + + $this->response->setContent($this->buildPdf()->getHtml()); + $this->response->send(); + return false; + } +} diff --git a/Core/Lib/PDF/Dynamic/PDFStyles.php b/Core/Lib/PDF/Dynamic/PDFStyles.php new file mode 100644 index 0000000000..b8cd5622e6 --- /dev/null +++ b/Core/Lib/PDF/Dynamic/PDFStyles.php @@ -0,0 +1,100 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic; + +/** + * Base CSS for dynamic PDF documents, with the FacturaScripts document style. + */ +class PDFStyles +{ + const A4_WIDTH_MM = 210; + const A4_HEIGHT_MM = 297; + + public static function get(string $orientation, int $marginMm, array $options = []): string + { + $options = array_merge([ + 'color1' => '#2770CA', + 'color2' => '#FFFFFF', + 'color3' => '#F1F1F1', + 'fontcolor' => '#000000', + 'fontsize' => '12px', + 'titlefontsize' => '18px', + ], $options); + + // the html2pdf margin is applied outside the page, so the visible sheet is the inner area + $width = $orientation === PDFBuilder::ORIENTATION_LANDSCAPE ? self::A4_HEIGHT_MM : self::A4_WIDTH_MM; + $height = $orientation === PDFBuilder::ORIENTATION_LANDSCAPE ? self::A4_WIDTH_MM : self::A4_HEIGHT_MM; + $innerWidth = $width - $marginMm * 2; + $innerHeight = $height - $marginMm * 2; + + return ':root {' + . '--fs-color1: ' . $options['color1'] . ';' + . '--fs-color2: ' . $options['color2'] . ';' + . '--fs-color3: ' . $options['color3'] . ';' + . '--fs-font-size: ' . $options['fontsize'] . ';' + . '--fs-title-size: ' . $options['titlefontsize'] . ';' + . '}' + . '* { box-sizing: border-box; margin: 0; padding: 0; }' + . 'body {' + . 'background: #525659;' + . "font-family: 'DejaVu Sans', Helvetica, Arial, sans-serif;" + . 'font-size: var(--fs-font-size);' + . 'color: ' . $options['fontcolor'] . ';' + . 'padding: 5mm 0;' + . '}' + . '.page {' + . 'width: ' . $innerWidth . 'mm;' + . 'min-height: ' . $innerHeight . 'mm;' + . 'background: #fff;' + . 'margin: 0 auto 5mm auto;' + . 'box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);' + . 'page-break-after: always;' + . '}' + . '.title { font-size: var(--fs-title-size); color: var(--fs-color1); font-weight: bold; }' + . 'h2.title { font-size: calc(var(--fs-title-size) - 2px); }' + . 'h3.title { font-size: calc(var(--fs-title-size) - 4px); }' + . '.text { margin-bottom: 2mm; }' + . '.primary-box { background: var(--fs-color1); color: var(--fs-color2); padding: 4px 8px;' + . ' text-transform: uppercase; font-weight: bold; }' + . '.seccondary-box { background: var(--fs-color3); padding: 4px 8px; text-transform: uppercase;' + . ' font-weight: bold; }' + . '.table-list { width: 100%; border-collapse: collapse; margin-bottom: 2mm; }' + . '.table-list thead th { background: var(--fs-color1); color: var(--fs-color2); padding: 4px 6px; }' + . '.table-list td { padding: 4px 6px; }' + . '.table-list tbody tr:nth-child(even) { background: var(--fs-color3); }' + . '.table-dual { width: 100%; border-collapse: collapse; margin-bottom: 2mm; }' + . '.table-dual td { padding: 3px 6px; }' + . '.table-dual td:first-child { background: var(--fs-color3); font-weight: bold; width: 40%; }' + . '.columns { display: flex; gap: 5mm; margin-bottom: 2mm; }' + . '.columns > div { flex: 1; min-width: 0; }' + . '.company-header { display: flex; gap: 5mm; align-items: flex-start; margin-bottom: 2mm; }' + . '.company-header .company-data { flex: 1; }' + . '.company-header img { max-height: 30mm; max-width: 60mm; }' + . '.text-left { text-align: left; }' + . '.text-center { text-align: center; }' + . '.text-right { text-align: right; }' + . '.font-bold { font-weight: bold; }' + . '.nowrap { white-space: nowrap; }' + . '@media print {' + . 'body { background: #fff; padding: 0; }' + . '.page { margin: 0 auto; box-shadow: none; }' + . '}'; + } +} From 93cadb999e78a747467aef5cebf833aa2890ec6c Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Fri, 3 Jul 2026 12:23:47 +0200 Subject: [PATCH 02/10] =?UTF-8?q?feat(pdf):=20a=C3=B1ade=20visor=20de=20pr?= =?UTF-8?q?evisualizaci=C3=B3n=20PDF=20con=20html2pdf.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Assets/JS/PDFViewer.js | 106 ++++++++++++++++++++++++++++++++++++ Core/Translation/en_EN.json | 3 + Core/Translation/es_ES.json | 3 + package.json | 1 + 4 files changed, 113 insertions(+) create mode 100644 Core/Assets/JS/PDFViewer.js diff --git a/Core/Assets/JS/PDFViewer.js b/Core/Assets/JS/PDFViewer.js new file mode 100644 index 0000000000..d08efc4c80 --- /dev/null +++ b/Core/Assets/JS/PDFViewer.js @@ -0,0 +1,106 @@ +/* + * This file is part of FacturaScripts + * Copyright (C) 2026 Carlos Garcia Gomez + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +function fsPdfPreviewModal(labels) { + const existing = document.getElementById('fsPdfPreviewModal'); + if (existing) { + return existing; + } + + const modalHTML = ` + + `; + document.body.insertAdjacentHTML('beforeend', modalHTML); + + const modalEl = document.getElementById('fsPdfPreviewModal'); + const frame = document.getElementById('fsPdfPreviewFrame'); + + document.getElementById('fsPdfPreviewPrintBtn').addEventListener('click', function () { + if (frame.contentWindow && typeof frame.contentWindow.fsPrint === 'function') { + frame.contentWindow.fsPrint(); + } + }); + + document.getElementById('fsPdfPreviewDownloadBtn').addEventListener('click', function () { + if (!frame.contentWindow || typeof frame.contentWindow.fsDownloadPdf !== 'function') { + return; + } + + const btn = this; + const original = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' ' + labels.generating; + frame.contentWindow.fsDownloadPdf(modalEl.dataset.filename || null).then(function () { + btn.disabled = false; + btn.innerHTML = original; + }).catch(function () { + btn.disabled = false; + btn.innerHTML = original; + }); + }); + + return modalEl; +} + +function fsShowPdfPreview(extraData = {}, filename = '', labels = {}) { + labels = Object.assign({ + title: 'PDF', + print: 'Print', + download: 'PDF', + generating: '...' + }, labels); + + const modalEl = fsPdfPreviewModal(labels); + modalEl.dataset.filename = filename; + + animateSpinner('add'); + $.ajax({ + method: 'POST', + url: window.location.href, + data: Object.assign({action: 'pdf-preview'}, extraData), + dataType: 'html', + success: function (html) { + animateSpinner('remove'); + document.getElementById('fsPdfPreviewFrame').srcdoc = html; + bootstrap.Modal.getOrCreateInstance(modalEl).show(); + }, + error: function () { + animateSpinner('remove', false); + } + }); +} diff --git a/Core/Translation/en_EN.json b/Core/Translation/en_EN.json index fd2874810b..9048953d93 100644 --- a/Core/Translation/en_EN.json +++ b/Core/Translation/en_EN.json @@ -578,6 +578,7 @@ "download-completed": "Download completed", "download-csv": "Download CSV", "download-error": "Download error. Code %status% - %error%", + "download-pdf": "Download PDF", "download-xml": "Download XML", "downloads": "Downloads", "drag": "Drag", @@ -819,6 +820,7 @@ "generate-settlements": "Generate settlements", "generate-test-data": "Generate test data", "generate-title": "Run report generation", + "generating-pdf": "Generating PDF", "generated-accounting-entries": "%quantity% accounting entries generated.", "generated-accounts": "%quantity% accounts generated.", "generated-agents": "%quantity% generated agents.", @@ -1362,6 +1364,7 @@ "pct-surcharge": "Surcharge rate", "pct-tax": "tax rate", "pct-vat": "VAT rate", + "pdf-preview": "PDF preview", "pending": "Pending", "pending-invoices-button": "Risk:", "pending-orders-button": "Pending orders:", diff --git a/Core/Translation/es_ES.json b/Core/Translation/es_ES.json index 5508d1a382..704422d816 100644 --- a/Core/Translation/es_ES.json +++ b/Core/Translation/es_ES.json @@ -578,6 +578,7 @@ "download-completed": "Descarga completada", "download-csv": "Descargar CSV", "download-error": "Error al descargar. Código %status% - %error% %body%", + "download-pdf": "Descargar PDF", "download-xml": "Descargar XML", "downloads": "Descargas", "drag": "Arrastrar", @@ -819,6 +820,7 @@ "generate-settlements": "Generar liquidaciones", "generate-test-data": "Generar datos de prueba", "generate-title": "Genera el informe", + "generating-pdf": "Generando PDF", "generated-accounting-entries": "%quantity% asientos contables generados.", "generated-accounts": "%quantity% cuentas contables generadas.", "generated-agents": "%quantity% agentes generados.", @@ -1362,6 +1364,7 @@ "pct-surcharge": "% Recargo", "pct-tax": "% impuestos", "pct-vat": "% Impuesto", + "pdf-preview": "Vista previa PDF", "pending": "Pendiente", "pending-invoices-button": "Fact. Ptes:", "pending-orders-button": "Ped. Ptes:", diff --git a/package.json b/package.json index a9b18f2cf4..0a138c1653 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@fortawesome/fontawesome-free": "^6.6.0", "bootstrap": "5.*", "chart.js": "2.*", + "html2pdf.js": "^0.14.0", "jquery": "^3.6.4", "jquery-ui-dist": "1.12.*", "pace-js": "1.*", From c8f40774a9793b2bd45b5ac09431b01f84524418 Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Fri, 3 Jul 2026 12:23:47 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(empresa):=20a=C3=B1ade=20vista=20pre?= =?UTF-8?q?via=20PDF=20en=20EditEmpresa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Controller/EditEmpresa.php | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/Core/Controller/EditEmpresa.php b/Core/Controller/EditEmpresa.php index 7fab544beb..ba76701c4a 100644 --- a/Core/Controller/EditEmpresa.php +++ b/Core/Controller/EditEmpresa.php @@ -23,7 +23,10 @@ use FacturaScripts\Core\Lib\ExtendedController\EditController; use FacturaScripts\Core\Tools; use FacturaScripts\Core\Where; +use FacturaScripts\Core\Lib\PDF\Dynamic\PDFBuilder; +use FacturaScripts\Core\Lib\PDF\Dynamic\PDFPreviewTrait; use FacturaScripts\Dinamic\Lib\RegimenIVA; +use FacturaScripts\Dinamic\Model\Almacen; /** * Controller to edit a single item from the Empresa model @@ -34,6 +37,8 @@ */ class EditEmpresa extends EditController { + use PDFPreviewTrait; + public function getModelClassName(): string { return 'Empresa'; @@ -48,6 +53,43 @@ public function getPageData(): array return $data; } + protected function buildPdf(): PDFBuilder + { + $empresa = $this->getModel(); + $empresa->loadFromCode($this->request->input('code')); + + $doc = PDFBuilder::create() + ->setTitle($empresa->nombrecorto ?? $empresa->nombre ?? 'company') + ->addCompanyHeader($empresa, 'right') + ->addSpacer(5) + ->addTitle(Tools::lang()->trans('company'), 2) + ->addDualColumnTable([ + Tools::lang()->trans('name') => $empresa->nombre, + Tools::lang()->trans('fiscal-number') => $empresa->cifnif, + Tools::lang()->trans('address') => $empresa->direccion, + Tools::lang()->trans('city') => $empresa->ciudad, + Tools::lang()->trans('admin') => $empresa->administrador, + ]); + + $rows = []; + foreach (Almacen::all([Where::eq('idempresa', $empresa->idempresa)]) as $almacen) { + $rows[] = [$almacen->codalmacen, $almacen->nombre, $almacen->direccion, $almacen->ciudad]; + } + + if (false === empty($rows)) { + $doc->addSpacer(5) + ->addTitle(Tools::lang()->trans('warehouses'), 2) + ->addTable($rows, [ + Tools::lang()->trans('code'), + Tools::lang()->trans('name'), + Tools::lang()->trans('address'), + Tools::lang()->trans('city'), + ]); + } + + return $doc; + } + protected function checkViesAction(): bool { $model = $this->getModel(); @@ -66,6 +108,7 @@ protected function checkViesAction(): bool protected function createViews() { parent::createViews(); + $this->loadPdfViewerAssets(); $this->createViewWarehouse(); $this->createViewBankAccounts(); @@ -103,6 +146,9 @@ protected function execPreviousAction($action): bool case 'check-vies': return $this->checkViesAction(); + case 'pdf-preview': + return $this->pdfPreviewAction(); + default: return parent::execPreviousAction($action); } @@ -139,6 +185,15 @@ protected function loadData($viewName, $view) 'label' => 'check-vies' ]); } + if ($view->model->exists()) { + $this->addButton($viewName, [ + 'action' => $this->pdfPreviewButtonJs($view->model->primaryColumnValue()), + 'color' => 'secondary', + 'icon' => 'fa-solid fa-file-pdf', + 'label' => 'pdf-preview', + 'type' => 'js' + ]); + } break; default: From 6f63e973cf6c89d46f4aa73312a7ad7bd94deed1 Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Fri, 3 Jul 2026 12:23:47 +0200 Subject: [PATCH 04/10] =?UTF-8?q?test(pdf):=20a=C3=B1ade=20tests=20del=20P?= =?UTF-8?q?DFBuilder=20y=20sus=20bloques?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Test/Core/Lib/PDF/Dynamic/PDFBuilderTest.php | 139 +++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 Test/Core/Lib/PDF/Dynamic/PDFBuilderTest.php diff --git a/Test/Core/Lib/PDF/Dynamic/PDFBuilderTest.php b/Test/Core/Lib/PDF/Dynamic/PDFBuilderTest.php new file mode 100644 index 0000000000..66500bdb47 --- /dev/null +++ b/Test/Core/Lib/PDF/Dynamic/PDFBuilderTest.php @@ -0,0 +1,139 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Test\Core\Lib\PDF\Dynamic; + +use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\DualColumnTableBlock; +use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\RawHtmlBlock; +use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\SpacerBlock; +use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\TableBlock; +use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\TextBlock; +use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\TitleBlock; +use FacturaScripts\Core\Lib\PDF\Dynamic\PDFBuilder; +use PHPUnit\Framework\TestCase; + +final class PDFBuilderTest extends TestCase +{ + public function testTitleBlockEscapesAndSetsLevel(): void + { + $html = (new TitleBlock('Hola mundo', 2, 'center'))->render(); + $this->assertSame('

Hola <b>mundo</b>

', $html); + + // el nivel se limita a 1..3 + $this->assertStringStartsWith('render()); + $this->assertStringStartsWith('render()); + } + + public function testTextBlockEscapesAndKeepsNewLines(): void + { + $html = (new TextBlock("line1\nline2", 'font-bold'))->render(); + $this->assertStringContainsString('class="text font-bold"', $html); + $this->assertStringContainsString("line1
\nline2", $html); + } + + public function testSpacerBlock(): void + { + $this->assertSame('
', (new SpacerBlock(8))->render()); + $this->assertSame('
', (new SpacerBlock(-3))->render()); + } + + public function testTableBlockRendersTitlesRowsAndAlignments(): void + { + $html = (new TableBlock( + [['a', 'b'], ['c', 'd']], + ['col1', 'col2'], + ['left', 'right'] + ))->render(); + + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertSame(2, substr_count($html, '') - 1); // 2 filas de datos + 1 de títulos + } + + public function testTableBlockWithoutTitlesHasNoHeader(): void + { + $html = (new TableBlock([['a']]))->render(); + $this->assertStringNotContainsString('', $html); + } + + public function testDualColumnTableBlock(): void + { + $html = (new DualColumnTableBlock(['Fecha' => '03-07-2026']))->render(); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + } + + public function testRawHtmlBlockSanitizes(): void + { + $html = (new RawHtmlBlock( + '

ok

x' + ))->render(); + + $this->assertStringNotContainsString('assertStringNotContainsString('alert(1)', $html); + $this->assertStringNotContainsString('onclick', $html); + $this->assertStringNotContainsString('javascript:', $html); + $this->assertStringContainsString('

ok

', $html); + } + + public function testBuilderBodySplitsPages(): void + { + $body = PDFBuilder::create() + ->addTitle('page one') + ->addPageBreak() + ->addText('page two') + ->getBodyHtml(); + + $this->assertSame(2, substr_count($body, '
')); + $this->assertStringContainsString('page one', $body); + $this->assertStringContainsString('page two', $body); + } + + public function testBuilderHtmlDocument(): void + { + $html = PDFBuilder::create() + ->setTitle('my "doc"') + ->setOrientation(PDFBuilder::ORIENTATION_LANDSCAPE) + ->addText('hello') + ->getHtml(); + + $this->assertStringStartsWith('', $html); + $this->assertStringContainsString('my "doc"', $html); + $this->assertStringContainsString('html2pdf.bundle.min.js', $html); + $this->assertStringContainsString('id="FORPRINT"', $html); + $this->assertStringContainsString('orientation:"landscape"', str_replace(' ', '', $html)); + $this->assertStringContainsString('function fsPrint()', $html); + $this->assertStringContainsString('function fsDownloadPdf(', $html); + // en horizontal, la página mide 257mm de ancho (297 - 2 x 20 de margen) + $this->assertStringContainsString('width: 257mm', $html); + } + + public function testCustomBlockRegistry(): void + { + PDFBuilder::addCustomBlock('MyBlock', TextBlock::class); + $body = PDFBuilder::create() + ->add('MyBlock', 'custom content') + ->getBodyHtml(); + + $this->assertStringContainsString('custom content', $body); + } +} From a77646a374419bb88b92c9adc42c5d66fcc345b4 Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Fri, 3 Jul 2026 13:56:50 +0200 Subject: [PATCH 05/10] =?UTF-8?q?fix(pdf):=20hace=20la=20previsualizaci?= =?UTF-8?q?=C3=B3n=20WYSIWYG=20con=20estilos=20=C3=BAnicamente=20del=20cor?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Lib/PDF/Dynamic/PDFStyles.php | 71 +++++++++++++++++++----------- 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/Core/Lib/PDF/Dynamic/PDFStyles.php b/Core/Lib/PDF/Dynamic/PDFStyles.php index b8cd5622e6..28700d2b08 100644 --- a/Core/Lib/PDF/Dynamic/PDFStyles.php +++ b/Core/Lib/PDF/Dynamic/PDFStyles.php @@ -29,25 +29,24 @@ class PDFStyles public static function get(string $orientation, int $marginMm, array $options = []): string { + // defaults matching the core PDF export look: black titles and light + // grey table shading (the 0.95 grey of ezPDF tables) $options = array_merge([ - 'color1' => '#2770CA', - 'color2' => '#FFFFFF', - 'color3' => '#F1F1F1', 'fontcolor' => '#000000', 'fontsize' => '12px', + 'shadecolor' => '#F2F2F2', + 'titlecolor' => '#000000', 'titlefontsize' => '18px', ], $options); - // the html2pdf margin is applied outside the page, so the visible sheet is the inner area + // the .page is a full size sheet with the margin as visible padding (wysiwyg preview); + // html2pdf exports with margin 0, so what you see is exactly what you get $width = $orientation === PDFBuilder::ORIENTATION_LANDSCAPE ? self::A4_HEIGHT_MM : self::A4_WIDTH_MM; $height = $orientation === PDFBuilder::ORIENTATION_LANDSCAPE ? self::A4_WIDTH_MM : self::A4_HEIGHT_MM; - $innerWidth = $width - $marginMm * 2; - $innerHeight = $height - $marginMm * 2; return ':root {' - . '--fs-color1: ' . $options['color1'] . ';' - . '--fs-color2: ' . $options['color2'] . ';' - . '--fs-color3: ' . $options['color3'] . ';' + . '--fs-shade: ' . $options['shadecolor'] . ';' + . '--fs-title-color: ' . $options['titlecolor'] . ';' . '--fs-font-size: ' . $options['fontsize'] . ';' . '--fs-title-size: ' . $options['titlefontsize'] . ';' . '}' @@ -60,38 +59,60 @@ public static function get(string $orientation, int $marginMm, array $options = . 'padding: 5mm 0;' . '}' . '.page {' - . 'width: ' . $innerWidth . 'mm;' - . 'min-height: ' . $innerHeight . 'mm;' + . 'position: relative;' + . 'width: ' . $width . 'mm;' + . 'min-height: ' . $height . 'mm;' . 'background: #fff;' . 'margin: 0 auto 5mm auto;' + . 'padding: ' . $marginMm . 'mm;' . 'box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);' . 'page-break-after: always;' . '}' - . '.title { font-size: var(--fs-title-size); color: var(--fs-color1); font-weight: bold; }' - . 'h2.title { font-size: calc(var(--fs-title-size) - 2px); }' - . 'h3.title { font-size: calc(var(--fs-title-size) - 4px); }' - . '.text { margin-bottom: 2mm; }' - . '.primary-box { background: var(--fs-color1); color: var(--fs-color2); padding: 4px 8px;' - . ' text-transform: uppercase; font-weight: bold; }' - . '.seccondary-box { background: var(--fs-color3); padding: 4px 8px; text-transform: uppercase;' - . ' font-weight: bold; }' - . '.table-list { width: 100%; border-collapse: collapse; margin-bottom: 2mm; }' - . '.table-list thead th { background: var(--fs-color1); color: var(--fs-color2); padding: 4px 6px; }' - . '.table-list td { padding: 4px 6px; }' - . '.table-list tbody tr:nth-child(even) { background: var(--fs-color3); }' + . '.title { font-size: var(--fs-title-size); color: var(--fs-title-color); font-weight: bold;' + . ' margin-bottom: 3mm; }' + . 'h2.title { font-size: calc(var(--fs-title-size) - 2px); margin-bottom: 2mm; }' + . 'h3.title { font-size: calc(var(--fs-title-size) - 4px); margin-bottom: 2mm; }' + . '.text { margin-bottom: 2mm; line-height: 1.4; }' + . '.shade-box { background: var(--fs-shade); padding: 4px 8px; font-weight: bold; }' + . '.table-list { width: 100%; border-collapse: collapse; margin-bottom: 3mm; }' + . '.table-list thead th { background: var(--fs-shade); padding: 5px 7px; }' + . '.table-list td { padding: 5px 7px; border-bottom: 1px solid var(--fs-shade); }' + . '.table-list tbody tr:nth-child(even) { background: var(--fs-shade); }' . '.table-dual { width: 100%; border-collapse: collapse; margin-bottom: 2mm; }' . '.table-dual td { padding: 3px 6px; }' - . '.table-dual td:first-child { background: var(--fs-color3); font-weight: bold; width: 40%; }' + . '.table-dual td:first-child { background: var(--fs-shade); font-weight: bold; width: 40%; }' . '.columns { display: flex; gap: 5mm; margin-bottom: 2mm; }' . '.columns > div { flex: 1; min-width: 0; }' - . '.company-header { display: flex; gap: 5mm; align-items: flex-start; margin-bottom: 2mm; }' + . '.company-header { display: flex; gap: 5mm; align-items: flex-start; margin-bottom: 5mm; }' . '.company-header .company-data { flex: 1; }' . '.company-header img { max-height: 30mm; max-width: 60mm; }' + . '.document-header { display: flex; justify-content: space-between; align-items: flex-start;' + . ' gap: 5mm; margin-bottom: 10mm; }' + . '.document-header .header-logo img { max-height: 22mm; max-width: 70mm; }' + . '.document-header .company-name { font-size: calc(var(--fs-title-size) + 4px); }' + . '.table-parallel { width: 100%; border-collapse: collapse; margin-bottom: 3mm; }' + . '.table-parallel td { padding: 3px 6px; vertical-align: top; width: 50%; }' + . 'hr { border: 0; border-top: 1px solid #333; margin: 2mm 0 4mm 0; }' + . '.page-footer { position: absolute; left: ' . $marginMm . 'mm; right: ' . $marginMm . 'mm;' + . ' bottom: 10mm; display: flex; justify-content: space-between;' + . ' font-size: calc(var(--fs-font-size) - 2px); }' + . '.watermark-text { position: absolute; top: 50%; left: 50%; width: 90%;' + . ' transform: translate(-50%, -50%) rotate(-35deg); text-align: center;' + . ' font-size: calc(var(--fs-title-size) + 4px); font-weight: bold; opacity: 0.5;' + . ' pointer-events: none; }' . '.text-left { text-align: left; }' . '.text-center { text-align: center; }' . '.text-right { text-align: right; }' + . '.text-justify { text-align: justify; }' . '.font-bold { font-weight: bold; }' + . '.font-big { font-size: calc(var(--fs-font-size) + 2px); }' + . '.font-small { font-size: calc(var(--fs-font-size) - 2px); }' . '.nowrap { white-space: nowrap; }' + . '.mx-auto { margin-left: auto; margin-right: auto; }' + . '.mb-0 { margin-bottom: 0; } .mb-2 { margin-bottom: 2mm; } .mb-5 { margin-bottom: 5mm; }' + . '.mt-0 { margin-top: 0; } .mt-2 { margin-top: 2mm; } .mt-5 { margin-top: 5mm; }' + . '.w-25 { width: 25%; } .w-50 { width: 50%; } .w-75 { width: 75%; } .w-100 { width: 100%; }' + . '@page { size: A4 ' . $orientation . '; margin: 0; }' . '@media print {' . 'body { background: #fff; padding: 0; }' . '.page { margin: 0 auto; box-shadow: none; }' From ba0d788ddeab8d4f72f190426be5430f5a213a69 Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Fri, 3 Jul 2026 13:56:50 +0200 Subject: [PATCH 06/10] =?UTF-8?q?feat(pdf):=20a=C3=B1ade=20componentes=20d?= =?UTF-8?q?el=20export=20cl=C3=A1sico=20y=20tablas=20desde=20columnas=20XM?= =?UTF-8?q?LView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dynamic/Blocks/DocumentHeaderBlock.php | 119 +++++++++++++ .../PDF/Dynamic/Blocks/PageFooterBlock.php | 47 +++++ .../PDF/Dynamic/Blocks/ParallelTableBlock.php | 52 ++++++ .../PDF/Dynamic/Blocks/WatermarkTextBlock.php | 46 +++++ Core/Lib/PDF/Dynamic/ModelTableHelper.php | 164 ++++++++++++++++++ Core/Lib/PDF/Dynamic/PDFBuilder.php | 59 ++++++- Test/Core/Lib/PDF/Dynamic/PDFBuilderTest.php | 59 ++++++- 7 files changed, 541 insertions(+), 5 deletions(-) create mode 100644 Core/Lib/PDF/Dynamic/Blocks/DocumentHeaderBlock.php create mode 100644 Core/Lib/PDF/Dynamic/Blocks/PageFooterBlock.php create mode 100644 Core/Lib/PDF/Dynamic/Blocks/ParallelTableBlock.php create mode 100644 Core/Lib/PDF/Dynamic/Blocks/WatermarkTextBlock.php create mode 100644 Core/Lib/PDF/Dynamic/ModelTableHelper.php diff --git a/Core/Lib/PDF/Dynamic/Blocks/DocumentHeaderBlock.php b/Core/Lib/PDF/Dynamic/Blocks/DocumentHeaderBlock.php new file mode 100644 index 0000000000..f4bb829614 --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/DocumentHeaderBlock.php @@ -0,0 +1,119 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +use FacturaScripts\Core\Model\Empresa; +use FacturaScripts\Dinamic\Model\AttachedFile; + +/** + * General document header, identical to the one in the core PDF export + * (PDFDocument::insertHeader): logo on one side and the company block on the + * other (name in big font, fiscal number + address, contact data). + */ +class DocumentHeaderBlock extends AbstractBlock +{ + /** @var Empresa */ + protected $empresa; + + /** @var bool */ + protected $logoLeft; + + /** @var ?string */ + protected $logoSrc; + + public function __construct(Empresa $empresa, ?string $logoSrc = null, bool $logoLeft = true) + { + $this->empresa = $empresa; + $this->logoSrc = $logoSrc; + $this->logoLeft = $logoLeft; + } + + public function render(): string + { + $logo = ''; + + $info = '
' + . '
' . $this->escape($this->empresa->nombre) . '
' + . '
' . $this->escape($this->fiscalLine()) . '
' + . '
' . $this->escape($this->contactLine()) . '
' + . '
'; + + $parts = $this->logoLeft ? $logo . $info : $info . $logo; + return '
' . $parts . '
'; + } + + protected function contactLine(): string + { + $parts = []; + foreach (['telefono1', 'telefono2', 'email', 'web'] as $field) { + if (!empty($this->empresa->{$field})) { + $parts[] = $this->empresa->{$field}; + } + } + + return implode(' · ', $parts); + } + + protected function fiscalLine(): string + { + $address = $this->empresa->direccion ?? ''; + if (!empty($this->empresa->codpostal)) { + $address .= empty($address) ? $this->empresa->codpostal : ', ' . $this->empresa->codpostal; + } + if (!empty($this->empresa->ciudad)) { + $address .= empty($address) ? $this->empresa->ciudad : ', ' . $this->empresa->ciudad; + } + if (!empty($this->empresa->provincia)) { + $address .= ' (' . $this->empresa->provincia . ')'; + } + + return empty($address) ? + (string)$this->empresa->cifnif : + $this->empresa->cifnif . ' - ' . $address; + } + + protected function imageToBase64(string $path): string + { + if (false === file_exists($path) || false === is_file($path)) { + return ''; + } + + return 'data:' . mime_content_type($path) . ';base64,' . base64_encode(file_get_contents($path)); + } + + protected function resolveLogo(): string + { + if (!empty($this->logoSrc)) { + return str_starts_with($this->logoSrc, 'data:') ? + $this->logoSrc : + $this->imageToBase64($this->logoSrc); + } + + if (!empty($this->empresa->idlogo)) { + $logoFile = new AttachedFile(); + if ($logoFile->load($this->empresa->idlogo) && $logoFile->isImage()) { + return $this->imageToBase64($logoFile->getFullPath()); + } + } + + // same fallback as the core PDF export + return $this->imageToBase64(FS_FOLDER . '/Dinamic/Assets/Images/horizontal-logo.png'); + } +} diff --git a/Core/Lib/PDF/Dynamic/Blocks/PageFooterBlock.php b/Core/Lib/PDF/Dynamic/Blocks/PageFooterBlock.php new file mode 100644 index 0000000000..ae28f30dba --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/PageFooterBlock.php @@ -0,0 +1,47 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +/** + * Footer fixed at the bottom of the current page, with a left and a right + * text, like the one in the core PDF export ("1 / 1" and "Generated at..."). + */ +class PageFooterBlock extends AbstractBlock +{ + /** @var string */ + protected $left; + + /** @var string */ + protected $right; + + public function __construct(string $left = '', string $right = '') + { + $this->left = $left; + $this->right = $right; + } + + public function render(): string + { + return '
' + . '
' . $this->escape($this->left) . '
' + . '
' . $this->escape($this->right) . '
' + . '
'; + } +} diff --git a/Core/Lib/PDF/Dynamic/Blocks/ParallelTableBlock.php b/Core/Lib/PDF/Dynamic/Blocks/ParallelTableBlock.php new file mode 100644 index 0000000000..15bb5c8b73 --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/ParallelTableBlock.php @@ -0,0 +1,52 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +/** + * Key/value pairs distributed in two columns, like the parallel table of the + * core PDF export (PDFCore::insertParallelTable): "key: value" with bold keys, + * no borders and no shading. + */ +class ParallelTableBlock extends AbstractBlock +{ + /** @var array */ + protected $data; + + public function __construct(array $data, string $cssClass = 'table-parallel') + { + $this->data = $data; + $this->cssClass = $cssClass; + } + + public function render(): string + { + $cells = []; + foreach ($this->data as $key => $value) { + $cells[] = '
'; + } + + $html = '
col1col2ad
Fecha03-07-2026' . $this->escape((string)$key) . ': ' . $this->escape((string)$value) . '
'; + foreach (array_chunk($cells, 2) as $pair) { + $html .= '' . $pair[0] . ($pair[1] ?? '') . ''; + } + + return $html . '
'; + } +} diff --git a/Core/Lib/PDF/Dynamic/Blocks/WatermarkTextBlock.php b/Core/Lib/PDF/Dynamic/Blocks/WatermarkTextBlock.php new file mode 100644 index 0000000000..76c6d6b707 --- /dev/null +++ b/Core/Lib/PDF/Dynamic/Blocks/WatermarkTextBlock.php @@ -0,0 +1,46 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic\Blocks; + +/** + * Diagonal text overlay across the current page, like the draft invoice + * warning of the core PDF export (red rotated text over the content). + */ +class WatermarkTextBlock extends AbstractBlock +{ + /** @var string */ + protected $text; + + /** @var string */ + protected $color; + + public function __construct(string $text, string $color = '#C80000') + { + $this->text = $text; + $this->color = $color; + } + + public function render(): string + { + return '
' + . $this->escape($this->text) + . '
'; + } +} diff --git a/Core/Lib/PDF/Dynamic/ModelTableHelper.php b/Core/Lib/PDF/Dynamic/ModelTableHelper.php new file mode 100644 index 0000000000..d071fd280d --- /dev/null +++ b/Core/Lib/PDF/Dynamic/ModelTableHelper.php @@ -0,0 +1,164 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\PDF\Dynamic; + +use FacturaScripts\Core\Tools; + +/** + * Extracts titles, alignments and row values from the columns of an XMLView + * (GroupItem/ColumnItem tree), like the core exporters do (ExportBase), so a + * model list can be dumped into a generic table block. + */ +class ModelTableHelper +{ + /** + * @param array $columns GroupItem[] or ColumnItem[] + */ + public static function alignments(array $columns): array + { + $alignments = []; + foreach ($columns as $col) { + if (is_string($col)) { + $alignments[$col] = 'left'; + continue; + } + + if (isset($col->columns)) { + foreach (static::alignments($col->columns) as $key => $value) { + $alignments[$key] = $value; + } + continue; + } + + if (!$col->hidden()) { + $alignments[$col->widget->fieldname] = static::mapAlignment($col->display); + } + } + + return $alignments; + } + + /** + * @param array $cursor ModelClass[] + * @param array $columns GroupItem[] or ColumnItem[] + */ + public static function rows(array $cursor, array $columns): array + { + $rows = []; + $widgets = static::widgets($columns); + foreach ($cursor as $num => $model) { + foreach ($widgets as $key => $widget) { + $rows[$num][$key] = $widget->plainText($model); + } + } + + return $rows; + } + + /** + * @param array $columns GroupItem[] or ColumnItem[] + */ + public static function titles(array $columns): array + { + $titles = []; + foreach ($columns as $col) { + if (is_string($col)) { + $titles[$col] = $col; + continue; + } + + if (isset($col->columns)) { + foreach (static::titles($col->columns) as $key => $value) { + $titles[$key] = $value; + } + continue; + } + + if (!$col->hidden()) { + $titles[$col->widget->fieldname] = Tools::trans($col->title); + } + } + + return $titles; + } + + /** + * @param array $columns GroupItem[] or ColumnItem[] + */ + public static function widgets(array $columns): array + { + $widgets = []; + foreach ($columns as $col) { + if (is_string($col)) { + continue; + } + + if (isset($col->columns)) { + foreach (static::widgets($col->columns) as $key => $value) { + $widgets[$key] = $value; + } + continue; + } + + if (!$col->hidden()) { + $widgets[$col->widget->fieldname] = $col->widget; + } + } + + return $widgets; + } + + /** + * Removes the columns where every row has an empty value, like the core + * exporters do (PDFCore::removeEmptyCols). Rows, titles and alignments + * must share the same keys. + */ + public static function removeEmptyColumns(array &$rows, array &$titles, array &$alignments, array $emptyValues = []): void + { + $emptyValues = array_merge(['', '-', null], $emptyValues); + foreach (array_keys($titles) as $key) { + foreach ($rows as $row) { + if (isset($row[$key]) && false === in_array($row[$key], $emptyValues, true)) { + continue 2; + } + } + + unset($titles[$key], $alignments[$key]); + foreach ($rows as $num => $row) { + unset($rows[$num][$key]); + } + } + } + + protected static function mapAlignment(string $display): string + { + switch ($display) { + case 'center': + return 'center'; + + case 'end': + case 'right': + return 'right'; + + default: + return 'left'; + } + } +} diff --git a/Core/Lib/PDF/Dynamic/PDFBuilder.php b/Core/Lib/PDF/Dynamic/PDFBuilder.php index 4ab6214ade..a702cf0ec6 100644 --- a/Core/Lib/PDF/Dynamic/PDFBuilder.php +++ b/Core/Lib/PDF/Dynamic/PDFBuilder.php @@ -107,6 +107,11 @@ public function addCss(string $css): self return $this; } + public function addDocumentHeader(Empresa $empresa, ?string $logoSrc = null, bool $logoLeft = true): self + { + return $this->add('DocumentHeader', $empresa, $logoSrc, $logoLeft); + } + public function addDualColumnTable(array $data): self { return $this->add('DualColumnTable', $data); @@ -122,11 +127,44 @@ public function addImage(string $src, ?int $widthMm = null, string $align = 'lef return $this->add('Image', $src, $widthMm, $align); } + /** + * Adds a table with the model list data, using the XMLView columns to + * resolve titles, alignments and cell values (like the core exporters). + * + * @param array $cursor ModelClass[] + * @param array $columns GroupItem[] from BaseView::getColumns() + */ + public function addModelTable(array $cursor, array $columns, string $cssClass = 'table-list'): self + { + $titles = ModelTableHelper::titles($columns); + $rows = []; + foreach (ModelTableHelper::rows($cursor, $columns) as $row) { + $rows[] = array_values($row); + } + + return $this->addTable( + $rows, + array_values($titles), + array_values(ModelTableHelper::alignments($columns)), + $cssClass + ); + } + public function addPageBreak(): self { return $this->add('PageBreak'); } + public function addPageFooter(string $left = '', string $right = ''): self + { + return $this->add('PageFooter', $left, $right); + } + + public function addParallelTable(array $data): self + { + return $this->add('ParallelTable', $data); + } + public function addSpacer(int $mm = 5): self { return $this->add('Spacer', $mm); @@ -147,6 +185,11 @@ public function addTitle(string $text, int $level = 1, string $align = 'left'): return $this->add('Title', $text, $level, $align); } + public function addWatermarkText(string $text, string $color = '#C80000'): self + { + return $this->add('WatermarkText', $text, $color); + } + public function getBodyHtml(): string { $pages = ['']; @@ -218,15 +261,25 @@ protected function exportScript(): string { $fileName = json_encode($this->sanitizeFileName($this->title) . '.pdf'); + // the page margin is already the .page padding (wysiwyg), so html2pdf margin is 0. + // onclone removes the preview-only decoration (shadows, gaps) before the capture. return 'function fsPrint() { window.print(); }' . 'function fsPdfOptions(filename) {' . 'return {' - . 'margin: ' . $this->margin . ',' + . 'margin: 0,' . 'filename: filename || ' . $fileName . ',' . 'image: {type: "jpeg", quality: 0.98},' - . 'html2canvas: {scale: 3, letterRendering: true, useCORS: true},' + . 'html2canvas: {scale: 3, letterRendering: true, useCORS: true, backgroundColor: "#ffffff",' + . ' onclone: function (doc) {' + . 'doc.body.style.padding = "0";' + . 'doc.body.style.background = "#fff";' + . 'doc.querySelectorAll(".page").forEach(function (page) {' + . 'page.style.boxShadow = "none";' + . 'page.style.margin = "0 auto";' + . '});' + . '}},' . 'jsPDF: {unit: "mm", format: "a4", orientation: ' . json_encode($this->orientation) . '},' - . 'pagebreak: {mode: ["avoid-all", "css", "legacy"]}' + . 'pagebreak: {mode: ["css", "legacy"]}' . '};' . '}' . 'function fsDownloadPdf(filename) {' diff --git a/Test/Core/Lib/PDF/Dynamic/PDFBuilderTest.php b/Test/Core/Lib/PDF/Dynamic/PDFBuilderTest.php index 66500bdb47..a3aff484d7 100644 --- a/Test/Core/Lib/PDF/Dynamic/PDFBuilderTest.php +++ b/Test/Core/Lib/PDF/Dynamic/PDFBuilderTest.php @@ -20,11 +20,15 @@ namespace FacturaScripts\Test\Core\Lib\PDF\Dynamic; use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\DualColumnTableBlock; +use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\PageFooterBlock; +use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\ParallelTableBlock; use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\RawHtmlBlock; use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\SpacerBlock; use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\TableBlock; use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\TextBlock; use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\TitleBlock; +use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\WatermarkTextBlock; +use FacturaScripts\Core\Lib\PDF\Dynamic\ModelTableHelper; use FacturaScripts\Core\Lib\PDF\Dynamic\PDFBuilder; use PHPUnit\Framework\TestCase; @@ -82,6 +86,29 @@ public function testDualColumnTableBlock(): void $this->assertStringContainsString('03-07-2026', $html); } + public function testParallelTableBlockPairsInTwoColumns(): void + { + $html = (new ParallelTableBlock([ + 'Nombre' => 'ACME', + 'Ciudad' => 'Madrid', + 'Email' => 'x@y.z', + ]))->render(); + + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('Nombre: ACME', $html); + // 3 pares → 2 filas, la última con celda vacía de relleno + $this->assertSame(2, substr_count($html, '')); + $this->assertStringContainsString('', $html); + } + + public function testPageFooterBlock(): void + { + $html = (new PageFooterBlock('1 / 1', 'Generado el 03-07-2026'))->render(); + $this->assertStringContainsString('class="page-footer"', $html); + $this->assertStringContainsString('
1 / 1
', $html); + $this->assertStringContainsString('
Generado el 03-07-2026
', $html); + } + public function testRawHtmlBlockSanitizes(): void { $html = (new RawHtmlBlock( @@ -123,8 +150,36 @@ public function testBuilderHtmlDocument(): void $this->assertStringContainsString('orientation:"landscape"', str_replace(' ', '', $html)); $this->assertStringContainsString('function fsPrint()', $html); $this->assertStringContainsString('function fsDownloadPdf(', $html); - // en horizontal, la página mide 257mm de ancho (297 - 2 x 20 de margen) - $this->assertStringContainsString('width: 257mm', $html); + // en horizontal, la página mide 297mm de ancho con el margen como padding + $this->assertStringContainsString('width: 297mm', $html); + $this->assertStringContainsString('padding: 20mm', $html); + } + + public function testWatermarkTextBlock(): void + { + $html = (new WatermarkTextBlock('BORRADOR'))->render(); + $this->assertStringContainsString('class="watermark-text"', $html); + $this->assertStringContainsString('color: #C80000', $html); + $this->assertStringContainsString('BORRADOR', $html); + } + + public function testRemoveEmptyColumns(): void + { + $titles = ['ref' => 'Ref.', 'dto' => 'Dto.', 'irpf' => 'IRPF', 'total' => 'Total']; + $alignments = ['ref' => 'left', 'dto' => 'right', 'irpf' => 'right', 'total' => 'right']; + $rows = [ + ['ref' => 'A1', 'dto' => '10,00%', 'irpf' => '0,00%', 'total' => '20,00'], + ['ref' => 'B2', 'dto' => '0,00%', 'irpf' => '0,00%', 'total' => '5,00'], + ]; + + ModelTableHelper::removeEmptyColumns($rows, $titles, $alignments, ['0,00', '0,00%']); + + // irpf desaparece (todo a cero), dto se mantiene (una fila con valor) + $this->assertArrayNotHasKey('irpf', $titles); + $this->assertArrayNotHasKey('irpf', $rows[0]); + $this->assertArrayHasKey('dto', $titles); + $this->assertArrayNotHasKey('irpf', $alignments); + $this->assertSame(['ref', 'dto', 'total'], array_keys($rows[0])); } public function testCustomBlockRegistry(): void From ffe98996a6a426c28989305c784c908a27a7b485 Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Fri, 3 Jul 2026 13:56:50 +0200 Subject: [PATCH 07/10] =?UTF-8?q?feat(pdf):=20muestra=20el=20bot=C3=B3n=20?= =?UTF-8?q?de=20vista=20previa=20junto=20al=20de=20imprimir=20en=20las=20p?= =?UTF-8?q?lantillas=20maestras?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Lib/PDF/Dynamic/PDFPreviewTrait.php | 7 +++++-- Core/View/Master/PanelController.html.twig | 8 ++++++++ Core/View/Master/PanelControllerTop.html.twig | 8 ++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Core/Lib/PDF/Dynamic/PDFPreviewTrait.php b/Core/Lib/PDF/Dynamic/PDFPreviewTrait.php index 39588110a0..7a3f1267b5 100644 --- a/Core/Lib/PDF/Dynamic/PDFPreviewTrait.php +++ b/Core/Lib/PDF/Dynamic/PDFPreviewTrait.php @@ -33,15 +33,18 @@ */ trait PDFPreviewTrait { + /** @var bool shows the preview button in the master templates */ + public $pdfPreviewSupport = true; + /** * Builds and returns the document to preview. */ abstract protected function buildPdf(): PDFBuilder; /** - * Returns the onclick js code for a 'js' type button that opens the preview modal. + * Returns the onclick js code for a button that opens the preview modal. */ - protected function pdfPreviewButtonJs($code, string $filename = ''): string + public function pdfPreviewButtonJs($code, string $filename = ''): string { $i18n = Tools::lang(); $labels = [ diff --git a/Core/View/Master/PanelController.html.twig b/Core/View/Master/PanelController.html.twig index e20ef820a2..8ad092b6c8 100644 --- a/Core/View/Master/PanelController.html.twig +++ b/Core/View/Master/PanelController.html.twig @@ -75,6 +75,14 @@ {% if fsc.hasData and firstView.settings.btnPrint %} {{ _self.printButton(fsc, firstView, i18n) }} {% endif %} + {# -- PDF preview button -- #} + {% if fsc.hasData and fsc.pdfPreviewSupport is defined and fsc.pdfPreviewSupport %} + + {% endif %} {% for includeView in getIncludeViews('PanelController', 'topButtons') %} {% include includeView['path'] %} {% endfor %} diff --git a/Core/View/Master/PanelControllerTop.html.twig b/Core/View/Master/PanelControllerTop.html.twig index ab9a78bc26..f9eb9d3f34 100644 --- a/Core/View/Master/PanelControllerTop.html.twig +++ b/Core/View/Master/PanelControllerTop.html.twig @@ -79,6 +79,14 @@ {% if fsc.hasData and firstView.settings.btnPrint %} {{ _self.printButton(fsc, firstView, i18n) }} {% endif %} + {# -- PDF preview button -- #} + {% if fsc.hasData and fsc.pdfPreviewSupport is defined and fsc.pdfPreviewSupport %} + + {% endif %} {% for includeView in getIncludeViews('PanelControllerTop', 'topButtons') %} {% include includeView['path'] %} {% endfor %} From b4464578c357eda03d8f72c5c2bb1e0cb7d879d2 Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Fri, 3 Jul 2026 13:56:50 +0200 Subject: [PATCH 08/10] feat(empresa): replica el export PDF original en la vista previa --- Core/Controller/EditEmpresa.php | 72 +++++++++++++++++---------------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/Core/Controller/EditEmpresa.php b/Core/Controller/EditEmpresa.php index ba76701c4a..db253bfd6c 100644 --- a/Core/Controller/EditEmpresa.php +++ b/Core/Controller/EditEmpresa.php @@ -26,7 +26,6 @@ use FacturaScripts\Core\Lib\PDF\Dynamic\PDFBuilder; use FacturaScripts\Core\Lib\PDF\Dynamic\PDFPreviewTrait; use FacturaScripts\Dinamic\Lib\RegimenIVA; -use FacturaScripts\Dinamic\Model\Almacen; /** * Controller to edit a single item from the Empresa model @@ -57,37 +56,51 @@ protected function buildPdf(): PDFBuilder { $empresa = $this->getModel(); $empresa->loadFromCode($this->request->input('code')); + $i18n = Tools::lang(); $doc = PDFBuilder::create() ->setTitle($empresa->nombrecorto ?? $empresa->nombre ?? 'company') - ->addCompanyHeader($empresa, 'right') - ->addSpacer(5) - ->addTitle(Tools::lang()->trans('company'), 2) - ->addDualColumnTable([ - Tools::lang()->trans('name') => $empresa->nombre, - Tools::lang()->trans('fiscal-number') => $empresa->cifnif, - Tools::lang()->trans('address') => $empresa->direccion, - Tools::lang()->trans('city') => $empresa->ciudad, - Tools::lang()->trans('admin') => $empresa->administrador, + ->addDocumentHeader($empresa) + ->addTitle($i18n->trans('company') . ': ' . $empresa->nombre) + ->addHtml('
') + ->addParallelTable([ + $i18n->trans('name') => $empresa->nombre, + $i18n->trans('short-name') => $empresa->nombrecorto, + $i18n->trans('fiscal-id') => $empresa->tipoidfiscal, + $i18n->trans('fiscal-number') => $empresa->cifnif, + $i18n->trans('address') => $empresa->direccion, + $i18n->trans('zip-code') => $empresa->codpostal, + $i18n->trans('city') => $empresa->ciudad, + $i18n->trans('province') => $empresa->provincia, + $i18n->trans('country') => $empresa->codpais, + $i18n->trans('phone') => $empresa->telefono1, + $i18n->trans('phone2') => $empresa->telefono2, + $i18n->trans('fax') => $empresa->fax, + $i18n->trans('email') => $empresa->email, + $i18n->trans('web') => $empresa->web, + $i18n->trans('admin') => $empresa->administrador, + $i18n->trans('start-date') => $empresa->fechaalta, ]); - $rows = []; - foreach (Almacen::all([Where::eq('idempresa', $empresa->idempresa)]) as $almacen) { - $rows[] = [$almacen->codalmacen, $almacen->nombre, $almacen->direccion, $almacen->ciudad]; + // las vistas relacionadas, como hace el export original con cada pestaña + foreach (['EditAlmacen', 'ListCuentaBanco', 'ListFormaPago', 'ListEjercicio'] as $viewName) { + $view = $this->views[$viewName] ?? null; + if (null === $view) { + continue; + } + + $modelClass = get_class($view->model); + $cursor = $modelClass::all([Where::eq('idempresa', $empresa->idempresa)]); + if (empty($cursor)) { + continue; + } + + $doc->addSpacer(3) + ->addTitle($view->title, 2) + ->addModelTable($cursor, $view->getColumns()); } - if (false === empty($rows)) { - $doc->addSpacer(5) - ->addTitle(Tools::lang()->trans('warehouses'), 2) - ->addTable($rows, [ - Tools::lang()->trans('code'), - Tools::lang()->trans('name'), - Tools::lang()->trans('address'), - Tools::lang()->trans('city'), - ]); - } - - return $doc; + return $doc->addPageFooter('1 / 1', $i18n->trans('generated-at', ['%when%' => Tools::dateTime()])); } protected function checkViesAction(): bool @@ -185,15 +198,6 @@ protected function loadData($viewName, $view) 'label' => 'check-vies' ]); } - if ($view->model->exists()) { - $this->addButton($viewName, [ - 'action' => $this->pdfPreviewButtonJs($view->model->primaryColumnValue()), - 'color' => 'secondary', - 'icon' => 'fa-solid fa-file-pdf', - 'label' => 'pdf-preview', - 'type' => 'js' - ]); - } break; default: From 024b516ea1820ff3710f3d6ea1ceff7a6041a5ba Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Fri, 3 Jul 2026 13:56:50 +0200 Subject: [PATCH 09/10] =?UTF-8?q?feat(ventas):=20a=C3=B1ade=20vista=20prev?= =?UTF-8?q?ia=20PDF=20con=20aviso=20de=20borrador=20en=20EditFacturaClient?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Controller/EditFacturaCliente.php | 145 +++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/Core/Controller/EditFacturaCliente.php b/Core/Controller/EditFacturaCliente.php index 99dc128e0b..387d983245 100644 --- a/Core/Controller/EditFacturaCliente.php +++ b/Core/Controller/EditFacturaCliente.php @@ -6,8 +6,12 @@ namespace FacturaScripts\Core\Controller; use FacturaScripts\Core\Lib\ExtendedController\BaseView; +use FacturaScripts\Core\Lib\PDF\Dynamic\ModelTableHelper; +use FacturaScripts\Core\Lib\PDF\Dynamic\PDFBuilder; +use FacturaScripts\Core\Lib\PDF\Dynamic\PDFPreviewTrait; use FacturaScripts\Core\Tools; use FacturaScripts\Core\Where; +use FacturaScripts\Dinamic\Model\Empresa; use FacturaScripts\Dinamic\Lib\Accounting\InvoiceToAccounting; use FacturaScripts\Dinamic\Lib\AjaxForms\SalesController; use FacturaScripts\Dinamic\Lib\Calculator; @@ -22,6 +26,8 @@ */ class EditFacturaCliente extends SalesController { + use PDFPreviewTrait; + private const VIEW_ACCOUNTS = 'ListAsiento'; private const VIEW_RECEIPTS = 'ListReciboCliente'; @@ -46,6 +52,7 @@ public function getPageData(): array protected function createViews(): void { parent::createViews(); + $this->loadPdfViewerAssets(); $this->createViewsReceipts(); $this->createViewsAccounting(); @@ -128,6 +135,9 @@ private function createViewsReceipts(string $viewName = self::VIEW_RECEIPTS): vo protected function execPreviousAction($action) { switch ($action) { + case 'pdf-preview': + return $this->pdfPreviewAction(); + case 'generate-accounting': return $this->generateAccountingAction(); @@ -144,6 +154,141 @@ protected function execPreviousAction($action) return parent::execPreviousAction($action); } + protected function buildPdf(): PDFBuilder + { + $invoice = new FacturaCliente(); + $invoice->load($this->request->queryOrInput('code')); + $i18n = Tools::lang(); + + $empresa = new Empresa(); + $empresa->load($invoice->idempresa); + + // cabecera general + datos del documento, como insertBusinessDocHeader() + $subject = $invoice->getSubject(); + $doc = PDFBuilder::create() + ->setTitle($invoice->codigo) + ->addDocumentHeader($empresa) + ->addTitle($i18n->trans('invoice') . ': ' . $invoice->codigo) + ->addHtml('
') + ->addParallelTable([ + $i18n->trans('customer') => $invoice->nombrecliente, + $i18n->trans('date') => $invoice->fecha, + $i18n->trans('address') => $this->pdfDocAddress($invoice), + $i18n->trans('code') => $invoice->codigo, + $subject->tipoidfiscal ?: $i18n->trans('cifnif') => $invoice->cifnif, + $i18n->trans('number') => $invoice->numero, + $i18n->trans('serie') => $invoice->getSerie()->descripcion, + ]) + ->addSpacer(3); + + // líneas del documento, como insertBusinessDocBody() + $titles = [ + 'referencia' => $i18n->trans('reference') . ' - ' . $i18n->trans('description'), + 'cantidad' => $i18n->trans('quantity'), + 'pvpunitario' => $i18n->trans('price'), + 'dtopor' => $i18n->trans('dto'), + 'dtopor2' => $i18n->trans('dto-2'), + 'pvptotal' => $i18n->trans('net'), + 'iva' => $i18n->trans('tax'), + 'recargo' => $i18n->trans('re'), + 'irpf' => $i18n->trans('irpf'), + ]; + $alignments = array_fill_keys(array_keys($titles), 'right'); + $alignments['referencia'] = 'left'; + + $rows = []; + foreach ($invoice->getLines() as $line) { + $rows[] = [ + 'referencia' => empty($line->referencia) ? $line->descripcion : $line->referencia . ' - ' . $line->descripcion, + 'cantidad' => Tools::number($line->cantidad), + 'pvpunitario' => Tools::number($line->pvpunitario), + 'dtopor' => Tools::number($line->dtopor) . '%', + 'dtopor2' => Tools::number($line->dtopor2) . '%', + 'pvptotal' => Tools::number($line->pvptotal), + 'iva' => Tools::number($line->iva) . '%', + 'recargo' => Tools::number($line->recargo) . '%', + 'irpf' => Tools::number($line->irpf) . '%', + ]; + } + + $zero = Tools::number(0); + ModelTableHelper::removeEmptyColumns($rows, $titles, $alignments, [$zero, $zero . '%']); + $doc->addTable(array_map('array_values', $rows), array_values($titles), array_values($alignments)); + + // totales, como insertBusinessDocFooter() + $totalTitles = [ + 'net' => $i18n->trans('net'), + 'taxes' => $i18n->trans('taxes'), + 'totalSurcharge' => $i18n->trans('re'), + 'totalIrpf' => $i18n->trans('retention'), + 'totalSupplied' => $i18n->trans('supplied-amount'), + 'total' => $i18n->trans('total'), + ]; + $totalAlignments = array_fill_keys(array_keys($totalTitles), 'right'); + $totalRows = [[ + 'net' => Tools::number($invoice->neto), + 'taxes' => Tools::number($invoice->totaliva), + 'totalSurcharge' => Tools::number($invoice->totalrecargo), + 'totalIrpf' => Tools::number(0 - $invoice->totalirpf), + 'totalSupplied' => Tools::number($invoice->totalsuplidos), + 'total' => Tools::number($invoice->total), + ]]; + ModelTableHelper::removeEmptyColumns($totalRows, $totalTitles, $totalAlignments, [$zero]); + $doc->addSpacer(3) + ->addTable(array_map('array_values', $totalRows), array_values($totalTitles), array_values($totalAlignments)); + + // recibos, como insertInvoiceReceipts() + $receiptRows = []; + foreach ($invoice->getReceipts() as $receipt) { + $receiptRows[] = [ + $receipt->numero, + Tools::number($receipt->importe), + Tools::date($receipt->vencimiento), + $i18n->trans($receipt->pagado ? 'paid' : 'unpaid'), + ]; + } + + if (false === empty($receiptRows)) { + $doc->addSpacer(3) + ->addTitle($i18n->trans('receipts'), 3) + ->addTable($receiptRows, [ + $i18n->trans('receipt'), + $i18n->trans('amount'), + $i18n->trans('expiration'), + $i18n->trans('status'), + ], ['left', 'right', 'right', 'right']); + } + + if (false === empty($invoice->observaciones)) { + $doc->addSpacer(3) + ->addTitle($i18n->trans('observations'), 3) + ->addText($invoice->observaciones); + } + + // aviso de factura no emitida, como el export original + if ($invoice->editable) { + $doc->addWatermarkText($i18n->trans('sketch-invoice-warning')); + } + + return $doc->addPageFooter('1 / 1', $i18n->trans('generated-at', ['%when%' => Tools::dateTime()])); + } + + private function pdfDocAddress(FacturaCliente $invoice): string + { + $address = $invoice->direccion ?? ''; + if (!empty($invoice->codpostal)) { + $address .= empty($address) ? $invoice->codpostal : ', ' . $invoice->codpostal; + } + if (!empty($invoice->ciudad)) { + $address .= empty($address) ? $invoice->ciudad : ', ' . $invoice->ciudad; + } + if (!empty($invoice->provincia)) { + $address .= ' (' . $invoice->provincia . ')'; + } + + return $address; + } + private function generateAccountingAction(): bool { $invoice = new FacturaCliente(); From d26f74cadda514ec222534f982276118293c25aa Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Fri, 3 Jul 2026 14:13:43 +0200 Subject: [PATCH 10/10] feat: pdf showcase implementation --- Core/Controller/PDFShowcase.php | 173 ++++++++++++++++++++++++++++++++ Core/Translation/en_EN.json | 1 + Core/Translation/es_ES.json | 1 + Core/View/PDFShowcase.html.twig | 114 +++++++++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 Core/Controller/PDFShowcase.php create mode 100644 Core/View/PDFShowcase.html.twig diff --git a/Core/Controller/PDFShowcase.php b/Core/Controller/PDFShowcase.php new file mode 100644 index 0000000000..da527c1614 --- /dev/null +++ b/Core/Controller/PDFShowcase.php @@ -0,0 +1,173 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Controller; + +use FacturaScripts\Core\Base\Controller; +use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\DualColumnTableBlock; +use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\TextBlock; +use FacturaScripts\Core\Lib\PDF\Dynamic\Blocks\TitleBlock; +use FacturaScripts\Core\Lib\PDF\Dynamic\PDFBuilder; +use FacturaScripts\Core\Lib\PDF\Dynamic\PDFPreviewTrait; +use FacturaScripts\Core\Tools; +use FacturaScripts\Dinamic\Model\Empresa; + +/** + * Showcase of the dynamic PDF library: an interactive playground where the + * same document is rebuilt live with different themes, orientation and + * watermark, to show how malleable the builder is. + * + * @author Carlos García Gómez + */ +class PDFShowcase extends Controller +{ + use PDFPreviewTrait; + + const THEMES = [ + 'core' => [], + 'azul' => ['titlecolor' => '#2770CA', 'shadecolor' => '#E8F0FB'], + 'verde' => ['titlecolor' => '#1E7E34', 'shadecolor' => '#E6F4EA'], + 'burdeos' => ['titlecolor' => '#8B1E3F', 'shadecolor' => '#F7E9EE'], + 'oscuro' => ['titlecolor' => '#212529', 'shadecolor' => '#DEE2E6'], + ]; + + public function getPageData(): array + { + $data = parent::getPageData(); + $data['menu'] = 'admin'; + $data['title'] = 'pdf-showcase'; + $data['icon'] = 'fa-solid fa-file-pdf'; + return $data; + } + + public function privateCore(&$response, $user, $permissions) + { + parent::privateCore($response, $user, $permissions); + $this->loadPdfViewerAssets(); + + if ($this->request->queryOrInput('action') === 'pdf-preview') { + $this->pdfPreviewAction(); + } + } + + protected function buildPdf(): PDFBuilder + { + $i18n = Tools::lang(); + $theme = $this->request->queryOrInput('theme', 'core'); + $orientation = $this->request->queryOrInput('orientation', PDFBuilder::ORIENTATION_PORTRAIT); + $watermark = (bool)$this->request->queryOrInput('watermark', '0'); + + $empresa = new Empresa(); + $empresa->loadWhere([]); + + $doc = PDFBuilder::create() + ->setTitle('pdf-showcase') + ->setOrientation($orientation) + ->setStyleOptions(static::THEMES[$theme] ?? []); + + $this->buildCoverPage($doc); + $doc->addPageBreak(); + $this->buildComponentsPage($doc, $empresa); + + if ($watermark) { + $doc->addWatermarkText($i18n->trans('sketch-invoice-warning')); + } + + return $doc->addPageFooter('FacturaScripts', $i18n->trans('generated-at', ['%when%' => Tools::dateTime()])); + } + + protected function buildComponentsPage(PDFBuilder $doc, Empresa $empresa): void + { + $doc->addDocumentHeader($empresa) + ->addTitle('Presupuesto: P-2026-0042') + ->addHtml('
') + ->addColumns([ + [new DualColumnTableBlock([ + 'Cliente' => 'ACME Soluciones S.L.', + 'CIF' => 'B12345678', + 'Dirección' => 'Calle Mayor 1, 28001 Madrid', + ])], + [new DualColumnTableBlock([ + 'Fecha' => Tools::date(), + 'Validez' => '30 días', + 'Forma de pago' => 'Transferencia', + ])], + ]) + ->addSpacer(3) + ->addTable([ + ['SRV-01', 'Consultoría e implantación del ERP', '8,00', '60,00', '480,00'], + ['LIC-02', 'Licencia anual del software', '1,00', '350,00', '350,00'], + ['FRM-03', 'Formación del equipo (por sesión)', '3,00', '90,00', '270,00'], + ['SOP-04', 'Soporte prioritario 12 meses', '1,00', '240,00', '240,00'], + ], ['Ref.', 'Descripción', 'Cant.', 'Precio', 'Total'], + ['left', 'left', 'right', 'right', 'right']) + ->addColumns([ + [new TextBlock('Los precios no incluyen IVA. La propuesta incluye migración de datos desde el sistema anterior y acompañamiento durante el primer mes.', 'text-justify')], + [new DualColumnTableBlock([ + 'Neto' => '1.340,00 €', + 'IVA 21%' => '281,40 €', + 'Total' => '1.621,40 €', + ])], + ], [55, 45]) + ->addSpacer(5) + ->addTitle('Ventas por trimestre', 2) + ->addCss('.demo-bar { background: var(--fs-shade); margin-bottom: 2mm; }' + . '.demo-bar > div { background: var(--fs-title-color); color: #fff; padding: 2px 8px;' + . ' font-size: calc(var(--fs-font-size) - 2px); white-space: nowrap; }') + ->addHtml($this->barChart([ + ['T1', 34, '8.120 €'], + ['T2', 58, '13.900 €'], + ['T3', 41, '9.800 €'], + ['T4', 100, '23.960 €'], + ])) + ->addSpacer(3) + ->addText('Este gráfico está construido con addCss() y addHtml(): cualquier plugin puede añadir ' + . 'sus propios estilos y componentes al documento.', 'text-center font-small'); + } + + protected function buildCoverPage(PDFBuilder $doc): void + { + $doc->addSpacer(45) + ->addImage(FS_FOLDER . '/Dinamic/Assets/Images/horizontal-logo.png', 70, 'center') + ->addSpacer(10) + ->addTitle('Catálogo de componentes PDF', 1, 'center') + ->addText('Documento de demostración generado dinámicamente con PDFBuilder.', 'text-center') + ->addSpacer(10) + ->addHtml('
Core/Lib/PDF/Dynamic · html2pdf.js · ' + . 'previsualización, impresión y descarga desde el navegador
') + ->addSpacer(45) + ->addColumns([ + [new TitleBlock('11', 2, 'center'), new TextBlock('bloques de contenido', 'text-center font-small')], + [new TitleBlock('3', 2, 'center'), new TextBlock('estilos de tabla', 'text-center font-small')], + [new TitleBlock('5', 2, 'center'), new TextBlock('temas en esta demo', 'text-center font-small')], + [new TitleBlock('A4', 2, 'center'), new TextBlock('vertical u horizontal', 'text-center font-small')], + ]); + } + + protected function barChart(array $data): string + { + $html = ''; + foreach ($data as [$label, $percent, $value]) { + $html .= '
' + . htmlspecialchars($label . ' · ' . $value, ENT_QUOTES, 'UTF-8') . '
'; + } + + return $html; + } +} diff --git a/Core/Translation/en_EN.json b/Core/Translation/en_EN.json index 9048953d93..52157b82ae 100644 --- a/Core/Translation/en_EN.json +++ b/Core/Translation/en_EN.json @@ -1365,6 +1365,7 @@ "pct-tax": "tax rate", "pct-vat": "VAT rate", "pdf-preview": "PDF preview", + "pdf-showcase": "PDF showcase", "pending": "Pending", "pending-invoices-button": "Risk:", "pending-orders-button": "Pending orders:", diff --git a/Core/Translation/es_ES.json b/Core/Translation/es_ES.json index 704422d816..d1c5bdab4f 100644 --- a/Core/Translation/es_ES.json +++ b/Core/Translation/es_ES.json @@ -1365,6 +1365,7 @@ "pct-tax": "% impuestos", "pct-vat": "% Impuesto", "pdf-preview": "Vista previa PDF", + "pdf-showcase": "Demo PDF", "pending": "Pendiente", "pending-invoices-button": "Fact. Ptes:", "pending-orders-button": "Ped. Ptes:", diff --git a/Core/View/PDFShowcase.html.twig b/Core/View/PDFShowcase.html.twig new file mode 100644 index 0000000000..4cfae09a03 --- /dev/null +++ b/Core/View/PDFShowcase.html.twig @@ -0,0 +1,114 @@ +{# +/** + * This file is part of FacturaScripts + * Copyright (C) 2026 Carlos Garcia Gomez + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +#} +{% extends "Master/MenuTemplate.html.twig" %} + +{% block body %} +
+
+
+
+
+

+ {{ trans('pdf-showcase') }} +

+

+ El mismo documento, reconstruido en vivo con cada cambio. + Todo se genera con PDFBuilder y se convierte a PDF en el navegador. +

+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+
+
+ +
+
+
+ +{% endblock %}