This commit is contained in:
Yuri Kuznetsov
2025-06-22 15:08:13 +03:00
parent 6e477b7ff6
commit c2591357ab
12 changed files with 275 additions and 65 deletions

View File

@@ -0,0 +1,45 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Pdf;
use Espo\ORM\Entity;
/**
* @template TEntity of Entity
* @since 9.2.0
*/
interface AttachmentProvider
{
/**
* @param TEntity $entity
* @return AttachmentWrapper[]
*/
public function get(Entity $entity, Params $params): array;
}

View File

@@ -0,0 +1,53 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Pdf;
use Espo\Entities\Attachment;
/**
* @since 9.2.0
*/
readonly class AttachmentWrapper
{
public function __construct(
private Attachment $attachment,
private ?string $description = null,
) {}
public function getAttachment(): Attachment
{
return $this->attachment;
}
public function getDescription(): ?string
{
return $this->description;
}
}

View File

@@ -33,10 +33,10 @@ use stdClass;
class Data
{
/**
* @var array<string, mixed>
*/
/** @var array<string, mixed> */
private $additionalTemplateData = [];
/** @var AttachmentWrapper[] */
private $attachments = [];
public function getAdditionalTemplateData(): stdClass
{
@@ -55,6 +55,28 @@ class Data
return $obj;
}
/**
* @param AttachmentWrapper[] $attachments
*/
public function withAttachmentsAdded(array $attachments): self
{
$obj = clone $this;
foreach ($attachments as $attachment) {
$obj->attachments[] = $attachment;
}
return $obj;
}
/**
* @return AttachmentWrapper[]
*/
public function getAttachments(): array
{
return $this->attachments;
}
public static function create(): self
{
return new self();

View File

@@ -30,23 +30,18 @@
namespace Espo\Tools\Pdf\Data;
use Espo\ORM\Entity;
use Espo\Core\Utils\Metadata;
use Espo\Core\InjectableFactory;
use Espo\Tools\Pdf\AttachmentProvider;
use Espo\Tools\Pdf\Data;
use Espo\Tools\Pdf\Params;
class DataLoaderManager
{
private Metadata $metadata;
private InjectableFactory $injectableFactory;
public function __construct(Metadata $metadata, InjectableFactory $injectableFactory)
{
$this->metadata = $metadata;
$this->injectableFactory = $injectableFactory;
}
public function __construct(
private Metadata $metadata,
private InjectableFactory $injectableFactory,
) {}
public function load(Entity $entity, ?Params $params = null, ?Data $data = null): Data
{
@@ -58,17 +53,29 @@ class DataLoaderManager
$data = Data::create();
}
/** @var class-string<DataLoader>[] $classNameList */
$classNameList = $this->metadata->get(['pdfDefs', $entity->getEntityType(), 'dataLoaderClassNameList']) ?? [];
$defs = $this->metadata->get("pdfDefs.{$entity->getEntityType()}") ?? [];
foreach ($classNameList as $className) {
$loader = $this->createLoader($className);
/** @var class-string<DataLoader>[] $loaderClassList */
$loaderClassList = $defs['dataLoaderClassNameList'] ?? [];
$loadedData = $loader->load($entity, $params);
foreach ($loaderClassList as $className) {
$loadedData = $this->createLoader($className)
->load($entity, $params);
$data = $data->withAdditionalTemplateData($loadedData);
}
/** @var class-string<AttachmentProvider<Entity>>[] $attachmentProviderClassList */
$attachmentProviderClassList = $defs['attachmentProviderClassNameList'] ?? [];
foreach ($attachmentProviderClassList as $className) {
$provider = $this->createProvider($className);
$attachments = $provider->get($entity, $params);
$data = $data->withAttachmentsAdded($attachments);
}
return $data;
}
@@ -79,4 +86,14 @@ class DataLoaderManager
{
return $this->injectableFactory->create($className);
}
/**
* @param class-string<AttachmentProvider<Entity>> $className
* @return AttachmentProvider<Entity>
*/
private function createProvider(string $className): AttachmentProvider
{
/** @var AttachmentProvider<Entity> */
return $this->injectableFactory->create($className);
}
}

View File

@@ -32,6 +32,7 @@ namespace Espo\Tools\Pdf\Dompdf;
use Dompdf\Dompdf;
use Dompdf\Options;
use Espo\Core\Utils\Config;
use Espo\Tools\Pdf\Params;
use Espo\Tools\Pdf\Template;
class DompdfInitializer
@@ -41,17 +42,22 @@ class DompdfInitializer
private const PT = 2.83465;
public function __construct(
private Config $config
private Config $config,
) {}
public function initialize(Template $template): Dompdf
public function initialize(Template $template, Params $params): Dompdf
{
$options = new Options();
$options->setIsPdfAEnabled($params->isPdfA());
$options->setDefaultFont($this->getFontFace($template));
$pdf = new Dompdf($options);
if ($params->isPdfA()) {
$this->mapFonts($pdf);
}
$size = $template->getPageFormat() === Template::PAGE_FORMAT_CUSTOM ?
[0.0, 0.0, $template->getPageWidth() * self::PT, $template->getPageHeight() * self::PT] :
$template->getPageFormat();
@@ -72,4 +78,19 @@ class DompdfInitializer
$this->config->get('pdfFontFace') ??
$this->defaultFontFace;
}
private function mapFonts(Dompdf $pdf): void
{
// Fonts are included in PDF/A. Map standard fonts to open source analogues.
$fontMetrics = $pdf->getFontMetrics();
$fontMetrics->setFontFamily('courier', $fontMetrics->getFamily('DejaVu Sans Mono'));
$fontMetrics->setFontFamily('fixed', $fontMetrics->getFamily('DejaVu Sans Mono'));
$fontMetrics->setFontFamily('helvetica', $fontMetrics->getFamily('DejaVu Sans'));
$fontMetrics->setFontFamily('monospace', $fontMetrics->getFamily('DejaVu Sans Mono'));
$fontMetrics->setFontFamily('sans-serif', $fontMetrics->getFamily('DejaVu Sans'));
$fontMetrics->setFontFamily('serif', $fontMetrics->getFamily('DejaVu Serif'));
$fontMetrics->setFontFamily('times', $fontMetrics->getFamily('DejaVu Serif'));
$fontMetrics->setFontFamily('times-roman', $fontMetrics->getFamily('DejaVu Serif'));
}
}

View File

@@ -29,6 +29,9 @@
namespace Espo\Tools\Pdf\Dompdf;
use Dompdf\Adapter\CPDF;
use Dompdf\Dompdf;
use Espo\Core\FileStorage\Manager;
use Espo\ORM\Entity;
use Espo\Tools\Pdf\Contents;
use Espo\Tools\Pdf\Data;
@@ -36,17 +39,19 @@ use Espo\Tools\Pdf\Dompdf\Contents as DompdfContents;
use Espo\Tools\Pdf\EntityPrinter as EntityPrinterInterface;
use Espo\Tools\Pdf\Params;
use Espo\Tools\Pdf\Template;
use RuntimeException;
class EntityPrinter implements EntityPrinterInterface
{
public function __construct(
private DompdfInitializer $dompdfInitializer,
private HtmlComposer $htmlComposer
private HtmlComposer $htmlComposer,
private Manager $fileStorageManager,
) {}
public function print(Template $template, Entity $entity, Params $params, Data $data): Contents
{
$pdf = $this->dompdfInitializer->initialize($template);
$pdf = $this->dompdfInitializer->initialize($template, $params);
$headHtml = $this->htmlComposer->composeHead($template, $entity);
$headerFooterHtml = $this->htmlComposer->composeHeaderFooter($template, $entity, $params, $data);
@@ -57,6 +62,34 @@ class EntityPrinter implements EntityPrinterInterface
$pdf->loadHtml($html);
$pdf->render();
$this->addAttachments($pdf, $data);
return new DompdfContents($pdf);
}
private function addAttachments(Dompdf $pdf, Data $data): void
{
if ($data->getAttachments() === []) {
return;
}
$canvas = $pdf->getCanvas();
if (!$canvas instanceof CPDF) {
throw new RuntimeException("Non CPDF canvas");
}
$cPdf = $canvas->get_cpdf();
foreach ($data->getAttachments() as $i => $attachmentWrapper) {
$attachment = $attachmentWrapper->getAttachment();
$path = $this->fileStorageManager->getLocalFilePath($attachment);
$name = $attachment->getName() ?? 'file-' . $i;
$description = $attachmentWrapper->getDescription() ?? '';
$cPdf->addEmbeddedFile($path, $name, $description);
}
}
}

View File

@@ -31,6 +31,7 @@ namespace Espo\Tools\Pdf;
use DateTime;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
@@ -42,6 +43,7 @@ use Espo\Core\Record\ServiceContainer;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\Util;
use Espo\Entities\Attachment;
use Espo\Entities\Template as TemplateEntity;
@@ -56,40 +58,19 @@ class MassService
private const ATTACHMENT_MASS_PDF_ROLE = 'Mass Pdf';
private const REMOVE_MASS_PDF_PERIOD = '1 hour';
private ServiceContainer $serviceContainer;
private Config $config;
private EntityManager $entityManager;
private Acl $acl;
private DataLoaderManager $dataLoaderManager;
private SelectBuilderFactory $selectBuilderFactory;
private Builder $builder;
private Language $defaultLanguage;
private JobSchedulerFactory $jobSchedulerFactory;
private FileStorageManager $fileStorageManager;
public function __construct(
ServiceContainer $serviceContainer,
Config $config,
EntityManager $entityManager,
Acl $acl,
DataLoaderManager $dataLoaderManager,
SelectBuilderFactory $selectBuilderFactory,
Builder $builder,
Language $defaultLanguage,
JobSchedulerFactory $jobSchedulerFactory,
FileStorageManager $fileStorageManager
) {
$this->serviceContainer = $serviceContainer;
$this->config = $config;
$this->entityManager = $entityManager;
$this->acl = $acl;
$this->dataLoaderManager = $dataLoaderManager;
$this->selectBuilderFactory = $selectBuilderFactory;
$this->builder = $builder;
$this->defaultLanguage = $defaultLanguage;
$this->jobSchedulerFactory = $jobSchedulerFactory;
$this->fileStorageManager = $fileStorageManager;
}
private ServiceContainer $serviceContainer,
private Config $config,
private EntityManager $entityManager,
private Acl $acl,
private DataLoaderManager $dataLoaderManager,
private SelectBuilderFactory $selectBuilderFactory,
private Builder $builder,
private Language $defaultLanguage,
private JobSchedulerFactory $jobSchedulerFactory,
private FileStorageManager $fileStorageManager,
private Metadata $metadata,
) {}
/**
* Generate a PDF for multiple records.
@@ -98,6 +79,7 @@ class MassService
* @throws Error
* @throws NotFound
* @throws Forbidden
* @throws BadRequest
*/
public function generate(
string $entityType,
@@ -153,6 +135,10 @@ class MassService
$idDataMap = IdDataMap::create();
$pdfA = $this->metadata->get("pdfDefs.$entityType.pdfA") ?? false;
$params = $params->withPdfA($pdfA);
foreach ($collection as $entity) {
$service->loadAdditionalFields($entity);

View File

@@ -35,16 +35,29 @@ namespace Espo\Tools\Pdf;
class Params
{
private bool $applyAcl = false;
private bool $pdfA = false;
public function applyAcl(): bool
{
return $this->applyAcl;
}
public function isPdfA(): bool
{
return $this->pdfA;
}
public function withPdfA(bool $pdfA = true): self
{
$obj = clone $this;
$obj->pdfA = $pdfA;
return $obj;
}
public function withAcl(bool $applyAcl = true): self
{
$obj = clone $this;
$obj->applyAcl = $applyAcl;
return $obj;

View File

@@ -35,6 +35,7 @@ use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\Entities\Template as TemplateEntity;
use Espo\ORM\EntityManager;
use Espo\Tools\Pdf\Data\DataLoaderManager;
@@ -50,6 +51,7 @@ class Service
private DataLoaderManager $dataLoaderManager,
private Config $config,
private Builder $builder,
private Metadata $metadata,
) {}
/**
@@ -116,6 +118,10 @@ class Service
throw new Error("Not matching entity types.");
}
$pdfA = $this->metadata->get("pdfDefs.{$entity->getEntityType()}.pdfA") ?? false;
$params = $params->withPdfA($pdfA);
$data = $this->dataLoaderManager->load($entity, $params, $data);
$engine = $this->config->get('pdfEngine') ?? self::DEFAULT_ENGINE;

View File

@@ -45,7 +45,7 @@
"johngrogg/ics-parser": "^3.0",
"phpseclib/phpseclib": "^3.0",
"openspout/openspout": "~4.28",
"dompdf/dompdf": "^3.0",
"dompdf/dompdf": "^3.1",
"brick/phonenumber": "^0.5.0",
"picqer/php-barcode-generator": "^2.4",
"chillerlan/php-qrcode": "^4.4",

16
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "fdcc2fe81ecada0b857711ff3899dcef",
"content-hash": "60833a543fd7f0ed10be53d868d984bf",
"packages": [
{
"name": "async-aws/core",
@@ -1043,16 +1043,16 @@
},
{
"name": "dompdf/dompdf",
"version": "v3.0.0",
"version": "v3.1.0",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "fbc7c5ee5d94f7a910b78b43feb7931b7f971b59"
"reference": "a51bd7a063a65499446919286fb18b518177155a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/fbc7c5ee5d94f7a910b78b43feb7931b7f971b59",
"reference": "fbc7c5ee5d94f7a910b78b43feb7931b7f971b59",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/a51bd7a063a65499446919286fb18b518177155a",
"reference": "a51bd7a063a65499446919286fb18b518177155a",
"shasum": ""
},
"require": {
@@ -1068,7 +1068,7 @@
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
@@ -1101,9 +1101,9 @@
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.0.0"
"source": "https://github.com/dompdf/dompdf/tree/v3.1.0"
},
"time": "2024-04-29T14:01:28+00:00"
"time": "2025-01-15T14:09:04+00:00"
},
{
"name": "dompdf/php-font-lib",

View File

@@ -14,6 +14,20 @@
]
},
"description": "List of classes that loads additional data for PDF. Classes should implement the interface Espo\\Tools\\Pdf\\DataLoader. Use __APPEND__ for extending."
},
"pdfA": {
"type": "boolean",
"description": "Use PDF/A. Since v9.2."
},
"attachmentProviderClassNameList": {
"type": "array",
"items": {
"anyOf": [
{"const": "__APPEND__"},
{"type": "string"}
]
},
"description": "Attachment providers. Should implement `Espo\\Tools\\Pdf\\AttachmentProvider`. Since v9.2."
}
}
}