Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 253 additions & 10 deletions Core/Lib/PDF/PDFDocument.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@

namespace FacturaScripts\Core\Lib\PDF;

use Exception;
use FacturaScripts\Core\Base\DataBase\DataBaseWhere;
use FacturaScripts\Core\Model\Base\BusinessDocument;
use FacturaScripts\Core\Template\ExtensionsTrait;
use FacturaScripts\Core\Tools;
use FacturaScripts\Dinamic\Model\AgenciaTransporte;
use FacturaScripts\Dinamic\Model\AttachedFile;
Expand All @@ -46,6 +48,8 @@
*/
abstract class PDFDocument extends PDFCore
{
use ExtensionsTrait;

const INVOICE_TOTALS_Y = 200;

/** @var FormatoDocumento */
Expand Down Expand Up @@ -247,6 +251,9 @@ protected function getTaxesRows($model)
*/
protected function insertBusinessDocBody($model)
{
$qrImage = $this->pipe('qrImageAfterLines', $model);
$qrTitle = $this->pipe('qrTitleAfterLines', $model);

$headers = [];
$tableOptions = [
'cols' => [],
Expand Down Expand Up @@ -300,6 +307,19 @@ protected function insertBusinessDocBody($model)
$this->removeEmptyCols($tableData, $headers, Tools::number(0));
$this->pdf->ezTable($tableData, $headers, '', $tableOptions);
}

// añadir el código QR si existe
if (!empty($qrImage)) {
// Añadir margen superior antes del QR
$this->pdf->y -= 10;

// Calcular el ancho disponible con margen derecho (usar mismo layout que el header)
$pageWidth = $this->pdf->ez['pageWidth'] - $this->pdf->ez['leftMargin'] - $this->pdf->ez['rightMargin'];
$rightBlockWidth = $pageWidth * 0.2; // 20% para el QR (igual que en header)
$leftBlockWidth = $pageWidth * 0.8; // 80% espacio libre a la izquierda (igual que en header)

$this->renderQRimage($qrImage, $qrTitle, $this->pdf->ez['leftMargin'], $this->pdf->y, $leftBlockWidth, $rightBlockWidth);
}
}

/**
Expand Down Expand Up @@ -414,28 +434,58 @@ protected function insertBusinessDocFooter($model)
*/
protected function insertBusinessDocHeader($model)
{
// obtenemos el QR y el título desde las extensiones
$qrImage = $this->pipe('qrImageHeader', $model);
$qrTitle = $this->pipe('qrTitleHeader', $model);

// Definir anchos de los bloques según si existe imagen QR o no
$pageWidth = $this->pdf->ez['pageWidth'] - $this->pdf->ez['leftMargin'] - $this->pdf->ez['rightMargin'];
$hasQrImage = !empty($qrImage);

if ($hasQrImage) {
// Si hay QR, usar layout de 80% / 20%
$leftBlockWidth = $pageWidth * 0.8;
$rightBlockWidth = $pageWidth * 0.2;
} else {
// Si no hay QR, usar el 100% del ancho para la tabla
$leftBlockWidth = $pageWidth;
$rightBlockWidth = 0;
}

$startY = $this->pdf->y;
$startX = $this->pdf->ez['leftMargin'];

// --- BLOQUE IZQUIERDO (80%) ---
$this->pdf->saveState();
$this->pdf->ezSetY($startY);
$headerData = [
'title' => $this->i18n->trans($model->modelClassName() . '-min'),
'subject' => $this->i18n->trans('customer'),
'fieldName' => 'nombrecliente'
];

if (isset($model->codproveedor)) {
$headerData['subject'] = $this->i18n->trans('supplier');
$headerData['fieldName'] = 'nombre';
}

if (!empty($this->format->titulo)) {
$headerData['title'] = Tools::fixHtml($this->format->titulo);
}

$this->pdf->ezText("\n" . $headerData['title'] . ': ' . $model->codigo . "\n", self::FONT_SIZE + 6);
$this->newLine();

// Título alineado a la izquierda y dentro del bloque
$this->pdf->ezText("\n" . $headerData['title'] . ': ' . $model->codigo, self::FONT_SIZE + 6, [
'justification' => 'left',
'left' => $startX - $this->pdf->ez['leftMargin'], // compensar margen
'width' => $leftBlockWidth
]);

// Línea divisoria solo del 80%
$lineY = $this->pdf->y;
$this->pdf->setStrokeColor(0, 0, 0);
$this->pdf->line($startX, $lineY - 8, $startX + $leftBlockWidth, $lineY - 8);
$this->pdf->y -= 10;
$subject = $model->getSubject();
$tipoIdFiscal = empty($subject->tipoidfiscal) ? $this->i18n->trans('cifnif') : $subject->tipoidfiscal;
$serie = $model->getSerie();

$tableData = [
['key' => $headerData['subject'], 'value' => Tools::fixHtml($model->{$headerData['fieldName']})],
['key' => $this->i18n->trans('date'), 'value' => $model->fecha],
Expand All @@ -445,8 +495,6 @@ protected function insertBusinessDocHeader($model)
['key' => $this->i18n->trans('number'), 'value' => $model->numero],
['key' => $this->i18n->trans('serie'), 'value' => $serie->descripcion]
];

// rectified invoice?
if (isset($model->codigorect) && !empty($model->codigorect)) {
$original = new $model();
if ($original->loadFromCode('', [new DataBaseWhere('codigo', $model->codigorect)])) {
Expand All @@ -464,16 +512,24 @@ protected function insertBusinessDocHeader($model)
unset($tableData[6]);
}

// Opciones de la tabla
$tableWidth = $leftBlockWidth + 5;
$tableOptions = [
'width' => $this->tableWidth,
'width' => $tableWidth,
'showHeadings' => 0,
'shaded' => 0,
'lineCol' => [1, 1, 1],
'cols' => []
'cols' => [],
'xPos' => $startX + ($tableWidth / 2) - 5, // Posicionar el centro de la tabla para que empiece en el margen izquierdo
];
$this->insertParallelTable($tableData, '', $tableOptions);
$this->pdf->ezText('');
$this->pdf->restoreState();

// --- BLOQUE DERECHO (20%) ---
$this->renderQRimage($qrImage, $qrTitle, $startX, $startY, $leftBlockWidth, $rightBlockWidth);

// Si hay dirección de envío, insertarla después
if (!empty($model->idcontactoenv) && ($model->idcontactoenv != $model->idcontactofact || !empty($model->codtrans))) {
$this->insertBusinessDocShipping($model);
}
Expand Down Expand Up @@ -659,4 +715,191 @@ protected function insertInvoiceReceipts($invoice)
$this->pdf->ezTable($rows, $headers, '', $tableOptions);
}
}

protected function renderQRimage(?string $qrImage, ?string $qrTitle, float $startX, float $startY, float $leftBlockWidth, float $rightBlockWidth): void
{
if (empty($qrImage)) {
return;
}

// Asegurar que el QR sea cuadrado usando el menor de los dos valores disponibles
$availableWidth = $rightBlockWidth - 10;
$qrSize = min(80, $availableWidth);

// Calcular posición para centrar el QR horizontalmente en el espacio disponible
$qrX = $startX + $leftBlockWidth + 10 + ($availableWidth - $qrSize) / 2; // Centrar el QR
$qrY = $startY - 10; // Ajuste para alineación superior

// Detectar si $qrImage es una ruta de archivo o base64
if (str_starts_with($qrImage, 'data:image/')) {
// Es una imagen en base64 - crear archivo temporal
$base64Data = explode(',', $qrImage, 2)[1] ?? $qrImage;
$imageData = base64_decode($base64Data);

// Verificar si la decodificación fue exitosa
if ($imageData === false) {
Copy link

Copilot AI Jul 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When base64_decode() fails, the method silently returns without any indication of the failure. Consider logging this error or providing feedback about the invalid base64 data.

Suggested change
if ($imageData === false) {
if ($imageData === false) {
error_log('Failed to decode base64 data for QR image. Data: ' . substr($base64Data, 0, 50) . '...');

Copilot uses AI. Check for mistakes.
return;
}

// Determinar el tipo de imagen desde el data URI
$mimeType = 'image/png'; // por defecto PNG
if (preg_match('/data:image\/([^;]+)/', $qrImage, $matches)) {
$mimeType = 'image/' . $matches[1];
}

// Crear archivo temporal
$extension = ($mimeType === 'image/png') ? '.png' : '.jpg';
$tempFile = tempnam(sys_get_temp_dir(), 'qr_') . $extension;
Copy link

Copilot AI Jul 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using tempnam() with a predictable prefix could potentially lead to temporary file conflicts or security issues. Consider using a more unique prefix that includes the process ID or a random component.

Suggested change
$tempFile = tempnam(sys_get_temp_dir(), 'qr_') . $extension;
$tempFile = tempnam(sys_get_temp_dir(), 'qr_' . uniqid()) . $extension;

Copilot uses AI. Check for mistakes.
if (!file_put_contents($tempFile, $imageData)) {
return;
}

try {
// Usar la función nativa de Cezpdf para añadir la imagen con dimensiones cuadradas
if ($mimeType === 'image/png') {
$this->pdf->addPngFromFile($tempFile, $qrX, $qrY - $qrSize, $qrSize, $qrSize);
Copy link

Copilot AI Jul 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block silently returns without any error logging or user feedback when image addition fails. Consider adding error logging or a fallback mechanism to inform users when QR code rendering fails.

Copilot uses AI. Check for mistakes.
} else {
$this->pdf->addJpegFromFile($tempFile, $qrX, $qrY - $qrSize, $qrSize, $qrSize);
}
} catch (Exception $e) {
return;
} finally {
// Limpiar el archivo temporal
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
} elseif (file_exists($qrImage)) {
// Es una ruta de archivo válida - usar directamente los métodos nativos
$extension = strtolower(pathinfo($qrImage, PATHINFO_EXTENSION));

try {
if ($extension === 'png') {
$this->pdf->addPngFromFile($qrImage, $qrX, $qrY - $qrSize, $qrSize, $qrSize);
} else {
$this->pdf->addJpegFromFile($qrImage, $qrX, $qrY - $qrSize, $qrSize, $qrSize);
}
} catch (Exception $e) {
return;
}
} else {
return;
}

$this->renderQRtitle($qrTitle, $qrX, $qrY, $qrSize);
}

protected function renderQRtitle(?string $qrTitle, float $qrX, float $qrY, float $qrSize): void
{
if (empty($qrTitle)) {
return;
}

// Añadir título del QR si existe
$textX = $qrX + $qrSize / 2; // Centrar el texto horizontalmente respecto al QR
$textY = $qrY - $qrSize - 5; // Posicionar el texto 5 puntos debajo del QR

// Calcular el ancho disponible para el texto (desde el inicio del QR hasta el margen derecho)
$pageRightMargin = $this->pdf->ez['pageWidth'] - $this->pdf->ez['rightMargin'];
$availableTextWidth = $pageRightMargin - $qrX;

// Estimar el ancho del texto
$textWidth = $this->pdf->getTextWidth(self::FONT_SIZE, $qrTitle);

if ($textWidth <= $availableTextWidth) {
// El texto cabe en una línea
$this->pdf->addText($textX, $textY, self::FONT_SIZE, $qrTitle, 0, 'center');

// Actualizar posición Y para texto en una línea
$newY = $textY - self::FONT_SIZE - 10; // Altura del texto + margen
} else {
// El texto es demasiado ancho, necesitamos dividirlo en líneas
$words = explode(' ', $qrTitle);
$lines = [];
$currentLine = '';

// Si no hay espacios en el texto (es una sola "palabra"), dividir por caracteres
if (count($words) === 1) {
$text = $qrTitle;
$currentLine = '';

for ($i = 0; $i < strlen($text); $i++) {
$char = $text[$i];
$testLine = $currentLine . $char;
$testWidth = $this->pdf->getTextWidth(self::FONT_SIZE, $testLine);

if ($testWidth <= $availableTextWidth) {
$currentLine = $testLine;
} else {
if (!empty($currentLine)) {
$lines[] = $currentLine;
$currentLine = $char;
} else {
// Si un solo carácter no cabe, lo añadimos anyway
$lines[] = $char;
}
}
}

// Añadir la última línea si no está vacía
if (!empty($currentLine)) {
$lines[] = $currentLine;
}
} else {
// Hay espacios, dividir por palabras como antes
foreach ($words as $word) {
$testLine = empty($currentLine) ? $word : $currentLine . ' ' . $word;
$testWidth = $this->pdf->getTextWidth(self::FONT_SIZE, $testLine);

if ($testWidth <= $availableTextWidth) {
$currentLine = $testLine;
} else {
if (!empty($currentLine)) {
$lines[] = $currentLine;
$currentLine = $word;
} else {
// La palabra sola es demasiado larga, dividir por caracteres
$currentLine = '';
for ($i = 0; $i < strlen($word); $i++) {
$char = $word[$i];
$testLine = $currentLine . $char;
$testWidth = $this->pdf->getTextWidth(self::FONT_SIZE, $testLine);

if ($testWidth <= $availableTextWidth) {
$currentLine = $testLine;
} else {
if (!empty($currentLine)) {
$lines[] = $currentLine;
$currentLine = $char;
} else {
$lines[] = $char;
}
}
}
}
}
}

// Añadir la última línea si no está vacía
if (!empty($currentLine)) {
$lines[] = $currentLine;
}
}

Comment on lines +817 to +888
Copy link

Copilot AI Jul 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text wrapping logic in renderQRtitle() is overly complex with nested conditions and duplicated character-by-character splitting logic. Consider extracting this into a separate method like wrapTextToLines() to improve readability and maintainability.

Suggested change
$words = explode(' ', $qrTitle);
$lines = [];
$currentLine = '';
// Si no hay espacios en el texto (es una sola "palabra"), dividir por caracteres
if (count($words) === 1) {
$text = $qrTitle;
$currentLine = '';
for ($i = 0; $i < strlen($text); $i++) {
$char = $text[$i];
$testLine = $currentLine . $char;
$testWidth = $this->pdf->getTextWidth(self::FONT_SIZE, $testLine);
if ($testWidth <= $availableTextWidth) {
$currentLine = $testLine;
} else {
if (!empty($currentLine)) {
$lines[] = $currentLine;
$currentLine = $char;
} else {
// Si un solo carácter no cabe, lo añadimos anyway
$lines[] = $char;
}
}
}
// Añadir la última línea si no está vacía
if (!empty($currentLine)) {
$lines[] = $currentLine;
}
} else {
// Hay espacios, dividir por palabras como antes
foreach ($words as $word) {
$testLine = empty($currentLine) ? $word : $currentLine . ' ' . $word;
$testWidth = $this->pdf->getTextWidth(self::FONT_SIZE, $testLine);
if ($testWidth <= $availableTextWidth) {
$currentLine = $testLine;
} else {
if (!empty($currentLine)) {
$lines[] = $currentLine;
$currentLine = $word;
} else {
// La palabra sola es demasiado larga, dividir por caracteres
$currentLine = '';
for ($i = 0; $i < strlen($word); $i++) {
$char = $word[$i];
$testLine = $currentLine . $char;
$testWidth = $this->pdf->getTextWidth(self::FONT_SIZE, $testLine);
if ($testWidth <= $availableTextWidth) {
$currentLine = $testLine;
} else {
if (!empty($currentLine)) {
$lines[] = $currentLine;
$currentLine = $char;
} else {
$lines[] = $char;
}
}
}
}
}
}
// Añadir la última línea si no está vacía
if (!empty($currentLine)) {
$lines[] = $currentLine;
}
}
$lines = $this->wrapTextToLines($qrTitle, self::FONT_SIZE, $availableTextWidth);

Copilot uses AI. Check for mistakes.
// Renderizar las líneas centradas
$lineHeight = self::FONT_SIZE + 2; // Espaciado entre líneas
for ($i = 0; $i < count($lines); $i++) {
$lineY = $textY - ($i * $lineHeight);
$this->pdf->addText($textX, $lineY, self::FONT_SIZE, $lines[$i], 0, 'center');
}

// Actualizar la posición Y del PDF para evitar solapamiento con contenido posterior
$totalTextHeight = count($lines) * $lineHeight;
$newY = $textY - $totalTextHeight - 10; // 10 puntos de margen adicional
}

if ($newY < $this->pdf->y) {
$this->pdf->y = $newY;
}
}
}
Loading