| @@ -14,10 +14,16 @@ | ||
| 14 | 14 | public function renderHtmlToPdf(string $html, array $options = []): string |
| 15 | 15 | { |
| 16 | 16 | $paper = (string) ($options['paper'] ?? 'A4'); |
| 17 | 17 | $orientation = (string) ($options['orientation'] ?? 'portrait'); |
| 18 | - $defaultFont = (string) ($options['default_font'] ?? 'DejaVu Sans'); | |
| 19 | 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 | + | |
| 20 | 26 | if (class_exists('Dompdf\\Dompdf')) { |
| 21 | 27 | $optionsClass = 'Dompdf\\Options'; |
| 22 | 28 | $dompdfClass = 'Dompdf\\Dompdf'; |
| 23 | 29 | |
| @@ -30,8 +36,36 @@ | ||
| 30 | 36 | $dompdfOptions->set('isRemoteEnabled', $remoteEnabled); |
| 31 | 37 | $dompdfOptions->set('isHtml5ParserEnabled', true); |
| 32 | 38 | $dompdfOptions->set('defaultFont', $defaultFont); |
| 33 | 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 | + | |
| 34 | 68 | // Restrict file:// reads to within ABSPATH so a crafted template can't read /etc/passwd |
| 35 | 69 | // or other files outside the WordPress install. |
| 36 | 70 | if (defined('ABSPATH')) { |
| 37 | 71 | $dompdfOptions->set('chroot', [ABSPATH]); |
| @@ -36,10 +70,21 @@ | ||
| 36 | 70 | if (defined('ABSPATH')) { |
| 37 | 71 | $dompdfOptions->set('chroot', [ABSPATH]); |
| 38 | 72 | } |
| 39 | 73 | |
| 40 | - // Block file:// and php:// protocols from remote requests (only http/https allowed). | |
| 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. | |
| 41 | 85 | $dompdfOptions->set('allowedProtocols', [ |
| 86 | + '' => ['rules' => []], | |
| 42 | 87 | 'http://' => ['rules' => []], |
| 43 | 88 | 'https://' => ['rules' => []], |
| 44 | 89 | ]); |
| 45 | 90 | |
| @@ -62,8 +107,13 @@ | ||
| 62 | 107 | } |
| 63 | 108 | } |
| 64 | 109 | |
| 65 | 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 | + | |
| 66 | 116 | $dompdf->loadHtml($html, 'UTF-8'); |
| 67 | 117 | $dompdf->setPaper($paper, $orientation); |
| 68 | 118 | $dompdf->render(); |
| 69 | 119 | return (string) $dompdf->output(); |
| @@ -76,8 +126,276 @@ | ||
| 76 | 126 | return (string) $mpdf->Output('', 'S'); |
| 77 | 127 | } |
| 78 | 128 | |
| 79 | 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 | + } | |
| 80 | 398 | } |
| 81 | 399 | |
| 82 | 400 | public function renderHtmlToPdfSafely(string $html, array $options = []): string |
| 83 | 401 | { |