From c2591357ab4198cc0c35ec2e18fbaad65c9b855e Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Sun, 22 Jun 2025 15:08:13 +0300 Subject: [PATCH] pdfa --- .../Espo/Tools/Pdf/AttachmentProvider.php | 45 ++++++++++++++++ .../Espo/Tools/Pdf/AttachmentWrapper.php | 53 +++++++++++++++++++ application/Espo/Tools/Pdf/Data.php | 28 ++++++++-- .../Espo/Tools/Pdf/Data/DataLoaderManager.php | 47 ++++++++++------ .../Tools/Pdf/Dompdf/DompdfInitializer.php | 25 ++++++++- .../Espo/Tools/Pdf/Dompdf/EntityPrinter.php | 37 ++++++++++++- application/Espo/Tools/Pdf/MassService.php | 52 +++++++----------- application/Espo/Tools/Pdf/Params.php | 15 +++++- application/Espo/Tools/Pdf/Service.php | 6 +++ composer.json | 2 +- composer.lock | 16 +++--- schema/metadata/pdfDefs.json | 14 +++++ 12 files changed, 275 insertions(+), 65 deletions(-) create mode 100644 application/Espo/Tools/Pdf/AttachmentProvider.php create mode 100644 application/Espo/Tools/Pdf/AttachmentWrapper.php diff --git a/application/Espo/Tools/Pdf/AttachmentProvider.php b/application/Espo/Tools/Pdf/AttachmentProvider.php new file mode 100644 index 0000000000..da35c27999 --- /dev/null +++ b/application/Espo/Tools/Pdf/AttachmentProvider.php @@ -0,0 +1,45 @@ +. + * + * 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; +} diff --git a/application/Espo/Tools/Pdf/AttachmentWrapper.php b/application/Espo/Tools/Pdf/AttachmentWrapper.php new file mode 100644 index 0000000000..cf55233f48 --- /dev/null +++ b/application/Espo/Tools/Pdf/AttachmentWrapper.php @@ -0,0 +1,53 @@ +. + * + * 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; + } +} diff --git a/application/Espo/Tools/Pdf/Data.php b/application/Espo/Tools/Pdf/Data.php index 6bfe89bc3c..1317d0ac82 100644 --- a/application/Espo/Tools/Pdf/Data.php +++ b/application/Espo/Tools/Pdf/Data.php @@ -33,10 +33,10 @@ use stdClass; class Data { - /** - * @var array - */ + /** @var array */ 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(); diff --git a/application/Espo/Tools/Pdf/Data/DataLoaderManager.php b/application/Espo/Tools/Pdf/Data/DataLoaderManager.php index 224d235b10..e57938afbd 100644 --- a/application/Espo/Tools/Pdf/Data/DataLoaderManager.php +++ b/application/Espo/Tools/Pdf/Data/DataLoaderManager.php @@ -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[] $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[] $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>[] $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> $className + * @return AttachmentProvider + */ + private function createProvider(string $className): AttachmentProvider + { + /** @var AttachmentProvider */ + return $this->injectableFactory->create($className); + } } diff --git a/application/Espo/Tools/Pdf/Dompdf/DompdfInitializer.php b/application/Espo/Tools/Pdf/Dompdf/DompdfInitializer.php index 08e5f45cec..cbbbc246e5 100644 --- a/application/Espo/Tools/Pdf/Dompdf/DompdfInitializer.php +++ b/application/Espo/Tools/Pdf/Dompdf/DompdfInitializer.php @@ -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')); + } } diff --git a/application/Espo/Tools/Pdf/Dompdf/EntityPrinter.php b/application/Espo/Tools/Pdf/Dompdf/EntityPrinter.php index e8cd18b5c0..45c936363f 100644 --- a/application/Espo/Tools/Pdf/Dompdf/EntityPrinter.php +++ b/application/Espo/Tools/Pdf/Dompdf/EntityPrinter.php @@ -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); + } + } } diff --git a/application/Espo/Tools/Pdf/MassService.php b/application/Espo/Tools/Pdf/MassService.php index 1755baf4d6..1e608c538d 100644 --- a/application/Espo/Tools/Pdf/MassService.php +++ b/application/Espo/Tools/Pdf/MassService.php @@ -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); diff --git a/application/Espo/Tools/Pdf/Params.php b/application/Espo/Tools/Pdf/Params.php index 438660eab3..bc401c091a 100644 --- a/application/Espo/Tools/Pdf/Params.php +++ b/application/Espo/Tools/Pdf/Params.php @@ -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; diff --git a/application/Espo/Tools/Pdf/Service.php b/application/Espo/Tools/Pdf/Service.php index a7cb386791..9c6a699b8b 100644 --- a/application/Espo/Tools/Pdf/Service.php +++ b/application/Espo/Tools/Pdf/Service.php @@ -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; diff --git a/composer.json b/composer.json index 6296ace48a..6255a47692 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 327a2a2d61..ea52d60359 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/schema/metadata/pdfDefs.json b/schema/metadata/pdfDefs.json index 1f56505f29..4c94eef892 100644 --- a/schema/metadata/pdfDefs.json +++ b/schema/metadata/pdfDefs.json @@ -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." } } }