| 1 |
<?php |
| 2 |
|
| 3 |
declare(strict_types=1); |
| 4 |
|
| 5 |
namespace Yatra\Services; |
| 6 |
|
| 7 |
class PdfService |
| 8 |
{ |
| 9 |
public function isAvailable(): bool |
| 10 |
{ |
| 11 |
return class_exists('Dompdf\\Dompdf') || class_exists('Mpdf\\Mpdf'); |
| 12 |
} |
| 13 |
|
| 14 |
public function renderHtmlToPdf(string $html, array $options = []): string |
| 15 |
{ |
| 16 |
$paper = (string) ($options['paper'] ?? 'A4'); |
| 17 |
$orientation = (string) ($options['orientation'] ?? 'portrait'); |
| 18 |
|
| 19 |
// Resolve the default font with awareness of the site's locale, |
| 20 |
// so a Nepali / Hindi / Arabic / CJK install gets a font that |
| 21 |
// can actually render the script — Dompdf's bundled DejaVu Sans |
| 22 |
// only covers Latin/Greek/Cyrillic, so non-Latin glyphs come |
| 23 |
// out as blank rectangles otherwise. See `resolveDefaultFont()`. |
| 24 |
$defaultFont = (string) ($options['default_font'] ?? $this->resolveDefaultFont()); |
| 25 |
|
| 26 |
if (class_exists('Dompdf\\Dompdf')) { |
| 27 |
$optionsClass = 'Dompdf\\Options'; |
| 28 |
$dompdfClass = 'Dompdf\\Dompdf'; |
| 29 |
|
| 30 |
$dompdfOptions = class_exists($optionsClass) ? new $optionsClass() : null; |
| 31 |
if ($dompdfOptions) { |
| 32 |
// Remote loading is required so PDFs can render the site logo / trip images. |
| 33 |
// Filter exists so site owners can lock it down to the local filesystem if they |
| 34 |
// never use remote images and want to fully eliminate SSRF risk. |
| 35 |
$remoteEnabled = (bool) apply_filters('yatra_pdf_remote_enabled', true); |
| 36 |
$dompdfOptions->set('isRemoteEnabled', $remoteEnabled); |
| 37 |
$dompdfOptions->set('isHtml5ParserEnabled', true); |
| 38 |
$dompdfOptions->set('defaultFont', $defaultFont); |
| 39 |
|
| 40 |
// Enable `<script type="text/php">` blocks in PDF |
| 41 |
// templates. Required so the itinerary template can |
| 42 |
// draw a per-page header that's skipped on page 1 |
| 43 |
// (Dompdf's CSS `position: fixed` runs unconditionally |
| 44 |
// on every page; a PHP canvas script is the only way |
| 45 |
// to gate by `$PAGE_NUM`). The PHP runs in Dompdf's |
| 46 |
// sandbox with access to the $pdf, $fontMetrics, |
| 47 |
// $PAGE_NUM and $PAGE_COUNT variables only — the |
| 48 |
// template files we ship are plugin-controlled so |
| 49 |
// this isn't an attack surface like user-supplied |
| 50 |
// HTML would be. |
| 51 |
$dompdfOptions->set('isPhpEnabled', true); |
| 52 |
|
| 53 |
// Move the font cache out of the plugin's vendor/ tree |
| 54 |
// (which is normally read-only on managed hosts and gets |
| 55 |
// wiped by composer install). Using |
| 56 |
// wp-content/uploads/yatra-pdf-fonts/cache/ keeps the |
| 57 |
// generated .ufm metric files persistent across deploys |
| 58 |
// AND ensures Dompdf can actually write — without write |
| 59 |
// access registerFont() silently no-ops, the Devanagari |
| 60 |
// font never gets installed, and Nepali glyphs render |
| 61 |
// as missing-glyph rectangles. |
| 62 |
$cacheDir = $this->ensureWritableFontCacheDir(); |
| 63 |
if ($cacheDir) { |
| 64 |
$dompdfOptions->set('fontDir', $cacheDir); |
| 65 |
$dompdfOptions->set('fontCache', $cacheDir); |
| 66 |
} |
| 67 |
|
| 68 |
// Restrict file:// reads to within ABSPATH so a crafted template can't read /etc/passwd |
| 69 |
// or other files outside the WordPress install. |
| 70 |
if (defined('ABSPATH')) { |
| 71 |
$dompdfOptions->set('chroot', [ABSPATH]); |
| 72 |
} |
| 73 |
|
| 74 |
// Block file:// and php:// protocols from remote requests |
| 75 |
// (only http/https for network fetches). The empty-string |
| 76 |
// key whitelists LOCAL file PATHS (no URL scheme) — |
| 77 |
// Dompdf's Helpers::explode_url() returns "" as the |
| 78 |
// protocol for a bare absolute path. Without that key, |
| 79 |
// FontMetrics::registerFont() silently rejects every |
| 80 |
// local TTF we pass to it, which is exactly why the |
| 81 |
// bundled Noto Sans Devanagari fonts never installed and |
| 82 |
// Nepali text fell back to DejaVu missing-glyph |
| 83 |
// rectangles. file paths are NOT the same as `file://` |
| 84 |
// URLs and don't expose any SSRF surface. |
| 85 |
$dompdfOptions->set('allowedProtocols', [ |
| 86 |
'' => ['rules' => []], |
| 87 |
'http://' => ['rules' => []], |
| 88 |
'https://' => ['rules' => []], |
| 89 |
]); |
| 90 |
|
| 91 |
// SSRF hardening for remote fetches: short timeout + identifying User-Agent so admins |
| 92 |
// can spot dompdf traffic in logs. Does not stop SSRF on its own — sites that do not |
| 93 |
// need remote images should disable via the `yatra_pdf_remote_enabled` filter above. |
| 94 |
if ($remoteEnabled) { |
| 95 |
$httpContext = stream_context_create([ |
| 96 |
'http' => [ |
| 97 |
'timeout' => 5, |
| 98 |
'follow_location' => 0, |
| 99 |
'user_agent' => 'YatraPDF/' . (defined('YATRA_VERSION') ? YATRA_VERSION : '1.0'), |
| 100 |
], |
| 101 |
'ssl' => [ |
| 102 |
'verify_peer' => true, |
| 103 |
'verify_peer_name' => true, |
| 104 |
], |
| 105 |
]); |
| 106 |
$dompdfOptions->setHttpContext($httpContext); |
| 107 |
} |
| 108 |
} |
| 109 |
|
| 110 |
$dompdf = $dompdfOptions ? new $dompdfClass($dompdfOptions) : new $dompdfClass(); |
| 111 |
|
| 112 |
// Register any user-supplied Unicode fonts BEFORE loadHtml so |
| 113 |
// CSS `font-family` references resolve correctly. |
| 114 |
$this->registerExtraFonts($dompdf); |
| 115 |
|
| 116 |
$dompdf->loadHtml($html, 'UTF-8'); |
| 117 |
$dompdf->setPaper($paper, $orientation); |
| 118 |
$dompdf->render(); |
| 119 |
return (string) $dompdf->output(); |
| 120 |
} |
| 121 |
|
| 122 |
if (class_exists('Mpdf\\Mpdf')) { |
| 123 |
$mpdfClass = 'Mpdf\\Mpdf'; |
| 124 |
$mpdf = new $mpdfClass(['format' => $paper]); |
| 125 |
$mpdf->WriteHTML($html); |
| 126 |
return (string) $mpdf->Output('', 'S'); |
| 127 |
} |
| 128 |
|
| 129 |
throw new \RuntimeException('PDF engine is not available'); |
| 130 |
} |
| 131 |
|
| 132 |
/** |
| 133 |
* Decide which font family to hand to Dompdf as `defaultFont` based |
| 134 |
* on the current WordPress locale. Dompdf's only bundled Unicode |
| 135 |
* font is DejaVu Sans, which covers Latin/Greek/Cyrillic; for other |
| 136 |
* scripts the site has to ship its own TTF (see |
| 137 |
* `registerExtraFonts()`). |
| 138 |
* |
| 139 |
* The mapping intentionally aims at FAMILIES the typical |
| 140 |
* `yatra_pdf_extra_fonts` install would expose (Noto Sans <script> |
| 141 |
* is the canonical pick), so when an admin drops the matching font |
| 142 |
* file into the fonts dir the chain "just works". The CSS in the |
| 143 |
* PDF templates ALSO carries these names as fallbacks, so |
| 144 |
* Dompdf's per-glyph font picker can pick whichever family is |
| 145 |
* actually registered. |
| 146 |
* |
| 147 |
* Filter `yatra_pdf_default_font` lets a site override the |
| 148 |
* resolved family entirely. |
| 149 |
*/ |
| 150 |
private function resolveDefaultFont(): string |
| 151 |
{ |
| 152 |
$locale = function_exists('determine_locale') ? determine_locale() : (function_exists('get_locale') ? get_locale() : 'en_US'); |
| 153 |
$lang = strtolower(substr((string) $locale, 0, 2)); |
| 154 |
|
| 155 |
$devanagari = ['ne', 'hi', 'mr', 'sa']; // Nepali, Hindi, Marathi, Sanskrit |
| 156 |
$arabic = ['ar', 'fa', 'ur']; |
| 157 |
$cjk = ['zh', 'ja', 'ko']; |
| 158 |
|
| 159 |
if (in_array($lang, $devanagari, true)) { |
| 160 |
$font = 'Noto Sans Devanagari'; |
| 161 |
} elseif (in_array($lang, $arabic, true)) { |
| 162 |
$font = 'Noto Sans Arabic'; |
| 163 |
} elseif (in_array($lang, $cjk, true)) { |
| 164 |
$font = 'Noto Sans CJK'; |
| 165 |
} else { |
| 166 |
$font = 'DejaVu Sans'; |
| 167 |
} |
| 168 |
|
| 169 |
return (string) apply_filters('yatra_pdf_default_font', $font, $locale); |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* Parse a TTF/OTF basename into (cssFamily, style). |
| 174 |
* |
| 175 |
* Examples: |
| 176 |
* "NotoSansDevanagari-Regular" → ("Noto Sans Devanagari", "normal") |
| 177 |
* "NotoSansDevanagari-Bold" → ("Noto Sans Devanagari", "bold") |
| 178 |
* "NotoSansArabic-Italic" → ("Noto Sans Arabic", "italic") |
| 179 |
* "NotoSansCJK-BoldItalic" → ("Noto Sans CJK", "bold_italic") |
| 180 |
* "Roboto" → ("Roboto", "normal") |
| 181 |
* "Noto Sans Devanagari-Bold" → ("Noto Sans Devanagari", "bold") (file already has spaces) |
| 182 |
* |
| 183 |
* Returns `["", ""]` when the basename can't yield a sensible family. |
| 184 |
* |
| 185 |
* The CSS family is derived by: |
| 186 |
* 1. Stripping any recognised weight/style suffix (Regular, Bold, |
| 187 |
* Italic, BoldItalic, Oblique, BoldOblique). |
| 188 |
* 2. Replacing underscores with spaces. |
| 189 |
* 3. If the result is PascalCase with no separators, inserting a |
| 190 |
* space before each interior capital letter — so the bundled |
| 191 |
* `NotoSansDevanagari` file matches the CSS `"Noto Sans |
| 192 |
* Devanagari"` declaration in the templates. |
| 193 |
* |
| 194 |
* @return array{0:string,1:string} |
| 195 |
*/ |
| 196 |
private function parseFontFilename(string $base): array |
| 197 |
{ |
| 198 |
$style = 'normal'; |
| 199 |
$family = $base; |
| 200 |
|
| 201 |
if (preg_match('/^(.+?)[-_](Regular|Bold(?:Italic|Oblique)?|Italic|Oblique)$/i', $base, $m)) { |
| 202 |
$family = $m[1]; |
| 203 |
$variant = strtolower($m[2]); |
| 204 |
if ($variant === 'bolditalic' || $variant === 'boldoblique') { |
| 205 |
$style = 'bold_italic'; |
| 206 |
} elseif ($variant === 'bold') { |
| 207 |
$style = 'bold'; |
| 208 |
} elseif ($variant === 'italic' || $variant === 'oblique') { |
| 209 |
$style = 'italic'; |
| 210 |
} else { |
| 211 |
// Regular / unknown → normal weight. |
| 212 |
$style = 'normal'; |
| 213 |
} |
| 214 |
} |
| 215 |
|
| 216 |
// Normalise underscores → spaces first so a file already named |
| 217 |
// "Noto_Sans_Devanagari-Bold.ttf" lands as "Noto Sans Devanagari". |
| 218 |
$cssFamily = str_replace('_', ' ', $family); |
| 219 |
|
| 220 |
// PascalCase → spaced (insert a space between a lowercase and the |
| 221 |
// next uppercase, and between two uppercases followed by a |
| 222 |
// lowercase — preserves "CJK" inside "Noto Sans CJK"). |
| 223 |
if (strpos($cssFamily, ' ') === false) { |
| 224 |
$cssFamily = preg_replace('/([a-z])([A-Z])/', '$1 $2', $cssFamily); |
| 225 |
$cssFamily = preg_replace('/([A-Z]+)([A-Z][a-z])/', '$1 $2', (string) $cssFamily); |
| 226 |
} |
| 227 |
|
| 228 |
$cssFamily = trim((string) $cssFamily); |
| 229 |
|
| 230 |
return [$cssFamily, $style]; |
| 231 |
} |
| 232 |
|
| 233 |
/** |
| 234 |
* Resolve a writable font cache directory under wp-content/uploads/ |
| 235 |
* — required so Dompdf's `registerFont()` can write the generated |
| 236 |
* `.ufm` metric files and the copied TTF. Returns the absolute path |
| 237 |
* on success, or an empty string when uploads aren't writable (in |
| 238 |
* which case the caller falls back to Dompdf's vendor cache). |
| 239 |
*/ |
| 240 |
private function ensureWritableFontCacheDir(): string |
| 241 |
{ |
| 242 |
$uploads = function_exists('wp_get_upload_dir') ? wp_get_upload_dir() : null; |
| 243 |
if (!$uploads || empty($uploads['basedir'])) { |
| 244 |
return ''; |
| 245 |
} |
| 246 |
$dir = rtrim((string) $uploads['basedir'], '/\\') . '/yatra-pdf-fonts/cache'; |
| 247 |
if (!is_dir($dir)) { |
| 248 |
if (function_exists('wp_mkdir_p')) { |
| 249 |
wp_mkdir_p($dir); |
| 250 |
} else { |
| 251 |
@mkdir($dir, 0755, true); |
| 252 |
} |
| 253 |
} |
| 254 |
if (!is_dir($dir) || !is_writable($dir)) { |
| 255 |
return ''; |
| 256 |
} |
| 257 |
|
| 258 |
// One-time cache invalidation. Tied to the plugin version so it |
| 259 |
// re-runs whenever the bundled fonts or the registerFont logic |
| 260 |
// change — for example the `allowedProtocols` fix that finally |
| 261 |
// lets registerFont() write font cache for local TTFs: |
| 262 |
// installations that rendered a PDF BEFORE that fix have a |
| 263 |
// partial `installed-fonts.json` recording the failed bold |
| 264 |
// entry, and Dompdf's runtime cache trusts that file even when |
| 265 |
// the matching .ufm is missing. Wiping the dir once per |
| 266 |
// version forces a fresh build with the corrected logic. |
| 267 |
$version = defined('YATRA_VERSION') ? (string) constant('YATRA_VERSION') : '1.0.0'; |
| 268 |
$stampFile = $dir . '/.yatra-font-cache-version'; |
| 269 |
$currentStamp = is_file($stampFile) ? (string) @file_get_contents($stampFile) : ''; |
| 270 |
if ($currentStamp !== $version) { |
| 271 |
foreach ((array) glob($dir . '/*') as $file) { |
| 272 |
if (is_file($file)) { |
| 273 |
@unlink($file); |
| 274 |
} |
| 275 |
} |
| 276 |
@file_put_contents($stampFile, $version); |
| 277 |
} |
| 278 |
|
| 279 |
return $dir; |
| 280 |
} |
| 281 |
|
| 282 |
/** |
| 283 |
* Register any TTF/OTF the site has dropped into the Yatra PDF |
| 284 |
* fonts dir with Dompdf's `FontMetrics`. Without this Dompdf has |
| 285 |
* no idea those files exist — even with a matching CSS |
| 286 |
* `font-family`, glyphs from the file would never reach the PDF. |
| 287 |
* |
| 288 |
* Default font dir is `wp-content/uploads/yatra-pdf-fonts/` |
| 289 |
* (filter `yatra_pdf_fonts_dir`), so admins can add fonts without |
| 290 |
* touching plugin code. Any TTF/OTF whose filename matches |
| 291 |
* `<Family>[-<Style>].ttf` is registered as that family/style — |
| 292 |
* style suffix is one of `Regular`, `Bold`, `Italic`, |
| 293 |
* `BoldItalic`. Unknown suffixes default to `normal`. |
| 294 |
* |
| 295 |
* The filter `yatra_pdf_extra_fonts` lets code register fonts |
| 296 |
* imperatively too, returning an array of |
| 297 |
* [ family => [ 'normal' => '/abs/path.ttf', 'bold' => ... ] ] |
| 298 |
*/ |
| 299 |
private function registerExtraFonts($dompdf): void |
| 300 |
{ |
| 301 |
$metrics = method_exists($dompdf, 'getFontMetrics') ? $dompdf->getFontMetrics() : null; |
| 302 |
if (!$metrics) { |
| 303 |
return; |
| 304 |
} |
| 305 |
|
| 306 |
// Scan order: plugin-bundled fonts (always present) first, then |
| 307 |
// the user-overridable uploads dir. Later registrations win so |
| 308 |
// a site can drop in a higher-quality Devanagari font in |
| 309 |
// uploads/yatra-pdf-fonts/ to override the bundled Noto Sans. |
| 310 |
$dirs = []; |
| 311 |
if (defined('YATRA_PLUGIN_PATH')) { |
| 312 |
$bundled = rtrim((string) YATRA_PLUGIN_PATH, '/\\') . '/assets/pdf-fonts'; |
| 313 |
if (is_dir($bundled)) { |
| 314 |
$dirs[] = $bundled; |
| 315 |
} |
| 316 |
} |
| 317 |
$uploads = function_exists('wp_get_upload_dir') ? wp_get_upload_dir() : null; |
| 318 |
if ($uploads && !empty($uploads['basedir'])) { |
| 319 |
$userDir = rtrim((string) $uploads['basedir'], '/\\') . '/yatra-pdf-fonts'; |
| 320 |
$userDir = (string) apply_filters('yatra_pdf_fonts_dir', $userDir); |
| 321 |
if ($userDir && is_dir($userDir)) { |
| 322 |
$dirs[] = $userDir; |
| 323 |
} |
| 324 |
} |
| 325 |
|
| 326 |
$fonts = []; |
| 327 |
|
| 328 |
foreach ($dirs as $dir) { |
| 329 |
foreach ((array) glob($dir . '/*.{ttf,TTF,otf,OTF}', GLOB_BRACE) as $file) { |
| 330 |
$base = pathinfo((string) $file, PATHINFO_FILENAME); |
| 331 |
[$cssFamily, $style] = $this->parseFontFilename($base); |
| 332 |
if ($cssFamily === '') { |
| 333 |
continue; |
| 334 |
} |
| 335 |
if (!isset($fonts[$cssFamily])) { |
| 336 |
$fonts[$cssFamily] = []; |
| 337 |
} |
| 338 |
$fonts[$cssFamily][$style] = $file; |
| 339 |
} |
| 340 |
} |
| 341 |
|
| 342 |
$fonts = (array) apply_filters('yatra_pdf_extra_fonts', $fonts); |
| 343 |
|
| 344 |
// Map our internal style keys to Dompdf's |
| 345 |
// ['weight' => …, 'style' => …] tuple, since registerFont() |
| 346 |
// uses CSS-shaped style arrays (weight = normal|bold, |
| 347 |
// style = normal|italic) and not the four-key family entries. |
| 348 |
$styleMatrix = [ |
| 349 |
'normal' => ['weight' => 'normal', 'style' => 'normal'], |
| 350 |
'bold' => ['weight' => 'bold', 'style' => 'normal'], |
| 351 |
'italic' => ['weight' => 'normal', 'style' => 'italic'], |
| 352 |
'bold_italic' => ['weight' => 'bold', 'style' => 'italic'], |
| 353 |
]; |
| 354 |
|
| 355 |
foreach ($fonts as $family => $styles) { |
| 356 |
if (!is_array($styles)) { |
| 357 |
continue; |
| 358 |
} |
| 359 |
// Fill missing styles with the regular variant so Dompdf |
| 360 |
// doesn't fall back to its own default on bold/italic. |
| 361 |
$normal = $styles['normal'] ?? reset($styles); |
| 362 |
if (!$normal) { |
| 363 |
continue; |
| 364 |
} |
| 365 |
foreach (array_keys($styleMatrix) as $style) { |
| 366 |
if (empty($styles[$style])) { |
| 367 |
$styles[$style] = $normal; |
| 368 |
} |
| 369 |
} |
| 370 |
|
| 371 |
foreach ($styleMatrix as $key => $css) { |
| 372 |
$file = $styles[$key] ?? null; |
| 373 |
if (!$file || !is_file($file)) { |
| 374 |
continue; |
| 375 |
} |
| 376 |
try { |
| 377 |
// registerFont downloads (or here, copies from a |
| 378 |
// local path) the TTF into Dompdf's font dir, then |
| 379 |
// generates the .ufm metrics file and wires the |
| 380 |
// family/weight/style triple into the FontMetrics |
| 381 |
// lookup table — all in one call. Passing a local |
| 382 |
// path works because Helpers::getFileContent() just |
| 383 |
// file_get_contents()s it. |
| 384 |
$metrics->registerFont( |
| 385 |
[ |
| 386 |
'family' => (string) $family, |
| 387 |
'weight' => $css['weight'], |
| 388 |
'style' => $css['style'], |
| 389 |
], |
| 390 |
$file |
| 391 |
); |
| 392 |
} catch (\Throwable $e) { |
| 393 |
// Don't take the whole PDF render down for one bad font. |
| 394 |
error_log('[Yatra] Failed to register PDF font ' . $family . ' (' . $key . '): ' . $e->getMessage()); |
| 395 |
} |
| 396 |
} |
| 397 |
} |
| 398 |
} |
| 399 |
|
| 400 |
public function renderHtmlToPdfSafely(string $html, array $options = []): string |
| 401 |
{ |
| 402 |
$originalErrorReporting = error_reporting(); |
| 403 |
$originalDisplayErrors = ini_get('display_errors'); |
| 404 |
$originalHtmlErrors = ini_get('html_errors'); |
| 405 |
$startObLevel = ob_get_level(); |
| 406 |
|
| 407 |
ini_set('display_errors', '0'); |
| 408 |
ini_set('html_errors', '0'); |
| 409 |
error_reporting($originalErrorReporting & ~E_DEPRECATED & ~E_USER_DEPRECATED); |
| 410 |
ob_start(); |
| 411 |
|
| 412 |
$previousErrorHandler = set_error_handler(static function (int $errno) { |
| 413 |
if ($errno === E_DEPRECATED || $errno === E_USER_DEPRECATED) { |
| 414 |
return true; |
| 415 |
} |
| 416 |
return false; |
| 417 |
}); |
| 418 |
|
| 419 |
try { |
| 420 |
return $this->renderHtmlToPdf($html, $options); |
| 421 |
} finally { |
| 422 |
if ($previousErrorHandler !== null) { |
| 423 |
restore_error_handler(); |
| 424 |
} |
| 425 |
|
| 426 |
while (ob_get_level() > $startObLevel) { |
| 427 |
ob_end_clean(); |
| 428 |
} |
| 429 |
|
| 430 |
error_reporting($originalErrorReporting); |
| 431 |
if ($originalDisplayErrors !== false) { |
| 432 |
ini_set('display_errors', (string) $originalDisplayErrors); |
| 433 |
} |
| 434 |
if ($originalHtmlErrors !== false) { |
| 435 |
ini_set('html_errors', (string) $originalHtmlErrors); |
| 436 |
} |
| 437 |
} |
| 438 |
} |
| 439 |
|
| 440 |
public function renderTemplate(string $templatePath, array $data = []): string |
| 441 |
{ |
| 442 |
$templateFile = YATRA_PLUGIN_PATH . 'templates/' . $templatePath; |
| 443 |
|
| 444 |
if (!file_exists($templateFile)) { |
| 445 |
throw new \InvalidArgumentException("Template file not found: {$templateFile}"); |
| 446 |
} |
| 447 |
|
| 448 |
// Defence in depth: only extract string-keyed entries with valid PHP-identifier names, |
| 449 |
// and skip keys that would clobber locals already in scope (EXTR_SKIP). Stops a malicious |
| 450 |
// $data array from injecting arbitrary local variables into the template scope. |
| 451 |
$safeData = []; |
| 452 |
foreach ($data as $key => $value) { |
| 453 |
if (is_string($key) && preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $key)) { |
| 454 |
$safeData[$key] = $value; |
| 455 |
} |
| 456 |
} |
| 457 |
extract($safeData, EXTR_SKIP); |
| 458 |
|
| 459 |
// Capture output |
| 460 |
ob_start(); |
| 461 |
try { |
| 462 |
include $templateFile; |
| 463 |
return ob_get_clean(); |
| 464 |
} catch (\Throwable $e) { |
| 465 |
ob_end_clean(); |
| 466 |
throw $e; |
| 467 |
} |
| 468 |
} |
| 469 |
|
| 470 |
public function renderTemplateToPdf(string $templatePath, array $data = [], array $options = []): string |
| 471 |
{ |
| 472 |
$html = $this->renderTemplate($templatePath, $data); |
| 473 |
return $this->renderHtmlToPdf($html, $options); |
| 474 |
} |
| 475 |
|
| 476 |
public function renderTemplateToPdfSafely(string $templatePath, array $data = [], array $options = []): string |
| 477 |
{ |
| 478 |
$html = $this->renderTemplate($templatePath, $data); |
| 479 |
return $this->renderHtmlToPdfSafely($html, $options); |
| 480 |
} |
| 481 |
|
| 482 |
public function outputPdfDownload(string $pdfBinary, string $filename, bool $inline = false): void |
| 483 |
{ |
| 484 |
// Strip CR/LF (header injection) and any quotes/backslashes from the ASCII fallback name. |
| 485 |
// Keep an RFC 5987 UTF-8 form so non-ASCII filenames still display correctly in modern browsers. |
| 486 |
$cleanFilename = preg_replace('/[\r\n"\\\\]/', '', $filename) ?? ''; |
| 487 |
if ($cleanFilename === '') { |
| 488 |
$cleanFilename = 'document.pdf'; |
| 489 |
} |
| 490 |
$asciiFilename = preg_replace('/[^\x20-\x7E]/', '_', $cleanFilename) ?? 'document.pdf'; |
| 491 |
$encodedFilename = rawurlencode($cleanFilename); |
| 492 |
|
| 493 |
header('Content-Type: application/pdf'); |
| 494 |
header( |
| 495 |
'Content-Disposition: ' . ($inline ? 'inline' : 'attachment') |
| 496 |
. '; filename="' . $asciiFilename . '"' |
| 497 |
. "; filename*=UTF-8''" . $encodedFilename |
| 498 |
); |
| 499 |
header('Cache-Control: no-cache, no-store, must-revalidate'); |
| 500 |
header('Pragma: no-cache'); |
| 501 |
header('Expires: 0'); |
| 502 |
|
| 503 |
echo $pdfBinary; |
| 504 |
exit; |
| 505 |
} |
| 506 |
} |
| 507 |
|