From becf842766811175b2c61bece1043b905f25c038 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:08:10 -0500 Subject: [PATCH 1/2] Refactor: Extract single-page certificate PDF rendering into reusable PDF method generatePdfFromCustomCertificate() instantiated mPDF directly to render the single-page certificate with zero margins and mirrorMargins=0, bypassing the PDF class because its constructor hard-codes margin_header/footer=8 and format_pdf() forces mirrorMargins=1 (book layout), which would insert blank pages around the certificate. Move that single-page rendering into a new reusable PDF::singlePageHtmlToPdfDownload() method that keeps the exact same mPDF configuration (zero margins, mirrorMargins=0, A4/A4-L by orientation) and routes remote asset fetches through SafeMpdfHttpClient, so the SSRF guard now lives in one place (pdf.lib.php) instead of being duplicated at each direct mPDF call site. No change to the rendered output. Co-Authored-By: Claude Opus 4.8 (1M context) --- public/main/inc/lib/certificate.lib.php | 41 ++++++------------------ public/main/inc/lib/pdf.lib.php | 42 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/public/main/inc/lib/certificate.lib.php b/public/main/inc/lib/certificate.lib.php index 4a0502d0604..172b3ef3878 100644 --- a/public/main/inc/lib/certificate.lib.php +++ b/public/main/inc/lib/certificate.lib.php @@ -2,7 +2,6 @@ /* For licensing terms, see /license.txt */ -use Chamilo\CoreBundle\Component\Mpdf\SafeMpdfHttpClient; use Chamilo\CoreBundle\Entity\GradebookCategory; use Chamilo\CoreBundle\Entity\PersonalFile; use Chamilo\CoreBundle\Entity\ResourceFile; @@ -1007,32 +1006,7 @@ public function generateCustomCertificate(string $fileName = ''): string public function generatePdfFromCustomCertificate(): void { $orientation = api_get_setting('certificate.certificate_pdf_orientation'); - - $pdfOrientation = 'landscape'; - if (!empty($orientation)) { - $pdfOrientation = $orientation; - } - - $pageFormat = 'landscape' === $pdfOrientation ? 'A4-L' : 'A4'; - - // Instanciate mPDF directly to avoid blank pages generated by the default - // PDF class: format_pdf() sets mirrorMargins=1 (book layout) and the - // constructor hard-codes margin_header=8 / margin_footer=8 even when - // headers and footers are empty, which causes mPDF to insert blank - // odd/even pages around the single certificate page. - $mpdf = new \Mpdf\Mpdf([ - 'tempDir' => Container::getCacheDir(), - 'mode' => 'utf-8', - 'format' => $pageFormat, - 'orientation' => $pdfOrientation, - 'margin_left' => 0, - 'margin_right' => 0, - 'margin_top' => 0, - 'margin_bottom' => 0, - 'margin_header' => 0, - 'margin_footer' => 0, - ], SafeMpdfHttpClient::container()); - $mpdf->mirrorMargins = 0; + $pdfOrientation = !empty($orientation) ? $orientation : 'landscape'; // Safety: ensure HTML content is present; fetch from Resource if needed. if (empty($this->certificate_data['file_content'])) { @@ -1048,11 +1022,14 @@ public function generatePdfFromCustomCertificate(): void } } - @$mpdf->WriteHTML((string) $this->certificate_data['file_content']); - - $pdfName = api_replace_dangerous_char(get_lang('Certificates')); - $mpdf->Output($pdfName.'.pdf', \Mpdf\Output\Destination::DOWNLOAD); - exit; + // Single-page render with SSRF-guarded mPDF lives in the PDF class; it + // skips format_pdf()'s book layout to avoid blank pages around the + // single certificate page. + PDF::singlePageHtmlToPdfDownload( + (string) $this->certificate_data['file_content'], + get_lang('Certificates'), + $pdfOrientation + ); } /** diff --git a/public/main/inc/lib/pdf.lib.php b/public/main/inc/lib/pdf.lib.php index ae784246c10..6091324a40e 100644 --- a/public/main/inc/lib/pdf.lib.php +++ b/public/main/inc/lib/pdf.lib.php @@ -538,6 +538,48 @@ public function content_to_pdf( return $output_file; } + /** + * Renders single-page HTML (e.g. a certificate) to a downloadable PDF. + * + * Unlike content_to_pdf()/html_to_pdf_with_template(), this method does NOT + * call format_pdf(): it deliberately skips the book-layout decoration + * (mirrorMargins, header/footer margins) that would otherwise insert blank + * pages around a single-page document. Remote asset fetches are routed + * through SafeMpdfHttpClient to prevent SSRF. + * + * @param string $html HTML content to render + * @param string $fileName Output file name (without extension) + * @param string $orientation 'landscape' or 'portrait' + * + * @throws MpdfException + */ + public static function singlePageHtmlToPdfDownload( + string $html, + string $fileName, + string $orientation = 'landscape' + ): void { + $pageFormat = 'landscape' === $orientation ? 'A4-L' : 'A4'; + + $mpdf = new Mpdf([ + 'tempDir' => Container::getCacheDir(), + 'mode' => 'utf-8', + 'format' => $pageFormat, + 'orientation' => $orientation, + 'margin_left' => 0, + 'margin_right' => 0, + 'margin_top' => 0, + 'margin_bottom' => 0, + 'margin_header' => 0, + 'margin_footer' => 0, + ], SafeMpdfHttpClient::container()); + $mpdf->mirrorMargins = 0; + + @$mpdf->WriteHTML($html); + + $mpdf->Output(api_replace_dangerous_char($fileName).'.pdf', Destination::DOWNLOAD); + exit; + } + /** * Gets the watermark from the platform or a course. * From 7c46d405da7ff8db14007b93081bafdef9bfc55c Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:27:53 -0500 Subject: [PATCH 2/2] Refactor: Reuse PDF class mPDF instance for single-page rendering Instead of instantiating a second mPDF object inside singlePageHtmlToPdfDownload(), build a PDF instance via the constructor and reuse its $this->pdf. This leaves a single 'new Mpdf' call (and a single SafeMpdfHttpClient guard) in the whole class. To allow the zero-margin certificate layout through the constructor, the constructor now honors 'margin_header'/'margin_footer' from $params instead of hard-coding both to 8. Both still default to 8, so callers that don't pass them are unaffected. Behavior change: lp_tracking.php builds 'new PDF('A4', 'P', ['margin_footer' => 4, ...])'; that value was previously discarded (footer rendered at 8mm) and now takes effect (4mm), matching the caller's original intent. Co-Authored-By: Claude Opus 4.8 (1M context) --- public/main/inc/lib/pdf.lib.php | 37 +++++++++++++++------------------ 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/public/main/inc/lib/pdf.lib.php b/public/main/inc/lib/pdf.lib.php index 6091324a40e..d150015543c 100644 --- a/public/main/inc/lib/pdf.lib.php +++ b/public/main/inc/lib/pdf.lib.php @@ -52,6 +52,7 @@ public function __construct( $params['right'] = $params['right'] ?? 15; $params['top'] = $params['top'] ?? 30; $params['bottom'] = $params['bottom'] ?? 30; + $params['margin_header'] = $params['margin_header'] ?? 8; $params['margin_footer'] = $params['margin_footer'] ?? 8; $this->params['filename'] = $params['filename'] ?? api_get_local_time(); @@ -77,8 +78,8 @@ public function __construct( 'margin_right' => $params['right'], 'margin_top' => $params['top'], 'margin_bottom' => $params['bottom'], - 'margin_header' => 8, - 'margin_footer' => 8, + 'margin_header' => $params['margin_header'], + 'margin_footer' => $params['margin_footer'], ]; // Default value is 96 set in the mpdf library file config.php @@ -541,11 +542,10 @@ public function content_to_pdf( /** * Renders single-page HTML (e.g. a certificate) to a downloadable PDF. * - * Unlike content_to_pdf()/html_to_pdf_with_template(), this method does NOT - * call format_pdf(): it deliberately skips the book-layout decoration - * (mirrorMargins, header/footer margins) that would otherwise insert blank - * pages around a single-page document. Remote asset fetches are routed - * through SafeMpdfHttpClient to prevent SSRF. + * Reuses the SSRF-guarded mPDF instance built by the constructor, with all + * margins set to 0 and without calling format_pdf(): this deliberately + * skips the book-layout decoration (mirrorMargins, header/footer margins) + * that would otherwise insert blank pages around a single-page document. * * @param string $html HTML content to render * @param string $fileName Output file name (without extension) @@ -559,24 +559,21 @@ public static function singlePageHtmlToPdfDownload( string $orientation = 'landscape' ): void { $pageFormat = 'landscape' === $orientation ? 'A4-L' : 'A4'; + $mpdfOrientation = 'landscape' === $orientation ? 'L' : 'P'; - $mpdf = new Mpdf([ - 'tempDir' => Container::getCacheDir(), - 'mode' => 'utf-8', - 'format' => $pageFormat, - 'orientation' => $orientation, - 'margin_left' => 0, - 'margin_right' => 0, - 'margin_top' => 0, - 'margin_bottom' => 0, + $pdf = new self($pageFormat, $mpdfOrientation, [ + 'left' => 0, + 'right' => 0, + 'top' => 0, + 'bottom' => 0, 'margin_header' => 0, 'margin_footer' => 0, - ], SafeMpdfHttpClient::container()); - $mpdf->mirrorMargins = 0; + ]); + $pdf->pdf->mirrorMargins = 0; - @$mpdf->WriteHTML($html); + @$pdf->pdf->WriteHTML($html); - $mpdf->Output(api_replace_dangerous_char($fileName).'.pdf', Destination::DOWNLOAD); + $pdf->pdf->Output(api_replace_dangerous_char($fileName).'.pdf', Destination::DOWNLOAD); exit; }