From 4d830e76b0dbdae6b27e4183e532f7e50229e2ac Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Thu, 30 Oct 2025 18:26:01 +0200 Subject: [PATCH] pdf filename --- .../ExtGroup/PdfGroup/GenerateType.php | 64 +++++++++++------- application/Espo/Entities/Template.php | 5 ++ application/Espo/EntryPoints/Pdf.php | 18 +++-- .../Espo/Resources/i18n/en_US/Template.json | 4 +- .../Resources/layouts/Template/detail.json | 6 +- .../metadata/entityDefs/Template.json | 5 ++ application/Espo/Tools/Pdf/Result.php | 66 +++++++++++++++++++ application/Espo/Tools/Pdf/Service.php | 48 +++++++++++++- 8 files changed, 179 insertions(+), 37 deletions(-) create mode 100644 application/Espo/Tools/Pdf/Result.php diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/PdfGroup/GenerateType.php b/application/Espo/Core/Formula/Functions/ExtGroup/PdfGroup/GenerateType.php index 394c1ba2cf..09d2b071d4 100644 --- a/application/Espo/Core/Formula/Functions/ExtGroup/PdfGroup/GenerateType.php +++ b/application/Espo/Core/Formula/Functions/ExtGroup/PdfGroup/GenerateType.php @@ -37,12 +37,16 @@ use Espo\Core\Formula\ArgumentList; use Espo\Core\Formula\Exceptions\Error; use Espo\Core\Formula\Functions\BaseFunction; use Espo\Core\Utils\Util; +use Espo\ORM\Entity; use Espo\Tools\Pdf\Params; use Espo\Core\Di; - +use Espo\Tools\Pdf\Result; use Espo\Tools\Pdf\Service; use Exception; +/** + * @noinspection PhpUnused + */ class GenerateType extends BaseFunction implements Di\EntityManagerAware, Di\InjectableFactoryAware, @@ -92,38 +96,29 @@ class GenerateType extends BaseFunction implements } if (!$entity) { - $this->log("Record {$entityType} {$id} does not exist."); + $this->log("Record $entityType $id does not exist."); throw new Error(); } - /** @var ?Template $template */ - $template = $em->getEntityById(Template::ENTITY_TYPE, $templateId); + $template = $em->getRDBRepositoryByClass(Template::class)->getById($templateId); if (!$template) { - $this->log("Template {$templateId} does not exist."); + $this->log("Template $templateId does not exist."); throw new Error(); } - if ($fileName) { - if (!str_ends_with($fileName, '.pdf')) { - $fileName .= '.pdf'; - } - } else { - $fileName = Util::sanitizeFileName($entity->get(Field::NAME)) . '.pdf'; - } - $params = Params::create()->withAcl(false); - try { - $service = $this->injectableFactory->create(Service::class); + $service = $this->injectableFactory->create(Service::class); - $contents = $service->generate( - $entity->getEntityType(), - $entity->getId(), - $template->getId(), - $params + try { + $result = $service->generate( + entityType: $entity->getEntityType(), + id: $entity->getId(), + templateId: $template->getId(), + params: $params, ); } catch (Exception $e) { $this->log("Error while generating. Message: " . $e->getMessage() . ".", 'error'); @@ -131,20 +126,41 @@ class GenerateType extends BaseFunction implements throw new Error(); } - /** @var Attachment $attachment */ - $attachment = $em->getNewEntity(Attachment::ENTITY_TYPE); + $fileName = $this->prepareFilename($fileName, $result, $entity); + + $attachment = $em->getRDBRepositoryByClass(Attachment::class)->getNew(); $attachment ->setName($fileName) ->setType('application/pdf') - ->setSize($contents->getStream()->getSize()) + ->setSize($result->getStream()->getSize()) ->setRelated(LinkParent::create($entityType, $id)) ->setRole(Attachment::ROLE_ATTACHMENT); $em->saveEntity($attachment); - $this->fileStorageManager->putStream($attachment, $contents->getStream()); + $this->fileStorageManager->putStream($attachment, $result->getStream()); return $attachment->getId(); } + + private function composeFilename(Entity $entity): string + { + $defaultName = $entity->get(Field::NAME) ?? $entity->getId(); + + return Util::sanitizeFileName($defaultName) . '.pdf'; + } + + private function prepareFilename(mixed $fileName, Result $result, Entity $entity): string + { + if ($fileName) { + if (!str_ends_with($fileName, '.pdf')) { + $fileName .= '.pdf'; + } + + return $fileName; + } + + return $result->getFilename() ?? $this->composeFilename($entity); + } } diff --git a/application/Espo/Entities/Template.php b/application/Espo/Entities/Template.php index c01c40d8d6..d8646fdd9e 100644 --- a/application/Espo/Entities/Template.php +++ b/application/Espo/Entities/Template.php @@ -53,4 +53,9 @@ class Template extends Entity { return $this->get('status') === self::STATUS_ACTIVE; } + + public function getFilename(): ?string + { + return $this->get('filename'); + } } diff --git a/application/Espo/EntryPoints/Pdf.php b/application/Espo/EntryPoints/Pdf.php index 127bcfb297..eb487d8a31 100644 --- a/application/Espo/EntryPoints/Pdf.php +++ b/application/Espo/EntryPoints/Pdf.php @@ -38,6 +38,7 @@ use Espo\Core\Name\Field; use Espo\Core\ORM\EntityManager; use Espo\Core\Utils\Util; use Espo\Entities\Template; +use Espo\ORM\Entity; use Espo\Tools\Pdf\Service; class Pdf implements EntryPoint @@ -69,11 +70,9 @@ class Pdf implements EntryPoint throw new NotFound("Template not found."); } - $contents = $this->service->generate($entityType, $entityId, $templateId); + $result = $this->service->generate($entityType, $entityId, $templateId); - $fileName = Util::sanitizeFileName($entity->get(Field::NAME) ?? 'unnamed'); - - $fileName = $fileName . '.pdf'; + $fileName = $result->getFilename() ?? $this->composeFileName($entity); $response ->setHeader('Content-Type', 'application/pdf') @@ -83,9 +82,16 @@ class Pdf implements EntryPoint ->setHeader('Content-Disposition', 'inline; filename="' . basename($fileName) . '"'); if (!$request->getServerParam('HTTP_ACCEPT_ENCODING')) { - $response->setHeader('Content-Length', (string) $contents->getStream()->getSize()); + $response->setHeader('Content-Length', (string) $result->getStream()->getSize()); } - $response->writeBody($contents->getStream()); + $response->writeBody($result->getStream()); + } + + private function composeFileName(Entity $entity): string + { + $defaultName = $entity->get(Field::NAME) ?? $entity->getId(); + + return Util::sanitizeFileName($defaultName) . '.pdf'; } } diff --git a/application/Espo/Resources/i18n/en_US/Template.json b/application/Espo/Resources/i18n/en_US/Template.json index 6050d81f16..6aab105317 100644 --- a/application/Espo/Resources/i18n/en_US/Template.json +++ b/application/Espo/Resources/i18n/en_US/Template.json @@ -21,7 +21,8 @@ "fontFace": "Font", "title": "Title", "style": "Style", - "status": "Status" + "status": "Status", + "filename": "Filename" }, "links": { }, @@ -48,6 +49,7 @@ "fontFace": {} }, "tooltips": { + "filename": "An optional filename. Entity attributes can be used as placeholders.\n\nAdditional placeholder:\n- `{{today}}`\n\nExample:\n`Document_{{today}}_{{name}}`", "footer": "Use {pageNumber} to print page number.", "variables": "Copy-paste needed placeholder to Header, Body or Footer." } diff --git a/application/Espo/Resources/layouts/Template/detail.json b/application/Espo/Resources/layouts/Template/detail.json index 00d45ab90a..8b77e9b0a6 100644 --- a/application/Espo/Resources/layouts/Template/detail.json +++ b/application/Espo/Resources/layouts/Template/detail.json @@ -35,11 +35,11 @@ }, { "rows": [ - [{"name": "title"}, false], + [{"name": "title"}, {"name": "filename"}], [{"name":"printHeader"}, {"name":"headerPosition"}], - [{"name":"header","fullWidth":true}], + [{"name":"header"}], [{"name":"printFooter"}, {"name":"footerPosition"}], - [{"name":"footer","fullWidth":true}], + [{"name":"footer"}], [{"name": "style"}] ] } diff --git a/application/Espo/Resources/metadata/entityDefs/Template.json b/application/Espo/Resources/metadata/entityDefs/Template.json index 8ea2334050..095be35e36 100644 --- a/application/Espo/Resources/metadata/entityDefs/Template.json +++ b/application/Espo/Resources/metadata/entityDefs/Template.json @@ -120,6 +120,11 @@ }, "title": { "type": "varchar" + }, + "filename": { + "type": "varchar", + "maxLength": 150, + "tooltip": true } }, "links": { diff --git a/application/Espo/Tools/Pdf/Result.php b/application/Espo/Tools/Pdf/Result.php new file mode 100644 index 0000000000..e6f1b1026c --- /dev/null +++ b/application/Espo/Tools/Pdf/Result.php @@ -0,0 +1,66 @@ +. + * + * 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 Psr\Http\Message\StreamInterface; + +/** + * @since 9.3.0 + */ +class Result implements Contents +{ + public function __construct( + private Contents $contents, + private ?string $filename + ) {} + + public function getStream(): StreamInterface + { + return $this->contents->getStream(); + } + + public function getString(): string + { + return $this->contents->getString(); + } + + public function getLength(): int + { + return $this->contents->getLength(); + } + + /** + * If null, it means that the template does not define the file name. + */ + public function getFilename(): ?string + { + return $this->filename; + } +} diff --git a/application/Espo/Tools/Pdf/Service.php b/application/Espo/Tools/Pdf/Service.php index e60a2b5b04..cc65d8e3b8 100644 --- a/application/Espo/Tools/Pdf/Service.php +++ b/application/Espo/Tools/Pdf/Service.php @@ -35,8 +35,11 @@ use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\NotFound; use Espo\Core\Record\ServiceContainer; use Espo\Core\Utils\Config; +use Espo\Core\Utils\DateTime; use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\Util; use Espo\Entities\Template as TemplateEntity; +use Espo\ORM\Entity; use Espo\ORM\EntityManager; use Espo\Tools\Pdf\Data\DataLoaderManager; @@ -52,6 +55,7 @@ class Service private Config $config, private Builder $builder, private Metadata $metadata, + private DateTime $dateTime, ) {} /** @@ -72,8 +76,8 @@ class Service string $id, string $templateId, ?Params $params = null, - ?Data $data = null - ): Contents { + ?Data $data = null, + ): Result { $params = $params ?? Params::create()->withAcl(true); @@ -130,6 +134,44 @@ class Service ->setEngine($engine) ->build(); - return $printer->printEntity($entity, $params, $data); + $contents = $printer->printEntity($entity, $params, $data); + + $filename = $this->prepareFilename($entity, $template, $params); + + return new Result($contents, $filename); + } + + private function prepareFilename(Entity $entity, TemplateEntity $template, Params $params): ?string + { + $filename = $template->getFilename() ?? null; + + if (!$filename) { + return null; + } + + $forbiddenAttributeList = $this->acl->getScopeForbiddenAttributeList($entity->getEntityType()); + + foreach ($entity->getAttributeList() as $attribute) { + $value = $entity->get($attribute); + + if (!is_scalar($value)) { + continue; + } + + if ($params->applyAcl() && in_array($attribute, $forbiddenAttributeList)) { + continue; + } + + $filename = str_replace('{{' . $attribute . '}}', (string) $value, $filename); + } + + $today = $this->dateTime->getTodayString(null, DateTime::SYSTEM_DATE_FORMAT); + $filename = str_replace('{{today}}', $today, $filename); + + if (!str_ends_with(strtolower($filename), '.pdf')) { + $filename .= '.pdf'; + } + + return Util::sanitizeFileName($filename) ?: null; } }