snapshot of https://github.com/espocrm/espocrm.git (master, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Test / Test on PHP 8.3 (push) Failing after 5s
Test / Test on PHP 8.4 (push) Failing after 4s
Test / Test on PHP 8.5 (push) Failing after 3s
TypeScript static analysis / TypeScript check (push) Failing after 14s
Test Integration on MySQL / Test on PHP 8.3 (push) Failing after 2s
Test Integration on MySQL / Test on PHP 8.5 (push) Failing after 0s
Test Integration on PostgreSQL / Test on PHP 8.4 fix (push) Failing after 1s
Test Integration on PostgreSQL / Test on PHP 8.4 master (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-18 04:19:15 +05:00
commit 7cc7e6b4f3
10077 changed files with 900756 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Dompdf;
use Espo\Tools\Pdf\Contents as ContentsInterface;
use GuzzleHttp\Psr7\Stream;
use Psr\Http\Message\StreamInterface;
use Dompdf\Dompdf;
use RuntimeException;
class Contents implements ContentsInterface
{
private ?string $string = null;
public function __construct(private Dompdf $pdf) {}
public function getStream(): StreamInterface
{
$resource = fopen('php://temp', 'r+');
if ($resource === false) {
throw new RuntimeException("Could not open temp.");
}
fwrite($resource, $this->getString());
rewind($resource);
return new Stream($resource);
}
public function getString(): string
{
if ($this->string === null) {
$this->string = $this->pdf->output();
}
return $this->string ?? '';
}
public function getLength(): int
{
return strlen($this->getString());
}
}

View File

@@ -0,0 +1,203 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Dompdf;
use Dompdf\Dompdf;
use Dompdf\FontMetrics;
use Dompdf\Options;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\File\Manager as FileManager;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\Module;
use Espo\Tools\Pdf\Params;
use Espo\Tools\Pdf\Template;
use RuntimeException;
class DompdfInitializer
{
private string $defaultFontFace = 'DejaVu Sans';
private string $cacheDir = 'data/cache/application/dompdf';
private string $pdfaCacheDir = 'data/cache/application/pdfa-dompdf';
private const PT = 2.83465;
/** @var array<string, string> */
private array $standardFontMapping = [
'courier' => 'DejaVu Sans Mono',
'fixed' => 'DejaVu Sans Mono',
'helvetica' => 'DejaVu Sans',
'monospace' => 'DejaVu Sans Mono',
'sans-serif' => 'DejaVu Sans',
'serif' => 'DejaVu Serif',
'times' => 'DejaVu Serif',
'times-roman' => 'DejaVu Serif',
];
public function __construct(
private Config $config,
private Metadata $metadata,
private FileManager $fileManager,
private Module $module,
) {}
public function initialize(Template $template, Params $params): Dompdf
{
$options = new Options();
$options
->setIsPdfAEnabled($params->isPdfA())
->setDefaultFont($this->getFontFace($template))
->setIsJavascriptEnabled(false);
$dir = $params->isPdfA() ? $this->pdfaCacheDir : $this->cacheDir;
$options->setFontDir($dir);
$options->setFontCache($dir);
if (!$this->fileManager->isDir($dir)) {
$this->fileManager->mkdir($dir);
}
$this->setupFontOptions($options);
$pdf = new Dompdf($options);
$this->mapFonts($pdf, $params->isPdfA(), $dir);
$size = $template->getPageFormat() === Template::PAGE_FORMAT_CUSTOM ?
[0.0, 0.0, $template->getPageWidth() * self::PT, $template->getPageHeight() * self::PT] :
$template->getPageFormat();
$orientation = $template->getPageOrientation() === Template::PAGE_ORIENTATION_PORTRAIT ?
'portrait' :
'landscape';
$pdf->setPaper($size, $orientation);
return $pdf;
}
private function getFontFace(Template $template): string
{
return
$template->getFontFace() ??
$this->config->get('pdfFontFace') ??
$this->defaultFontFace;
}
private function mapFonts(Dompdf $pdf, bool $isPdfA, string $dir): void
{
$file = $dir . '/' . FontMetrics::USER_FONTS_FILE;
if ($this->fileManager->exists($file)) {
return;
}
// When fonts are included in PDF/A, we need to map standard fonts to open source analogues.
// Also need to support popular fonts specified in CSS styles.
$fontMetrics = $pdf->getFontMetrics();
if ($isPdfA) {
foreach ($this->standardFontMapping as $key => $value) {
$fontMetrics->setFontFamily($key, $fontMetrics->getFamily($value));
}
return;
}
$this->setupAdditionalFonts($pdf);
/** @var string[] $fontList */
$fontList = $this->metadata->get('app.pdfEngines.Dompdf.fontFaceList') ?? [];
$fontList = array_map(fn ($it) => strtolower($it), $fontList);
foreach ($this->standardFontMapping as $key => $value) {
if (in_array(strtolower($key), $fontList)) {
continue;
}
$fontMetrics->setFontFamily($key, $fontMetrics->getFamily($value));
}
}
private function setupFontOptions(Options $options): void
{
$dirs = ['application/Espo/Resources/fonts'];
foreach ($this->module->getOrderedList() as $module) {
$dirs[] = $this->module->getModulePath($module) . '/Resources/fonts';
}
$dirs[] = 'custom/Espo/Custom/Resources/fonts';
$dirs = array_filter($dirs, fn ($dir) => $this->fileManager->isDir($dir));
$dirs = array_values($dirs);
$options->setChroot($dirs);
}
private function setupAdditionalFonts(Dompdf $pdf): void
{
/** @var array{family?: string, style?: string, weight?: string, source?: string}[] $fonts */
$fonts = $this->metadata->get("app.pdfEngines.Dompdf.additionalParams.fonts") ?? [];
foreach ($fonts as $defs) {
$family = $defs['family'] ?? throw new RuntimeException("No font 'family'.");
$style = $defs['style'] ?? throw new RuntimeException("No font 'style'.");
$weight = $defs['weight'] ?? throw new RuntimeException("No font 'weight'.");
$source = $defs['source'] ?? throw new RuntimeException("No font 'source'.");
$this->registerFont(
pdf: $pdf,
family: $family,
style: $style,
weight: $weight,
source: $source,
);
}
}
private function registerFont(
Dompdf $pdf,
string $family,
string $style,
string $weight,
string $source,
): void {
$fontMetrics = $pdf->getFontMetrics();
$fontMetrics->registerFont([
'family' => $family,
'style' => $style,
'weight' => $weight,
], $source);
}
}

View File

@@ -0,0 +1,95 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\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;
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 Manager $fileStorageManager,
) {}
public function print(Template $template, Entity $entity, Params $params, Data $data): Contents
{
$pdf = $this->dompdfInitializer->initialize($template, $params);
$headHtml = $this->htmlComposer->composeHead($template, $entity);
$headerFooterHtml = $this->htmlComposer->composeHeaderFooter($template, $entity, $params, $data);
$mainHtml = $this->htmlComposer->composeMain($template, $entity, $params, $data);
$html = $headHtml . "\n<body>" . $headerFooterHtml . $mainHtml . "</body>";
$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

@@ -0,0 +1,325 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Dompdf;
use chillerlan\QRCode\Common\EccLevel;
use chillerlan\QRCode\Output\QRMarkupSVG;
use chillerlan\QRCode\Output\QROutputInterface;
use Espo\Core\Htmlizer\TemplateRendererFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Log;
use Espo\ORM\Entity;
use Espo\Tools\Pdf\Data;
use Espo\Tools\Pdf\Params;
use Espo\Tools\Pdf\Template;
use Picqer\Barcode\BarcodeGeneratorSVG;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use Picqer\Barcode\Exceptions\UnknownTypeException;
use RuntimeException;
class HtmlComposer
{
public function __construct(
private Config $config,
private TemplateRendererFactory $templateRendererFactory,
private ImageSourceProvider $imageSourceProvider,
private Log $log
) {}
public function composeHead(Template $template, Entity $entity): string
{
$topMargin = $template->getTopMargin();
$rightMargin = $template->getRightMargin();
$bottomMargin = $template->getBottomMargin();
$leftMargin = $template->getLeftMargin();
$fontSize = $this->config->get('pdfFontSize') ?? 12;
$headerPosition = $template->getHeaderPosition();
$footerPosition = $template->getFooterPosition();
$titleHtml = '';
if ($template->hasTitle()) {
$title = $this->replacePlaceholders($template->getTitle(), $entity);
$titleHtml = "<title>" . htmlspecialchars($title) . "</title>";
}
$templateStyle = $template->getStyle() ?? '';
/** @noinspection HtmlRequiredTitleElement */
return "
<head>
{$titleHtml}
<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\"/>
</head>
<style>
@page {
margin: {$topMargin}mm {$rightMargin}mm {$bottomMargin}mm {$leftMargin}mm;
}
body {
font-size: {$fontSize}pt;
}
table.bordered, table.bordered td, table.bordered th {
border: 1px solid;
}
> header {
position: fixed;
margin-top: -{$topMargin}mm;
margin-left: -{$rightMargin}mm;
margin-right: -{$leftMargin}mm;
top: {$headerPosition}mm;
left: {$leftMargin}mm;
right: {$rightMargin}mm;
}
> footer {
position: fixed;
margin-bottom: -{$bottomMargin}mm;
margin-left: -{$leftMargin}mm;
margin-right: -{$rightMargin}mm;
height: {$footerPosition}mm;
bottom: 0;
left: {$leftMargin}mm;
right: {$rightMargin}mm;
}
> header .page-number:after,
> footer .page-number:after {
content: counter(page);
}
$templateStyle
</style>
";
}
public function composeHeaderFooter(Template $template, Entity $entity, Params $params, Data $data): string
{
$html = "";
$renderer = $this->templateRendererFactory
->create()
->setApplyAcl($params->applyAcl())
->setEntity($entity)
->setSkipInlineAttachmentHandling()
->setData($data->getAdditionalTemplateData());
if ($template->hasHeader()) {
$htmlHeader = $renderer->renderTemplate($template->getHeader());
$htmlHeader = $this->replaceHeadTags($htmlHeader);
$html .= "<header>$htmlHeader</header>";
}
if ($template->hasFooter()) {
$htmlFooter = $renderer->renderTemplate($template->getFooter());
$htmlFooter = $this->replaceHeadTags($htmlFooter);
$html .= "<footer>$htmlFooter</footer>";
}
return $html;
}
public function composeMain(
Template $template,
Entity $entity,
Params $params,
Data $data
): string {
$renderer = $this->templateRendererFactory
->create()
->setApplyAcl($params->applyAcl())
->setEntity($entity)
->setSkipInlineAttachmentHandling()
->setData($data->getAdditionalTemplateData());
$bodyTemplate = $template->getBody();
$html = $renderer->renderTemplate($bodyTemplate);
$html = $this->replaceTags($html);
return "<main>$html</main>";
}
private function replaceTags(string $html): string
{
/** @noinspection HtmlUnknownAttribute */
$html = str_replace('<br pagebreak="true">', '<div style="page-break-after: always;"></div>', $html);
$html = preg_replace('/src="@([A-Za-z0-9+\/]*={0,2})"/', 'src="data:image/jpeg;base64,$1"', $html);
$html = str_replace('?entryPoint=attachment&amp;', '?entryPoint=attachment&', $html ?? '');
$html = preg_replace_callback(
'/<barcodeimage data="([^"]+)"\/>/',
function ($matches) {
$dataString = $matches[1];
$data = json_decode(urldecode($dataString), true);
return $this->composeBarcode($data);
},
$html
) ?? '';
return preg_replace_callback(
"/src=\"\?entryPoint=attachment&id=([A-Za-z0-9\-]*)\"/",
function ($matches) {
$id = $matches[1];
if (!$id) {
return '';
}
$src = $this->imageSourceProvider->get($id);
if (!$src) {
return '';
}
return "src=\"$src\"";
},
$html
) ?? '';
}
private function replaceHeadTags(string $html): string
{
$html = str_replace('{pageNumber}', '<span class="page-number"></span>', $html);
return $this->replaceTags($html);
}
/**
*
* @param array<string, mixed> $data
* @return string
*/
private function composeBarcode(array $data): string
{
$value = $data['value'] ?? null;
if ($value === null) {
return '';
}
$codeType = $data['type'] ?? 'CODE128';
/** @noinspection SpellCheckingInspection */
$typeMap = [
"CODE128" => 'C128',
"CODE128A" => 'C128A',
"CODE128B" => 'C128B',
"CODE128C" => 'C128C',
"EAN13" => 'EAN13',
"EAN8" => 'EAN8',
"EAN5" => 'EAN5',
"EAN2" => 'EAN2',
"UPC" => 'UPCA',
"UPCE" => 'UPCE',
"ITF14" => 'I25',
"pharmacode" => 'PHARMA',
"QRcode" => 'QRCODE,H',
];
$type = $typeMap[$codeType] ?? null;
/** @noinspection SpellCheckingInspection */
if ($codeType === 'QRcode') {
$width = $data['width'] ?? 40;
$height = $data['height'] ?? 40;
//$color = $data['color'] ?? '#000';
$options = new QROptions();
// @todo Revise and test after updating the lib.
/** @phpstan-ignore-next-line property.protected */
$options->outputType = QROutputInterface::MARKUP_SVG;
/** @phpstan-ignore-next-line property.protected */
$options->eccLevel = EccLevel::H;
$code = (new QRCode($options))->render($value);
$css = "width: {$width}mm; height: {$height}mm;";
/** @noinspection HtmlRequiredAltAttribute */
return "<img src=\"$code\" style=\"$css\">";
}
if (!$type || $type === 'QRCODE,H') {
$this->log->warning("Not supported barcode type $codeType.");
return '';
}
$width = $data['width'] ?? 60;
$height = $data['height'] ?? 30;
$color = $data['color'] ?? '#000';
try {
$code = (new BarcodeGeneratorSVG())->getBarcode($value, $type, 2, $height, $color);
} catch (UnknownTypeException $e) {
throw new RuntimeException("Barcode print error.", previous: $e);
}
$encoded = base64_encode($code);
$css = "width: {$width}mm; height: {$height}mm;";
/** @noinspection HtmlRequiredAltAttribute */
return "<img src=\"data:image/svg+xml;base64,$encoded\" style=\"$css\">";
}
private function replacePlaceholders(string $string, Entity $entity): string
{
$newString = $string;
$attributeList = ['name'];
foreach ($attributeList as $attribute) {
$value = (string) ($entity->get($attribute) ?? '');
$newString = str_replace('{$' . $attribute . '}', $value, $newString);
}
return $newString;
}
}

View File

@@ -0,0 +1,71 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM – Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\Dompdf;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\FileStorage\Manager as FileStorageManager;
use Espo\Entities\Attachment;
use Espo\ORM\EntityManager;
use Espo\Tools\Attachment\Checker;
class ImageSourceProvider
{
public function __construct(
private EntityManager $entityManager,
private Checker $checker,
private FileStorageManager $fileStorageManager,
) {}
public function get(string $id): ?string
{
/** @var Attachment $attachment */
$attachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $id);
if (!$attachment) {
return null;
}
try {
$this->checker->checkTypeImage($attachment);
} catch (Forbidden) {
return null;
}
$type = $attachment->getType();
if (!$type) {
return null;
}
$contents = $this->fileStorageManager->getContents($attachment);
return 'data:' . $type . ';base64,' . base64_encode($contents);
}
}