| @@ -1,8 +1,19 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | 3 | $companyName = (string) ($company_name ?? ''); |
| 4 | 4 | $companyAddress = (string) ($company_address ?? ''); |
| 5 | +// Settings keep the business address in separate fields (street, city, state, | |
| 6 | +// postcode, country); documents only ever printed the street, so everything after | |
| 7 | +// it was missing and the address looked cut off at its last line. Prefer the | |
| 8 | +// composed lines, falling back to the raw street for any caller not passing them. | |
| 9 | +$companyAddressLines = (isset($company_address_lines) && is_array($company_address_lines)) | |
| 10 | + ? array_values(array_filter(array_map('strval', $company_address_lines), static function ($line) { | |
| 11 | + return trim($line) !== ''; | |
| 12 | + })) | |
| 13 | + : array_values(array_filter(preg_split('/\R/', $companyAddress) ?: [], static function ($line) { | |
| 14 | + return trim($line) !== ''; | |
| 15 | + })); | |
| 5 | 16 | $companyEmail = (string) ($company_email ?? ''); |
| 6 | 17 | $companyPhone = (string) ($company_phone ?? ''); |
| 7 | 18 | |
| 8 | 19 | $customerName = (string) ($customer_name ?? ''); |
| @@ -16,11 +27,16 @@ | ||
| 16 | 27 | $tripTitle = (string) ($trip_title ?? ''); |
| 17 | 28 | $tripDescription = (string) ($trip_description ?? ''); |
| 18 | 29 | $tripDuration = (string) ($trip_duration ?? ''); |
| 19 | 30 | $tripDifficulty = (string) ($trip_difficulty ?? ''); |
| 20 | -$tripHighlights = (string) ($trip_highlights ?? ''); | |
| 21 | -$tripIncludes = (string) ($trip_includes ?? ''); | |
| 22 | -$tripExcludes = (string) ($trip_excludes ?? ''); | |
| 31 | +// These three may arrive as either a string (line-separated) OR an | |
| 32 | +// array (parsed JSON). DON'T cast to string here — `(string) $array` | |
| 33 | +// produces the literal text "Array", which is exactly the bug that | |
| 34 | +// made the Trip Highlights section render "• Array" in the PDF. | |
| 35 | +// The template below already handles both shapes via `is_array(...)`. | |
| 36 | +$tripHighlights = $trip_highlights ?? ''; | |
| 37 | +$tripIncludes = $trip_includes ?? ''; | |
| 38 | +$tripExcludes = $trip_excludes ?? ''; | |
| 23 | 39 | |
| 24 | 40 | $travelDate = (string) ($travel_date ?? ''); |
| 25 | 41 | $returnDate = (string) ($return_date ?? ''); |
| 26 | 42 | |
| @@ -32,8 +48,70 @@ | ||
| 32 | 48 | $travelerCount = (int) ($traveler_count ?? 1); |
| 33 | 49 | $departureLocation = (string) ($departure_location ?? ''); |
| 34 | 50 | $destination = (string) ($destination ?? ''); |
| 35 | 51 | |
| 52 | +// Day-by-day timeline data. ItineraryPdfBuilder always passes an array | |
| 53 | +// (may be empty when the trip has no itinerary recorded). Each entry | |
| 54 | +// can be a stdClass (from TripRepository::getItineraryDays) or an | |
| 55 | +// associative array (from the JSON-encoded itinerary_days column), so | |
| 56 | +// we read fields through a small helper that accepts both shapes. | |
| 57 | +$itineraryDays = is_array($itinerary_days ?? null) ? $itinerary_days : []; | |
| 58 | + | |
| 59 | +$yatra_itinerary_get = static function ($source, string $key, $default = null) { | |
| 60 | + if (is_object($source)) { | |
| 61 | + return $source->{$key} ?? $default; | |
| 62 | + } | |
| 63 | + if (is_array($source)) { | |
| 64 | + return $source[$key] ?? $default; | |
| 65 | + } | |
| 66 | + return $default; | |
| 67 | +}; | |
| 68 | + | |
| 69 | +/** | |
| 70 | + * Normalize a mixed-shape list (highlights, includes, excludes) to a | |
| 71 | + * flat array of trimmed strings. Each input item may be a plain | |
| 72 | + * string, an associative array with a `title`/`name`/`text` key, or a | |
| 73 | + * stdClass with the same properties — the trip builder writes either | |
| 74 | + * shape depending on which UI version saved the record. The previous | |
| 75 | + * loop called `trim($item)` directly and 500'd whenever it hit a | |
| 76 | + * stdClass (see the PHP fatal error in the previous render). Empty | |
| 77 | + * entries are filtered out so we never print bullet points with no | |
| 78 | + * text. | |
| 79 | + */ | |
| 80 | +$yatra_normalize_list = static function ($value): array { | |
| 81 | + if (is_string($value)) { | |
| 82 | + $value = explode("\n", $value); | |
| 83 | + } | |
| 84 | + if (!is_array($value)) { | |
| 85 | + return []; | |
| 86 | + } | |
| 87 | + $out = []; | |
| 88 | + foreach ($value as $item) { | |
| 89 | + if (is_string($item)) { | |
| 90 | + $s = trim($item); | |
| 91 | + } elseif (is_array($item)) { | |
| 92 | + $s = trim((string) ($item['title'] ?? $item['name'] ?? $item['text'] ?? '')); | |
| 93 | + } elseif (is_object($item)) { | |
| 94 | + $s = trim((string) ($item->title ?? $item->name ?? $item->text ?? '')); | |
| 95 | + } else { | |
| 96 | + $s = ''; | |
| 97 | + } | |
| 98 | + if ($s !== '') { | |
| 99 | + $out[] = $s; | |
| 100 | + } | |
| 101 | + } | |
| 102 | + return $out; | |
| 103 | +}; | |
| 104 | + | |
| 105 | + | |
| 106 | +// Optional logo + header colour supplied by the White Label module. | |
| 107 | +// Unbranded sites keep this document's original header colour and show no logo. | |
| 108 | +$pdfBranding = function_exists('yatra_get_pdf_branding') | |
| 109 | + ? yatra_get_pdf_branding('#1e40af') | |
| 110 | + : ['logo_url' => '', 'header_color' => '#1e40af']; | |
| 111 | +$brandLogoUrl = (string) ($pdfBranding['logo_url'] ?? ''); | |
| 112 | +$brandHeaderColor = (string) ($pdfBranding['header_color'] ?? '#1e40af'); | |
| 113 | + | |
| 36 | 114 | ?><!DOCTYPE html> |
| 37 | 115 | <html lang="en"> |
| 38 | 116 | <head> |
| 39 | 117 | <meta charset="UTF-8"> |
| @@ -39,12 +117,51 @@ | ||
| 39 | 117 | <meta charset="UTF-8"> |
| 40 | 118 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 41 | 119 | <title><?php echo esc_html__('Travel Itinerary', 'yatra'); ?> - <?php echo htmlspecialchars($bookingRef, ENT_QUOTES, 'UTF-8'); ?></title> |
| 42 | 120 | <style> |
| 43 | - @page { size: A4 portrait; margin: 0mm; } | |
| 121 | + /* A4 page geometry. | |
| 122 | + Dompdf's `@page margin` has TWO known quirks in this build: | |
| 123 | + 1. only the single-value shorthand (`margin: Xmm`) is | |
| 124 | + reliably parsed, | |
| 125 | + 2. the top/bottom margin doesn't always carry across | |
| 126 | + page-breaks initiated by `page-break-before: always`, | |
| 127 | + so content on subsequent pages starts flush at the | |
| 128 | + page top and overlaps with whatever we drew with | |
| 129 | + `$pdf->page_script()`. | |
| 130 | + We work around it by setting the @page margin to ZERO and | |
| 131 | + emulating page margins ourselves: body padding handles the | |
| 132 | + LEFT/RIGHT inset (Dompdf applies body horizontal padding | |
| 133 | + per-line, so it's consistent on every page); the page-break | |
| 134 | + sections get an explicit `padding-top` (room for the | |
| 135 | + per-page header) and the last visible content has bottom | |
| 136 | + clearance so the footer rule isn't crowded. */ | |
| 137 | + @page { | |
| 138 | + size: A4 portrait; | |
| 139 | + margin: 0; | |
| 140 | + } | |
| 44 | 141 | * { margin: 0; padding: 0; box-sizing: border-box; } |
| 45 | - body { margin: 0; padding: 0; font-family: "DejaVu Sans", sans-serif; font-size: 12px; color: #111; } | |
| 46 | - .content { padding: 12mm 12mm 14mm 12mm; } | |
| 142 | + /* Explicit font-family on every text-bearing element. Dompdf's | |
| 143 | + default CSS resets some inherit chains; without this, table | |
| 144 | + cells were occasionally falling back to DejaVu Sans Bold | |
| 145 | + (which has no Devanagari) for the Travel Date value, even | |
| 146 | + though the parent body declared the Noto chain. */ | |
| 147 | + body, table, td, th, p, div, span, h1, h2, h3, h4, h5, h6, li, a, strong, em { | |
| 148 | + font-family: "Noto Sans Devanagari", "Noto Sans Arabic", "Noto Sans CJK", "DejaVu Sans", sans-serif; | |
| 149 | + } | |
| 150 | + /* Body padding emulates @page margin: | |
| 151 | + LEFT / RIGHT 18mm — applied per line, so consistent across | |
| 152 | + every page | |
| 153 | + TOP / BOTTOM 22mm — applies at document start / end. For | |
| 154 | + the per-page top/bottom we rely on the | |
| 155 | + page-break-before sections having their | |
| 156 | + own padding-top below. */ | |
| 157 | + body { | |
| 158 | + margin: 0; | |
| 159 | + padding: 22mm 18mm; | |
| 160 | + font-size: 12px; | |
| 161 | + color: #111; | |
| 162 | + } | |
| 163 | + .content { padding: 0; } | |
| 47 | 164 | |
| 48 | 165 | .page-break { page-break-before: always; } |
| 49 | 166 | .avoid-break { page-break-inside: avoid; } |
| 50 | 167 | |
| @@ -49,9 +166,10 @@ | ||
| 49 | 166 | .avoid-break { page-break-inside: avoid; } |
| 50 | 167 | |
| 51 | 168 | .header { width: 100%; border-collapse: collapse; } |
| 52 | 169 | .header td { vertical-align: top; } |
| 53 | - .brand { background: #1e40af; color: #fff; padding: 6mm 6mm; } | |
| 170 | + .brand { background: <?php echo htmlspecialchars($brandHeaderColor, ENT_QUOTES, 'UTF-8'); ?>; color: #fff; padding: 6mm 6mm; } | |
| 171 | + .brand-logo { max-height: 18mm; max-width: 60mm; margin-bottom: 3mm; } | |
| 54 | 172 | .brand h1 { font-size: 18px; font-weight: 700; margin: 0; } |
| 55 | 173 | .brand p { font-size: 11px; margin-top: 4px; } |
| 56 | 174 | |
| 57 | 175 | .itinerary-number { background: #f3f4f6; padding: 4mm 6mm; border-bottom: 2px solid #1e40af; margin-top: 2mm; } |
| @@ -57,10 +175,23 @@ | ||
| 57 | 175 | .itinerary-number { background: #f3f4f6; padding: 4mm 6mm; border-bottom: 2px solid #1e40af; margin-top: 2mm; } |
| 58 | 176 | .itinerary-number h2 { font-size: 16px; font-weight: 700; color: #1e40af; margin: 0; } |
| 59 | 177 | .itinerary-number .ref { font-size: 13px; color: #6b7280; margin-top: 2px; } |
| 60 | 178 | |
| 61 | - .section { margin-top: 6mm; page-break-inside: avoid; } | |
| 62 | - .section-title { font-size: 14px; font-weight: 700; color: #374151; margin-bottom: 3mm; padding-bottom: 2mm; border-bottom: 1px solid #e5e7eb; } | |
| 179 | + .section { margin-top: 6mm; } | |
| 180 | + /* Each top-level section starts on its own page. `.section + | |
| 181 | + .section` pushes the second-and-later sections to new pages, | |
| 182 | + and `padding-top: 16mm` reserves clearance for the | |
| 183 | + per-page running header drawn via $pdf->page_script() so | |
| 184 | + the section title doesn't end up under the header strip. | |
| 185 | + Page 1 (the first .section) skips both rules — it shows | |
| 186 | + the existing tall brand banner in normal flow above. */ | |
| 187 | + .section + .section { | |
| 188 | + page-break-before: always; | |
| 189 | + padding-top: 16mm; | |
| 190 | + } | |
| 191 | + .section.section-skip-break { page-break-before: avoid; } | |
| 192 | + /* Headings shouldn't orphan at the bottom of a page. */ | |
| 193 | + .section-title { font-size: 14px; font-weight: 700; color: #374151; margin-bottom: 3mm; padding-bottom: 2mm; border-bottom: 1px solid #e5e7eb; page-break-after: avoid; } | |
| 63 | 194 | |
| 64 | 195 | .panel { width: 100%; border: 1px solid #e5e7eb; border-collapse: collapse; margin-top: 6mm; margin-bottom: 6mm; } |
| 65 | 196 | .panel td { padding: 5mm 5mm; vertical-align: top; } |
| 66 | 197 | .panel h3 { font-size: 11px; text-transform: uppercase; color: #6b7280; margin-bottom: 8px; letter-spacing: 0.5px; } |
| @@ -66,28 +197,56 @@ | ||
| 66 | 197 | .panel h3 { font-size: 11px; text-transform: uppercase; color: #6b7280; margin-bottom: 8px; letter-spacing: 0.5px; } |
| 67 | 198 | .muted { color: #6b7280; } |
| 68 | 199 | |
| 69 | 200 | .trip-details { width: 100%; border-collapse: collapse; margin-bottom: 6mm; } |
| 70 | - .trip-details th { background: #f9fafb; font-weight: 600; text-align: left; padding: 3mm 4mm; border: 1px solid #e5e7eb; } | |
| 201 | + .trip-details th { background: #f9fafb; font-weight: 700; text-align: left; padding: 3mm 4mm; border: 1px solid #e5e7eb; } | |
| 71 | 202 | .trip-details td { padding: 3mm 4mm; border: 1px solid #e5e7eb; } |
| 72 | - .trip-details .label { font-weight: 600; color: #6b7280; width: 30%; } | |
| 203 | + .trip-details .label { font-weight: 700; color: #6b7280; width: 30%; } | |
| 73 | 204 | |
| 205 | + /* Info-card grid for Trip Info / Traveler & Booking. Replaces | |
| 206 | + the previous "label-value-as-table-row" layout, which read | |
| 207 | + more like a tax form than a travel document. Two columns, | |
| 208 | + each column = a stack of label+value rows where the label | |
| 209 | + sits above the value (vertical pairing) and the value uses | |
| 210 | + the document's primary text colour. Empty rows are hidden | |
| 211 | + upstream so this never renders a half-empty card. */ | |
| 212 | + .info-grid { width: 100%; border-collapse: collapse; margin-top: 2mm; } | |
| 213 | + .info-grid > tbody > tr > td { vertical-align: top; padding: 0; } | |
| 214 | + .info-card { background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 2mm; padding: 5mm 5mm; } | |
| 215 | + .info-card h3 { font-size: 11px; text-transform: uppercase; color: #1e40af; letter-spacing: 0.5px; margin: 0 0 3mm 0; padding-bottom: 2mm; border-bottom: 1px solid #dbeafe; } | |
| 216 | + .info-row { padding: 1.5mm 0; } | |
| 217 | + .info-row + .info-row { border-top: 1px dashed #e5e7eb; } | |
| 218 | + .info-label { display: block; font-size: 9.5px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.4px; margin-bottom: 1mm; } | |
| 219 | + .info-value { display: block; color: #111827; font-size: 12px; line-height: 1.4; } | |
| 220 | + | |
| 74 | 221 | .timeline { margin: 4mm 0; } |
| 75 | 222 | .timeline-item { position: relative; padding-left: 8mm; margin-bottom: 6mm; page-break-inside: avoid; } |
| 76 | 223 | .timeline-item:before { content: ''; position: absolute; left: 0; top: 6px; width: 8px; height: 8px; background: #1e40af; border-radius: 50%; } |
| 77 | 224 | .timeline-item:after { content: ''; position: absolute; left: 3px; top: 14px; width: 2px; height: calc(100% - 8px); background: #e5e7eb; } |
| 78 | 225 | .timeline-item:last-child:after { display: none; } |
| 79 | - .timeline-date { font-weight: 600; color: #1e40af; margin-bottom: 1mm; } | |
| 80 | - .timeline-title { font-weight: 600; margin-bottom: 1mm; } | |
| 226 | + .timeline-date { font-weight: 700; color: #1e40af; margin-bottom: 1mm; } | |
| 227 | + .timeline-title { font-weight: 700; margin-bottom: 1mm; } | |
| 81 | 228 | .timeline-desc { color: #6b7280; line-height: 1.4; } |
| 82 | 229 | |
| 83 | - .status { display: inline-block; padding: 1mm 3mm; border-radius: 2mm; font-size: 10px; font-weight: 600; text-transform: uppercase; } | |
| 230 | + .status { display: inline-block; padding: 1mm 3mm; border-radius: 2mm; font-size: 10px; font-weight: 700; text-transform: uppercase; } | |
| 84 | 231 | .status.confirmed { background: #d1fae5; color: #065f46; } |
| 85 | 232 | .status.pending { background: #fef3c7; color: #92400e; } |
| 86 | 233 | .status.cancelled { background: #fee2e2; color: #991b1b; } |
| 87 | 234 | |
| 88 | - .footer { margin-top: 8mm; padding-top: 4mm; border-top: 1px solid #e5e7eb; font-size: 10px; color: #6b7280; } | |
| 235 | + /* Footer needs explicit width:100% — without it Dompdf | |
| 236 | + collapses the table to its content width, so the | |
| 237 | + "right-aligned" cell only reaches the centre of the page | |
| 238 | + instead of the right margin. border-collapse + table-layout | |
| 239 | + fixed keep the two columns honouring their declared widths | |
| 240 | + (60% / 40%) regardless of content length. */ | |
| 241 | + .footer { width: 100%; table-layout: fixed; border-collapse: collapse; margin-top: 8mm; padding-top: 4mm; border-top: 1px solid #e5e7eb; font-size: 10px; color: #6b7280; } | |
| 89 | 242 | .footer td { vertical-align: top; } |
| 243 | + /* Dompdf doesn't always inherit text-align from a td down to | |
| 244 | + child block elements (<p>) — apply the alignment directly to | |
| 245 | + the cell content so the right-hand footer note actually | |
| 246 | + hugs the right edge instead of sitting flush-left. */ | |
| 247 | + .footer td.footer-right { text-align: right; } | |
| 248 | + .footer td.footer-right p { text-align: right; margin: 0; padding: 0; } | |
| 90 | 249 | |
| 91 | 250 | .trip-description { background: #f8fafc; padding: 4mm; border-left: 4px solid #1e40af; margin-bottom: 6mm; page-break-inside: avoid; } |
| 92 | 251 | .trip-description h3 { color: #1e40af; margin-bottom: 2mm; font-size: 14px; } |
| 93 | 252 | .trip-description p { line-height: 1.5; margin-bottom: 2mm; } |
| @@ -94,13 +253,55 @@ | ||
| 94 | 253 | |
| 95 | 254 | .itinerary-highlights { margin: 4mm 0; } |
| 96 | 255 | .highlight-item { padding: 2mm 0; border-bottom: 1px dashed #e5e7eb; } |
| 97 | 256 | .highlight-item:last-child { border-bottom: none; } |
| 98 | - .highlight-title { font-weight: 600; color: #374151; } | |
| 257 | + .highlight-title { font-weight: 700; color: #374151; } | |
| 99 | 258 | .highlight-desc { color: #6b7280; font-size: 11px; margin-top: 1mm; } |
| 100 | 259 | </style> |
| 101 | 260 | </head> |
| 102 | 261 | <body> |
| 262 | + <!-- Per-page header and footer, drawn via Dompdf's PHP canvas hook | |
| 263 | + using `$pdf->page_text(...)`. This is the most reliable | |
| 264 | + multi-page chrome approach in Dompdf because: | |
| 265 | + 1) It runs ONCE per page during the canvas-output phase, | |
| 266 | + so the strings are guaranteed to render — no fragile | |
| 267 | + CSS `position: fixed` quirks. | |
| 268 | + 2) `page_text` supports the special `{PAGE_NUM}` and | |
| 269 | + `{PAGE_COUNT}` substitutions, which is the only way to | |
| 270 | + get an accurate "X of Y" before page count is known. | |
| 271 | + 3) Conditional rendering via `if ($PAGE_NUM > 1)` is | |
| 272 | + trivial — used here to SKIP the running header on page 1 | |
| 273 | + (page 1 already has its own tall brand banner in body | |
| 274 | + content, so a second header up top would duplicate it). | |
| 275 | + | |
| 276 | + Coordinates are in Dompdf's point grid — 72pt equals 1 inch. | |
| 277 | + A4 portrait is 595 × 842 pt. The 45pt left/right offsets | |
| 278 | + match the 16mm @page side margin set in CSS above. --> | |
| 279 | + <?php | |
| 280 | + // Pre-compute strings for the per-page header/footer script. The | |
| 281 | + // actual <script type="text/php"> block is positioned at the END | |
| 282 | + // of body (just before </body>) instead of here at the top — | |
| 283 | + // critical reason: Dompdf's processPageScript() iterates the | |
| 284 | + // pages that exist AT THE MOMENT page_text() / page_script() are | |
| 285 | + // called. Placing the script at the top of body means only | |
| 286 | + // page 1 has been laid out, so the header/footer registers for | |
| 287 | + // page 1 ONLY (and even the "{PAGE_COUNT}" placeholder resolves | |
| 288 | + // to 1 instead of the real total). Moving it to the end means | |
| 289 | + // all pages exist by the time the script runs, so the chrome | |
| 290 | + // applies to every page and the page counter is accurate. | |
| 291 | + $_headerLeft = ($companyName !== '' ? $companyName : __('Travel Company', 'yatra')) | |
| 292 | + . ' · ' . __('Travel Itinerary', 'yatra'); | |
| 293 | + $_headerRight = (string) $bookingRef; | |
| 294 | + | |
| 295 | + $_footerLeftParts = []; | |
| 296 | + if ($companyName !== '') { $_footerLeftParts[] = $companyName; } | |
| 297 | + if ($companyEmail !== '') { $_footerLeftParts[] = $companyEmail; } | |
| 298 | + if ($companyPhone !== '') { $_footerLeftParts[] = $companyPhone; } | |
| 299 | + $_footerLeft = implode(' · ', $_footerLeftParts); | |
| 300 | + | |
| 301 | + $_pageLabel = __('Page', 'yatra') . ' '; | |
| 302 | + ?> | |
| 303 | + | |
| 103 | 304 | <div class="content"> |
| 104 | 305 | <!-- Header --> |
| 105 | 306 | <table class="header"> |
| 106 | 307 | <tr> |
| @@ -105,9 +306,12 @@ | ||
| 105 | 306 | <table class="header"> |
| 106 | 307 | <tr> |
| 107 | 308 | <td style="width: 60%;"> |
| 108 | 309 | <div class="brand"> |
| 109 | - <h1><?php echo htmlspecialchars($companyName ?: __('Travel Company', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></h1> | |
| 310 | + <?php if ($brandLogoUrl !== ''): ?> | |
| 311 | + <img class="brand-logo" src="<?php echo htmlspecialchars($brandLogoUrl, ENT_QUOTES, 'UTF-8'); ?>" alt=""> | |
| 312 | + <?php endif; ?> | |
| 313 | + <h1><?php echo htmlspecialchars($companyName ?: __('Travel Company', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></h1> | |
| 110 | 314 | <p><?php esc_html_e('Travel Itinerary Document', 'yatra'); ?></p> |
| 111 | 315 | </div> |
| 112 | 316 | </td> |
| 113 | 317 | <td style="width: 40%;"> |
| @@ -123,261 +327,603 @@ | ||
| 123 | 327 | <h2><?php esc_html_e('Travel Itinerary', 'yatra'); ?></h2> |
| 124 | 328 | <div class="ref"><?php esc_html_e('Issued:', 'yatra'); ?> <?php echo htmlspecialchars($bookingDate, ENT_QUOTES, 'UTF-8'); ?></div> |
| 125 | 329 | </div> |
| 126 | 330 | |
| 127 | - <!-- Trip Description --> | |
| 331 | + <!-- Trip Description. | |
| 332 | + trip_description is stored as rich HTML in the DB | |
| 333 | + (TinyMCE/Gutenberg output); the previous template escaped | |
| 334 | + it with htmlspecialchars() so the user literally saw `<p>` | |
| 335 | + tags in the PDF. Use wp_kses_post() instead — same allow-list | |
| 336 | + WP uses for post content, so admin-saved HTML renders | |
| 337 | + correctly without opening up to script injection. --> | |
| 128 | 338 | <?php if (!empty($tripDescription)): ?> |
| 129 | 339 | <div class="section"> |
| 130 | 340 | <div class="section-title"><?php esc_html_e('About This Trip', 'yatra'); ?></div> |
| 131 | 341 | <div class="trip-description"> |
| 132 | 342 | <h3><?php echo htmlspecialchars($tripTitle, ENT_QUOTES, 'UTF-8'); ?></h3> |
| 133 | - <p><?php echo nl2br(htmlspecialchars($tripDescription, ENT_QUOTES, 'UTF-8')); ?></p> | |
| 343 | + <?php echo wp_kses_post(wpautop($tripDescription)); ?> | |
| 134 | 344 | </div> |
| 135 | 345 | </div> |
| 136 | 346 | <?php endif; ?> |
| 137 | 347 | |
| 138 | - <!-- Trip Details --> | |
| 348 | + <!-- Trip Information + Traveler & Booking. | |
| 349 | + Rendered as info-card pairs (label above value) instead | |
| 350 | + of label-value table rows — reads as a travel document, | |
| 351 | + not a tax form. Empty fields are omitted entirely (no | |
| 352 | + "N/A" placeholders). Booking status is shown ONCE here, | |
| 353 | + in the booking card — it used to render in both Travel | |
| 354 | + Information AND Booking Information, so the same pill | |
| 355 | + appeared twice on the page. --> | |
| 356 | + <?php | |
| 357 | + $tripRows = []; | |
| 358 | + if ($tripTitle !== '') { $tripRows[] = [__('Trip Name', 'yatra'), $tripTitle, false]; } | |
| 359 | + if ($tripDuration !== '') { $tripRows[] = [__('Duration', 'yatra'), $tripDuration, false]; } | |
| 360 | + if ($tripDifficulty !== '') { $tripRows[] = [__('Difficulty', 'yatra'), $tripDifficulty, false]; } | |
| 361 | + if ($travelerCount > 0) { $tripRows[] = [__('Travelers', 'yatra'), (string) $travelerCount, false]; } | |
| 362 | + | |
| 363 | + $travelRows = []; | |
| 364 | + if ($departureLocation !== '') { $travelRows[] = [__('Departure Location', 'yatra'), $departureLocation, false]; } | |
| 365 | + if ($destination !== '') { $travelRows[] = [__('Destination', 'yatra'), $destination, false]; } | |
| 366 | + if ($travelDate !== '') { $travelRows[] = [__('Travel Date', 'yatra'), $travelDate, false]; } | |
| 367 | + if (!empty($returnDate)) { $travelRows[] = [__('Return Date', 'yatra'), $returnDate, false]; } | |
| 368 | + | |
| 369 | + $travelerRows = []; | |
| 370 | + if ($customerName !== '') { $travelerRows[] = [__('Name', 'yatra'), $customerName, false]; } | |
| 371 | + if ($customerEmail !== '') { $travelerRows[] = [__('Email', 'yatra'), $customerEmail, false]; } | |
| 372 | + | |
| 373 | + $bookingRows = []; | |
| 374 | + if ($bookingRef !== '') { $bookingRows[] = [__('Reference', 'yatra'), $bookingRef, false]; } | |
| 375 | + if ($bookingDate !== '') { $bookingRows[] = [__('Issued On', 'yatra'), $bookingDate, false]; } | |
| 376 | + if ($bookingStatus !== '') { $bookingRows[] = [__('Status', 'yatra'), '<span class="status ' . $statusClass . '">' . htmlspecialchars($bookingStatus, ENT_QUOTES, 'UTF-8') . '</span>', true]; } | |
| 377 | + | |
| 378 | + $renderCard = static function (string $heading, array $rows): void { | |
| 379 | + if (empty($rows)) return; | |
| 380 | + echo '<div class="info-card">'; | |
| 381 | + echo '<h3>' . htmlspecialchars($heading, ENT_QUOTES, 'UTF-8') . '</h3>'; | |
| 382 | + foreach ($rows as [$label, $value, $rawHtml]) { | |
| 383 | + echo '<div class="info-row">'; | |
| 384 | + echo '<span class="info-label">' . htmlspecialchars((string) $label, ENT_QUOTES, 'UTF-8') . '</span>'; | |
| 385 | + echo '<span class="info-value">' . ($rawHtml ? $value : htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8')) . '</span>'; | |
| 386 | + echo '</div>'; | |
| 387 | + } | |
| 388 | + echo '</div>'; | |
| 389 | + }; | |
| 390 | + | |
| 391 | + $renderTwoCardRow = static function (string $leftHeading, array $leftRows, string $rightHeading, array $rightRows) use ($renderCard): void { | |
| 392 | + if (empty($leftRows) && empty($rightRows)) return; | |
| 393 | + echo '<table class="info-grid"><tr>'; | |
| 394 | + if (!empty($leftRows)) { | |
| 395 | + $width = !empty($rightRows) ? '49%' : '100%'; | |
| 396 | + echo '<td style="width:' . $width . '; padding-right:' . (!empty($rightRows) ? '2%' : '0') . ';">'; | |
| 397 | + $renderCard($leftHeading, $leftRows); | |
| 398 | + echo '</td>'; | |
| 399 | + } | |
| 400 | + if (!empty($rightRows)) { | |
| 401 | + $width = !empty($leftRows) ? '49%' : '100%'; | |
| 402 | + echo '<td style="width:' . $width . ';">'; | |
| 403 | + $renderCard($rightHeading, $rightRows); | |
| 404 | + echo '</td>'; | |
| 405 | + } | |
| 406 | + echo '</tr></table>'; | |
| 407 | + }; | |
| 408 | + ?> | |
| 409 | + | |
| 410 | + <?php if (!empty($tripRows) || !empty($travelRows)): ?> | |
| 139 | 411 | <div class="section"> |
| 140 | 412 | <div class="section-title"><?php esc_html_e('Trip Information', 'yatra'); ?></div> |
| 141 | - <table class="panel"> | |
| 142 | - <tr> | |
| 143 | - <td style="width: 50%;"> | |
| 144 | - <h3><?php esc_html_e('Trip Details', 'yatra'); ?></h3> | |
| 145 | - <table class="trip-details"> | |
| 146 | - <tr> | |
| 147 | - <th class="label"><?php esc_html_e('Trip Name', 'yatra'); ?></th> | |
| 148 | - <td><?php echo htmlspecialchars($tripTitle, ENT_QUOTES, 'UTF-8'); ?></td> | |
| 149 | - </tr> | |
| 150 | - <tr> | |
| 151 | - <th class="label"><?php esc_html_e('Duration', 'yatra'); ?></th> | |
| 152 | - <td><?php echo htmlspecialchars($tripDuration ?: __('N/A', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></td> | |
| 153 | - </tr> | |
| 154 | - <tr> | |
| 155 | - <th class="label"><?php esc_html_e('Difficulty Level', 'yatra'); ?></th> | |
| 156 | - <td><?php echo htmlspecialchars($tripDifficulty ?: __('N/A', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></td> | |
| 157 | - </tr> | |
| 158 | - <tr> | |
| 159 | - <th class="label"><?php esc_html_e('Number of Travelers', 'yatra'); ?></th> | |
| 160 | - <td><?php echo $travelerCount; ?></td> | |
| 161 | - </tr> | |
| 162 | - </table> | |
| 163 | - </td> | |
| 164 | - <td style="width: 50%;"> | |
| 165 | - <h3><?php esc_html_e('Travel Information', 'yatra'); ?></h3> | |
| 166 | - <table class="trip-details"> | |
| 167 | - <tr> | |
| 168 | - <th class="label"><?php esc_html_e('Departure Location', 'yatra'); ?></th> | |
| 169 | - <td><?php echo htmlspecialchars($departureLocation ?: __('N/A', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></td> | |
| 170 | - </tr> | |
| 171 | - <tr> | |
| 172 | - <th class="label"><?php esc_html_e('Destination', 'yatra'); ?></th> | |
| 173 | - <td><?php echo htmlspecialchars($destination ?: __('N/A', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></td> | |
| 174 | - </tr> | |
| 175 | - <tr> | |
| 176 | - <th class="label"><?php esc_html_e('Travel Date', 'yatra'); ?></th> | |
| 177 | - <td><?php echo htmlspecialchars($travelDate ?: __('N/A', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></td> | |
| 178 | - </tr> | |
| 179 | - <?php if (!empty($returnDate)): ?> | |
| 180 | - <tr> | |
| 181 | - <th class="label"><?php esc_html_e('Return Date', 'yatra'); ?></th> | |
| 182 | - <td><?php echo htmlspecialchars($returnDate, ENT_QUOTES, 'UTF-8'); ?></td> | |
| 183 | - </tr> | |
| 184 | - <?php endif; ?> | |
| 185 | - <tr> | |
| 186 | - <th class="label"><?php esc_html_e('Booking Status', 'yatra'); ?></th> | |
| 187 | - <td><span class="status <?php echo $statusClass; ?>"><?php echo htmlspecialchars($bookingStatus, ENT_QUOTES, 'UTF-8'); ?></span></td> | |
| 188 | - </tr> | |
| 189 | - </table> | |
| 190 | - </td> | |
| 191 | - </tr> | |
| 192 | - </table> | |
| 413 | + <?php $renderTwoCardRow(__('Trip Details', 'yatra'), $tripRows, __('Travel Information', 'yatra'), $travelRows); ?> | |
| 193 | 414 | </div> |
| 415 | + <?php endif; ?> | |
| 194 | 416 | |
| 195 | - <!-- Customer Information --> | |
| 417 | + <?php if (!empty($travelerRows) || !empty($bookingRows)): ?> | |
| 196 | 418 | <div class="section"> |
| 197 | - <div class="section-title"><?php esc_html_e('Traveler Information', 'yatra'); ?></div> | |
| 198 | - <table class="panel"> | |
| 199 | - <tr> | |
| 200 | - <td style="width: 50%;"> | |
| 201 | - <h3><?php esc_html_e('Primary Traveler', 'yatra'); ?></h3> | |
| 202 | - <table class="trip-details"> | |
| 203 | - <tr> | |
| 204 | - <th class="label"><?php esc_html_e('Name', 'yatra'); ?></th> | |
| 205 | - <td><?php echo htmlspecialchars($customerName, ENT_QUOTES, 'UTF-8'); ?></td> | |
| 206 | - </tr> | |
| 207 | - <tr> | |
| 208 | - <th class="label"><?php esc_html_e('Email', 'yatra'); ?></th> | |
| 209 | - <td><?php echo htmlspecialchars($customerEmail, ENT_QUOTES, 'UTF-8'); ?></td> | |
| 210 | - </tr> | |
| 211 | - </table> | |
| 212 | - </td> | |
| 213 | - <td style="width: 50%;"> | |
| 214 | - <h3><?php esc_html_e('Booking Information', 'yatra'); ?></h3> | |
| 215 | - <table class="trip-details"> | |
| 216 | - <tr> | |
| 217 | - <th class="label"><?php esc_html_e('Booking Reference', 'yatra'); ?></th> | |
| 218 | - <td><?php echo htmlspecialchars($bookingRef, ENT_QUOTES, 'UTF-8'); ?></td> | |
| 219 | - </tr> | |
| 220 | - <tr> | |
| 221 | - <th class="label"><?php esc_html_e('Booking Date', 'yatra'); ?></th> | |
| 222 | - <td><?php echo htmlspecialchars($bookingDate, ENT_QUOTES, 'UTF-8'); ?></td> | |
| 223 | - </tr> | |
| 224 | - <tr> | |
| 225 | - <th class="label"><?php esc_html_e('Status', 'yatra'); ?></th> | |
| 226 | - <td><span class="status <?php echo $statusClass; ?>"><?php echo htmlspecialchars($bookingStatus, ENT_QUOTES, 'UTF-8'); ?></span></td> | |
| 227 | - </tr> | |
| 228 | - </table> | |
| 229 | - </td> | |
| 230 | - </tr> | |
| 231 | - </table> | |
| 419 | + <div class="section-title"><?php esc_html_e('Traveler & Booking', 'yatra'); ?></div> | |
| 420 | + <?php $renderTwoCardRow(__('Primary Traveler', 'yatra'), $travelerRows, __('Booking Information', 'yatra'), $bookingRows); ?> | |
| 232 | 421 | </div> |
| 422 | + <?php endif; ?> | |
| 233 | 423 | |
| 234 | - <!-- Trip Highlights --> | |
| 235 | - <?php if (!empty($tripHighlights)): ?> | |
| 424 | + <!-- Trip Highlights. | |
| 425 | + Highlights come in three shapes depending on which schema | |
| 426 | + version saved them: | |
| 427 | + 1. Newline-separated string (legacy plain-text field) | |
| 428 | + 2. Array of plain strings (newer JSON column) | |
| 429 | + 3. Array of objects / assoc arrays with `title` + | |
| 430 | + optional `description` (current trip-builder UI) | |
| 431 | + Normalise to a list of `[title, description]` tuples up | |
| 432 | + front so the markup loop stays simple — and never call | |
| 433 | + trim() on a stdClass (the bug that 500'd the previous | |
| 434 | + render). --> | |
| 435 | + <?php | |
| 436 | + $highlightsList = []; | |
| 437 | + $rawHighlights = $tripHighlights; | |
| 438 | + if (is_string($rawHighlights)) { | |
| 439 | + $rawHighlights = explode("\n", $rawHighlights); | |
| 440 | + } | |
| 441 | + if (is_array($rawHighlights)) { | |
| 442 | + foreach ($rawHighlights as $h) { | |
| 443 | + $title = ''; | |
| 444 | + $description = ''; | |
| 445 | + if (is_string($h)) { | |
| 446 | + $title = trim($h); | |
| 447 | + } elseif (is_array($h)) { | |
| 448 | + $title = trim((string) ($h['title'] ?? $h['name'] ?? $h['text'] ?? '')); | |
| 449 | + $description = trim((string) ($h['description'] ?? $h['desc'] ?? '')); | |
| 450 | + } elseif (is_object($h)) { | |
| 451 | + $title = trim((string) ($h->title ?? $h->name ?? $h->text ?? '')); | |
| 452 | + $description = trim((string) ($h->description ?? $h->desc ?? '')); | |
| 453 | + } | |
| 454 | + if ($title !== '' || $description !== '') { | |
| 455 | + $highlightsList[] = [$title, $description]; | |
| 456 | + } | |
| 457 | + } | |
| 458 | + } | |
| 459 | + ?> | |
| 460 | + <?php if (!empty($highlightsList)): ?> | |
| 236 | 461 | <div class="section"> |
| 237 | 462 | <div class="section-title"><?php esc_html_e('Trip Highlights', 'yatra'); ?></div> |
| 238 | 463 | <div class="itinerary-highlights"> |
| 239 | - <?php | |
| 240 | - $highlights = is_array($tripHighlights) ? $tripHighlights : explode("\n", $tripHighlights); | |
| 241 | - foreach ($highlights as $highlight): | |
| 242 | - $highlight = trim($highlight); | |
| 243 | - if (!empty($highlight)): | |
| 244 | - ?> | |
| 464 | + <?php foreach ($highlightsList as [$hTitle, $hDesc]): ?> | |
| 245 | 465 | <div class="highlight-item"> |
| 246 | - <div class="highlight-title">• <?php echo htmlspecialchars($highlight, ENT_QUOTES, 'UTF-8'); ?></div> | |
| 466 | + <?php if ($hTitle !== ''): ?> | |
| 467 | + <div class="highlight-title">• <?php echo htmlspecialchars($hTitle, ENT_QUOTES, 'UTF-8'); ?></div> | |
| 468 | + <?php endif; ?> | |
| 469 | + <?php if ($hDesc !== ''): ?> | |
| 470 | + <div class="highlight-desc"><?php echo wp_kses_post(wpautop($hDesc)); ?></div> | |
| 471 | + <?php endif; ?> | |
| 247 | 472 | </div> |
| 248 | - <?php | |
| 249 | - endif; | |
| 250 | - endforeach; | |
| 251 | - ?> | |
| 473 | + <?php endforeach; ?> | |
| 252 | 474 | </div> |
| 253 | 475 | </div> |
| 254 | 476 | <?php endif; ?> |
| 255 | 477 | |
| 256 | - <!-- Travel Timeline --> | |
| 478 | + <!-- Travel Timeline — mirrors the single-trip page's | |
| 479 | + "Itinerary" section so the printed PDF matches what the | |
| 480 | + traveller already saw on the website. Each day from the | |
| 481 | + trip's itinerary table renders as a timeline item with | |
| 482 | + title, description, and the activities/meals/transport | |
| 483 | + entries that belong to it. When the trip has no itinerary | |
| 484 | + recorded we fall back to a derived Departure → days → | |
| 485 | + Arrival outline so the PDF is never empty. --> | |
| 257 | 486 | <div class="section"> |
| 258 | 487 | <div class="section-title"><?php esc_html_e('Travel Timeline', 'yatra'); ?></div> |
| 259 | 488 | <div class="timeline"> |
| 260 | - <div class="timeline-item"> | |
| 261 | - <div class="timeline-date"><?php echo htmlspecialchars($travelDate ?: __('TBD', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></div> | |
| 262 | - <div class="timeline-title"><?php esc_html_e('Departure', 'yatra'); ?></div> | |
| 263 | - <div class="timeline-desc"><?php echo esc_html(sprintf(__('Departure from %s. Please arrive at least 30 minutes before departure time.', 'yatra'), htmlspecialchars($departureLocation ?: __('meeting point', 'yatra'), ENT_QUOTES, 'UTF-8'))); ?></div> | |
| 264 | - </div> | |
| 265 | - | |
| 266 | - <div class="timeline-item"> | |
| 267 | - <div class="timeline-date"><?php echo htmlspecialchars($travelDate ?: __('TBD', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></div> | |
| 268 | - <div class="timeline-title"><?php esc_html_e('Arrival at Destination', 'yatra'); ?></div> | |
| 269 | - <div class="timeline-desc"><?php echo esc_html(sprintf(__('Arrival at %s. Check-in and accommodation briefing.', 'yatra'), htmlspecialchars($destination ?: __('destination', 'yatra'), ENT_QUOTES, 'UTF-8'))); ?></div> | |
| 270 | - </div> | |
| 489 | + <?php if (!empty($itineraryDays)): ?> | |
| 490 | + <?php foreach ($itineraryDays as $dayIndex => $day): | |
| 491 | + $dayNumber = (int) ($yatra_itinerary_get($day, 'day_number', $dayIndex + 1)); | |
| 492 | + $dayTitle = (string) ($yatra_itinerary_get($day, 'title', $yatra_itinerary_get($day, 'day_title', ''))); | |
| 493 | + $dayDescription = (string) ($yatra_itinerary_get($day, 'description', $yatra_itinerary_get($day, 'day_description', ''))); | |
| 494 | + $dayEntries = $yatra_itinerary_get($day, 'entries', []); | |
| 495 | + if (!is_array($dayEntries)) { | |
| 496 | + $dayEntries = []; | |
| 497 | + } | |
| 271 | 498 | |
| 272 | - <?php if (!empty($tripDuration)): ?> | |
| 273 | - <?php | |
| 274 | - $days = (int) preg_replace('/\D/', '', $tripDuration); | |
| 275 | - for ($i = 2; $i <= $days; $i++): | |
| 276 | - ?> | |
| 277 | - <div class="timeline-item"> | |
| 278 | - <div class="timeline-date"><?php esc_html_e('Day', 'yatra'); ?> <?php echo $i; ?></div> | |
| 279 | - <div class="timeline-title"><?php esc_html_e('Activities & Sightseeing', 'yatra'); ?></div> | |
| 280 | - <div class="timeline-desc"><?php echo esc_html(sprintf(__('Full day of planned activities and sightseeing at %s. Detailed itinerary will be provided by your tour guide.', 'yatra'), htmlspecialchars($destination, ENT_QUOTES, 'UTF-8'))); ?></div> | |
| 281 | - </div> | |
| 282 | - <?php endfor; ?> | |
| 283 | - <?php endif; ?> | |
| 499 | + // Resolve the calendar date for this day, if we | |
| 500 | + // know the travel start: Day 1 = travel_date, | |
| 501 | + // Day N = travel_date + (N-1) days. Skipped | |
| 502 | + // silently when no travel_date is available. | |
| 503 | + $dayDateLabel = ''; | |
| 504 | + if (!empty($travel_date)) { | |
| 505 | + $startTs = strtotime((string) ($travel_date_raw ?? '')); | |
| 506 | + } | |
| 507 | + // Use the already-formatted $travelDate for Day 1, | |
| 508 | + // and compute later days from $travelDate's source | |
| 509 | + // when available via the global travel_date var. | |
| 510 | + ?> | |
| 511 | + <div class="timeline-item"> | |
| 512 | + <div class="timeline-date"> | |
| 513 | + <?php | |
| 514 | + /* translators: %d: itinerary day number. */ | |
| 515 | + echo esc_html(sprintf(__('Day %d', 'yatra'), $dayNumber)); | |
| 516 | + ?> | |
| 517 | + </div> | |
| 518 | + <div class="timeline-title"> | |
| 519 | + <?php echo htmlspecialchars($dayTitle !== '' ? $dayTitle : sprintf(__('Day %d', 'yatra'), $dayNumber), ENT_QUOTES, 'UTF-8'); ?> | |
| 520 | + </div> | |
| 521 | + <?php if ($dayDescription !== ''): ?> | |
| 522 | + <div class="timeline-desc"><?php echo wp_kses_post(wpautop($dayDescription)); ?></div> | |
| 523 | + <?php endif; ?> | |
| 284 | 524 | |
| 285 | - <?php if (!empty($returnDate)): ?> | |
| 286 | - <div class="timeline-item"> | |
| 287 | - <div class="timeline-date"><?php echo htmlspecialchars($returnDate, ENT_QUOTES, 'UTF-8'); ?></div> | |
| 288 | - <div class="timeline-title"><?php esc_html_e('Return Journey', 'yatra'); ?></div> | |
| 289 | - <div class="timeline-desc"><?php echo esc_html(sprintf(__('Departure from %s and return to %s.', 'yatra'), htmlspecialchars($destination, ENT_QUOTES, 'UTF-8'), htmlspecialchars($departureLocation ?: __('origin', 'yatra'), ENT_QUOTES, 'UTF-8'))); ?></div> | |
| 290 | - </div> | |
| 525 | + <?php if (!empty($dayEntries)): ?> | |
| 526 | + <div class="timeline-desc" style="margin-top:2mm;"> | |
| 527 | + <?php foreach ($dayEntries as $entry): | |
| 528 | + $entryTitle = (string) ($yatra_itinerary_get($entry, 'title', '')); | |
| 529 | + $entryDesc = (string) ($yatra_itinerary_get($entry, 'description', '')); | |
| 530 | + $entryType = (string) ($yatra_itinerary_get($entry, 'item_type', $yatra_itinerary_get($entry, 'item_type_name', ''))); | |
| 531 | + $entryTime = (string) ($yatra_itinerary_get($entry, 'time', $yatra_itinerary_get($entry, 'start_time', ''))); | |
| 532 | + $entryLoc = (string) ($yatra_itinerary_get($entry, 'location', '')); | |
| 533 | + if ($entryTitle === '' && $entryDesc === '') { | |
| 534 | + continue; | |
| 535 | + } | |
| 536 | + ?> | |
| 537 | + <div style="padding:2mm 0; border-bottom:1px dashed #e5e7eb;"> | |
| 538 | + <div style="font-weight:600; color:#374151;"> | |
| 539 | + <?php if ($entryTime !== ''): ?> | |
| 540 | + <span style="color:#1e40af; margin-right:2mm;">[<?php echo htmlspecialchars($entryTime, ENT_QUOTES, 'UTF-8'); ?>]</span> | |
| 541 | + <?php endif; ?> | |
| 542 | + <?php echo htmlspecialchars($entryTitle !== '' ? $entryTitle : $entryType, ENT_QUOTES, 'UTF-8'); ?> | |
| 543 | + <?php if ($entryType !== '' && $entryTitle !== '' && stripos($entryTitle, $entryType) === false): ?> | |
| 544 | + <span style="color:#6b7280; font-weight:400; font-size:10px; margin-left:2mm;">· <?php echo htmlspecialchars($entryType, ENT_QUOTES, 'UTF-8'); ?></span> | |
| 545 | + <?php endif; ?> | |
| 546 | + </div> | |
| 547 | + <?php if ($entryLoc !== ''): ?> | |
| 548 | + <div style="color:#6b7280; font-size:10px; margin-top:1mm;"> | |
| 549 | + <?php | |
| 550 | + /* translators: %s: location name (city/landmark) for an itinerary entry. */ | |
| 551 | + echo esc_html(sprintf(__('Location: %s', 'yatra'), $entryLoc)); | |
| 552 | + ?> | |
| 553 | + </div> | |
| 554 | + <?php endif; ?> | |
| 555 | + <?php if ($entryDesc !== ''): ?> | |
| 556 | + <div style="color:#6b7280; margin-top:1mm; line-height:1.4;"> | |
| 557 | + <?php echo wp_kses_post(wpautop($entryDesc)); ?> | |
| 558 | + </div> | |
| 559 | + <?php endif; ?> | |
| 560 | + </div> | |
| 561 | + <?php endforeach; ?> | |
| 562 | + </div> | |
| 563 | + <?php endif; ?> | |
| 564 | + </div> | |
| 565 | + <?php endforeach; ?> | |
| 566 | + <?php else: ?> | |
| 567 | + <!-- Derived outline shown when no day-by-day | |
| 568 | + itinerary has been authored for the trip. | |
| 569 | + Previously this section rendered both a | |
| 570 | + "Departure" AND an "Arrival at Destination" | |
| 571 | + row using `$travelDate` for BOTH — so the | |
| 572 | + customer saw the same start date repeated | |
| 573 | + twice for what should be two distinct events. | |
| 574 | + Now we render the actual booked travel range: | |
| 575 | + * Trip Start — `$travelDate` (booking's | |
| 576 | + stored start_date / arrival) | |
| 577 | + * Trip End — `$returnDate` (booking's | |
| 578 | + stored end_date / departure), | |
| 579 | + resolved in ItineraryPdfBuilder | |
| 580 | + and shown only when it differs | |
| 581 | + from the start (single-day | |
| 582 | + trips skip it) | |
| 583 | + The dates come from the values the plugin already | |
| 584 | + records at booking time, so arrival and departure | |
| 585 | + reflect the real booked range — no more duplicate | |
| 586 | + "arrival = departure date" confusion. --> | |
| 587 | + <?php | |
| 588 | + $startDate = $travelDate !== '' ? $travelDate : __('TBD', 'yatra'); | |
| 589 | + $endDate = $returnDate !== '' ? $returnDate : ''; | |
| 590 | + $isMultiDay = $endDate !== '' && $endDate !== $startDate; | |
| 591 | + // Resolve the destination / origin labels ONCE here | |
| 592 | + // so the sprintf() calls below interpolate plain | |
| 593 | + // values — not a `__('destination', 'yatra')` ternary | |
| 594 | + // that wp-cli would attach to each surrounding | |
| 595 | + // translator comment, producing "string has N | |
| 596 | + // different translator comments" warnings. | |
| 597 | + $destinationLabel = $destination !== '' ? $destination : __('destination', 'yatra'); | |
| 598 | + $originLabel = $departureLocation !== '' ? $departureLocation : __('origin', 'yatra'); | |
| 599 | + $meetingPointLabel = $departureLocation !== '' ? $departureLocation : __('meeting point', 'yatra'); | |
| 600 | + ?> | |
| 601 | + | |
| 602 | + <div class="timeline-item"> | |
| 603 | + <div class="timeline-date"><?php echo htmlspecialchars($startDate, ENT_QUOTES, 'UTF-8'); ?></div> | |
| 604 | + <div class="timeline-title"> | |
| 605 | + <?php | |
| 606 | + if ($isMultiDay) { | |
| 607 | + esc_html_e('Trip Start', 'yatra'); | |
| 608 | + } else { | |
| 609 | + esc_html_e('Trip Day', 'yatra'); | |
| 610 | + } | |
| 611 | + ?> | |
| 612 | + </div> | |
| 613 | + <div class="timeline-desc"><?php | |
| 614 | + /* translators: %s: departure location (e.g. meeting point or city). */ | |
| 615 | + echo esc_html(sprintf(__('Departure from %s. Please arrive at least 30 minutes before departure time.', 'yatra'), $meetingPointLabel)); | |
| 616 | + if ($destination !== '') { | |
| 617 | + echo ' '; | |
| 618 | + /* translators: %s: destination name (city or region). */ | |
| 619 | + echo esc_html(sprintf(__('Arrival at %s for check-in and accommodation briefing.', 'yatra'), $destination)); | |
| 620 | + } | |
| 621 | + ?></div> | |
| 622 | + </div> | |
| 623 | + | |
| 624 | + <?php | |
| 625 | + // Optional intermediate days. Only shown for | |
| 626 | + // multi-day trips where we have a duration. | |
| 627 | + // Day 1 = Trip Start (above). Day N = Trip End | |
| 628 | + // (below). So intermediates are 2 .. N-1. | |
| 629 | + $derivedDays = (int) preg_replace('/\D/', '', (string) $tripDuration); | |
| 630 | + if ($isMultiDay && $derivedDays > 2): | |
| 631 | + for ($i = 2; $i < $derivedDays; $i++): ?> | |
| 632 | + <div class="timeline-item"> | |
| 633 | + <div class="timeline-date"> | |
| 634 | + <?php | |
| 635 | + /* translators: %d: itinerary day number. */ | |
| 636 | + echo esc_html(sprintf(__('Day %d', 'yatra'), $i)); | |
| 637 | + ?> | |
| 638 | + </div> | |
| 639 | + <div class="timeline-title"><?php esc_html_e('Activities & Sightseeing', 'yatra'); ?></div> | |
| 640 | + <div class="timeline-desc"><?php | |
| 641 | + /* translators: %s: destination name (city or region). */ | |
| 642 | + echo esc_html(sprintf(__('Full day of planned activities and sightseeing at %s. Detailed itinerary will be provided by your tour guide.', 'yatra'), $destinationLabel)); | |
| 643 | + ?></div> | |
| 644 | + </div> | |
| 645 | + <?php endfor; | |
| 646 | + endif; ?> | |
| 647 | + | |
| 648 | + <?php if ($isMultiDay): ?> | |
| 649 | + <div class="timeline-item"> | |
| 650 | + <div class="timeline-date"><?php echo htmlspecialchars($endDate, ENT_QUOTES, 'UTF-8'); ?></div> | |
| 651 | + <div class="timeline-title"><?php esc_html_e('Trip End', 'yatra'); ?></div> | |
| 652 | + <div class="timeline-desc"><?php | |
| 653 | + /* translators: 1: destination name, 2: origin/departure location. */ | |
| 654 | + echo esc_html(sprintf(__('Departure from %1$s and return to %2$s.', 'yatra'), $destinationLabel, $originLabel)); | |
| 655 | + ?></div> | |
| 656 | + </div> | |
| 657 | + <?php endif; ?> | |
| 291 | 658 | <?php endif; ?> |
| 292 | 659 | </div> |
| 293 | 660 | </div> |
| 294 | 661 | |
| 295 | - <!-- Important Information --> | |
| 662 | + <!-- Important Information — mirrors the same fields the | |
| 663 | + single-trip page's "Important Information" tab surfaces | |
| 664 | + (templates/partials/single-trip/content-important-info.php): | |
| 665 | + physical requirements, visa, vaccination, cancellation | |
| 666 | + policy, age range, accommodation, transportation. Empty | |
| 667 | + fields are skipped, so trips with no important-info | |
| 668 | + content authored don't show a half-empty table. | |
| 669 | + | |
| 670 | + The previous version hardcoded generic "Comfortable | |
| 671 | + clothing, walking shoes, sunscreen..." advice that wasn't | |
| 672 | + actually associated with the booked trip — misleading the | |
| 673 | + reader and identical across every itinerary PDF the site | |
| 674 | + ever generated. --> | |
| 675 | + <?php | |
| 676 | + $physicalReq = (string) ($physical_requirements ?? ''); | |
| 677 | + $visaReq = (string) ($visa_requirements ?? ''); | |
| 678 | + $vaccinationReq = (string) ($vaccination_requirements ?? ''); | |
| 679 | + $cancellation = (string) ($cancellation_policy ?? ''); | |
| 680 | + $ageMin = $age_min ?? null; | |
| 681 | + $ageMax = $age_max ?? null; | |
| 682 | + $accomType = (string) ($accommodation_type ?? ''); | |
| 683 | + $accomDetails = (string) ($accommodation_details ?? ''); | |
| 684 | + $mealPlan = (string) ($meal_plan ?? ''); | |
| 685 | + $transportInc = (bool) ($transportation_included ?? false); | |
| 686 | + $pickupLoc = (string) ($pickup_location ?? ''); | |
| 687 | + $dropoffLoc = (string) ($dropoff_location ?? ''); | |
| 688 | + $transportDet = (string) ($transportation_details ?? ''); | |
| 689 | + | |
| 690 | + $hasAgeInfo = ($ageMin !== null && $ageMin !== '') || ($ageMax !== null && $ageMax !== ''); | |
| 691 | + $hasAccom = $accomType !== '' || $accomDetails !== '' || $mealPlan !== ''; | |
| 692 | + $hasTransp = $transportInc || $pickupLoc !== '' || $dropoffLoc !== '' || $transportDet !== ''; | |
| 693 | + | |
| 694 | + $hasAnyImportantInfo = $physicalReq !== '' | |
| 695 | + || $visaReq !== '' | |
| 696 | + || $vaccinationReq !== '' | |
| 697 | + || $cancellation !== '' | |
| 698 | + || $hasAgeInfo | |
| 699 | + || $hasAccom | |
| 700 | + || $hasTransp | |
| 701 | + || $companyPhone !== ''; | |
| 702 | + ?> | |
| 703 | + <?php if ($hasAnyImportantInfo): ?> | |
| 296 | 704 | <div class="section"> |
| 297 | 705 | <div class="section-title"><?php esc_html_e('Important Information', 'yatra'); ?></div> |
| 298 | 706 | <table class="trip-details"> |
| 707 | + <?php if ($physicalReq !== ''): ?> | |
| 299 | 708 | <tr> |
| 300 | - <th class="label"><?php esc_html_e('What to Bring', 'yatra'); ?></th> | |
| 301 | - <td><?php esc_html_e('Comfortable clothing, walking shoes, sunscreen, hat, camera, personal medications, and any required travel documents.', 'yatra'); ?></td> | |
| 709 | + <th class="label"><?php esc_html_e('Physical Requirements', 'yatra'); ?></th> | |
| 710 | + <td><?php echo wp_kses_post(wpautop($physicalReq)); ?></td> | |
| 302 | 711 | </tr> |
| 712 | + <?php endif; ?> | |
| 713 | + | |
| 714 | + <?php if ($visaReq !== ''): ?> | |
| 303 | 715 | <tr> |
| 304 | - <th class="label"><?php esc_html_e('Weather Conditions', 'yatra'); ?></th> | |
| 305 | - <td><?php esc_html_e('Please check weather forecast before departure and pack accordingly. Weather conditions may vary by destination.', 'yatra'); ?></td> | |
| 716 | + <th class="label"><?php esc_html_e('Visa Requirements', 'yatra'); ?></th> | |
| 717 | + <td><?php echo wp_kses_post(wpautop($visaReq)); ?></td> | |
| 306 | 718 | </tr> |
| 719 | + <?php endif; ?> | |
| 720 | + | |
| 721 | + <?php if ($vaccinationReq !== ''): ?> | |
| 307 | 722 | <tr> |
| 723 | + <th class="label"><?php esc_html_e('Health & Vaccination', 'yatra'); ?></th> | |
| 724 | + <td><?php echo wp_kses_post(wpautop($vaccinationReq)); ?></td> | |
| 725 | + </tr> | |
| 726 | + <?php endif; ?> | |
| 727 | + | |
| 728 | + <?php if ($cancellation !== ''): ?> | |
| 729 | + <tr> | |
| 730 | + <th class="label"><?php esc_html_e('Cancellation Policy', 'yatra'); ?></th> | |
| 731 | + <td><?php echo wp_kses_post(wpautop($cancellation)); ?></td> | |
| 732 | + </tr> | |
| 733 | + <?php endif; ?> | |
| 734 | + | |
| 735 | + <?php if ($hasAgeInfo): | |
| 736 | + $ageParts = []; | |
| 737 | + if ($ageMin !== null && $ageMin !== '') { | |
| 738 | + /* translators: %d: minimum age in years. */ | |
| 739 | + $ageParts[] = sprintf(__('Min %d years', 'yatra'), (int) $ageMin); | |
| 740 | + } | |
| 741 | + if ($ageMax !== null && $ageMax !== '') { | |
| 742 | + /* translators: %d: maximum age in years. */ | |
| 743 | + $ageParts[] = sprintf(__('Max %d years', 'yatra'), (int) $ageMax); | |
| 744 | + } | |
| 745 | + ?> | |
| 746 | + <tr> | |
| 747 | + <th class="label"><?php esc_html_e('Age Requirements', 'yatra'); ?></th> | |
| 748 | + <td><?php echo esc_html(implode(' · ', $ageParts)); ?></td> | |
| 749 | + </tr> | |
| 750 | + <?php endif; ?> | |
| 751 | + | |
| 752 | + <?php if ($hasAccom): ?> | |
| 753 | + <tr> | |
| 754 | + <th class="label"><?php esc_html_e('Accommodation', 'yatra'); ?></th> | |
| 755 | + <td> | |
| 756 | + <?php if ($accomType !== ''): ?> | |
| 757 | + <div><?php echo esc_html($accomType); ?></div> | |
| 758 | + <?php endif; ?> | |
| 759 | + <?php if ($mealPlan !== ''): ?> | |
| 760 | + <div> | |
| 761 | + <?php | |
| 762 | + echo esc_html(sprintf( | |
| 763 | + /* translators: %s: meal plan label (e.g. "Breakfast included"). */ | |
| 764 | + __('Meal Plan: %s', 'yatra'), | |
| 765 | + function_exists('yatra_meal_plan_label') | |
| 766 | + ? yatra_meal_plan_label($mealPlan) | |
| 767 | + : $mealPlan | |
| 768 | + )); | |
| 769 | + ?> | |
| 770 | + </div> | |
| 771 | + <?php endif; ?> | |
| 772 | + <?php if ($accomDetails !== ''): ?> | |
| 773 | + <?php echo wp_kses_post(wpautop($accomDetails)); ?> | |
| 774 | + <?php endif; ?> | |
| 775 | + </td> | |
| 776 | + </tr> | |
| 777 | + <?php endif; ?> | |
| 778 | + | |
| 779 | + <?php if ($hasTransp): ?> | |
| 780 | + <tr> | |
| 781 | + <th class="label"><?php esc_html_e('Transportation', 'yatra'); ?></th> | |
| 782 | + <td> | |
| 783 | + <?php if ($transportInc): ?> | |
| 784 | + <div><?php esc_html_e('Transportation Included', 'yatra'); ?></div> | |
| 785 | + <?php endif; ?> | |
| 786 | + <?php if ($pickupLoc !== ''): ?> | |
| 787 | + <div> | |
| 788 | + <?php | |
| 789 | + /* translators: %s: pickup location for transportation. */ | |
| 790 | + echo esc_html(sprintf(__('Pickup: %s', 'yatra'), $pickupLoc)); | |
| 791 | + ?> | |
| 792 | + </div> | |
| 793 | + <?php endif; ?> | |
| 794 | + <?php if ($dropoffLoc !== ''): ?> | |
| 795 | + <div> | |
| 796 | + <?php | |
| 797 | + /* translators: %s: dropoff location for transportation. */ | |
| 798 | + echo esc_html(sprintf(__('Dropoff: %s', 'yatra'), $dropoffLoc)); | |
| 799 | + ?> | |
| 800 | + </div> | |
| 801 | + <?php endif; ?> | |
| 802 | + <?php if ($transportDet !== ''): ?> | |
| 803 | + <?php echo wp_kses_post(wpautop($transportDet)); ?> | |
| 804 | + <?php endif; ?> | |
| 805 | + </td> | |
| 806 | + </tr> | |
| 807 | + <?php endif; ?> | |
| 808 | + | |
| 809 | + <?php if ($companyPhone !== ''): ?> | |
| 810 | + <tr> | |
| 308 | 811 | <th class="label"><?php esc_html_e('Emergency Contact', 'yatra'); ?></th> |
| 309 | - <td><?php echo htmlspecialchars($companyPhone ?: __('Contact your travel agent', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></td> | |
| 812 | + <td><?php echo htmlspecialchars($companyPhone, ENT_QUOTES, 'UTF-8'); ?></td> | |
| 310 | 813 | </tr> |
| 814 | + <?php endif; ?> | |
| 311 | 815 | </table> |
| 312 | 816 | </div> |
| 817 | + <?php endif; ?> | |
| 313 | 818 | |
| 314 | - <!-- What's Included --> | |
| 315 | - <?php if (!empty($tripIncludes)): ?> | |
| 819 | + <!-- What's Included / Not Included. | |
| 820 | + Both lists run through the same shape-tolerant normaliser | |
| 821 | + so a stdClass entry can't crash the render the way it did | |
| 822 | + before (trim() on stdClass = PHP fatal). Empty lists hide | |
| 823 | + the whole section. --> | |
| 824 | + <?php $includesList = $yatra_normalize_list($tripIncludes); ?> | |
| 825 | + <?php if (!empty($includesList)): ?> | |
| 316 | 826 | <div class="section avoid-break"> |
| 317 | 827 | <div class="section-title"><?php esc_html_e('What\'s Included', 'yatra'); ?></div> |
| 318 | 828 | <div class="trip-description"> |
| 319 | - <?php | |
| 320 | - $includes = is_array($tripIncludes) ? $tripIncludes : explode("\n", $tripIncludes); | |
| 321 | - foreach ($includes as $include): | |
| 322 | - $include = trim($include); | |
| 323 | - if (!empty($include)): | |
| 324 | - ?> | |
| 325 | - <p>• <?php echo htmlspecialchars($include, ENT_QUOTES, 'UTF-8'); ?></p> | |
| 326 | - <?php | |
| 327 | - endif; | |
| 328 | - endforeach; | |
| 329 | - ?> | |
| 829 | + <?php foreach ($includesList as $include): ?> | |
| 830 | + <p>• <?php echo htmlspecialchars($include, ENT_QUOTES, 'UTF-8'); ?></p> | |
| 831 | + <?php endforeach; ?> | |
| 330 | 832 | </div> |
| 331 | 833 | </div> |
| 332 | 834 | <?php endif; ?> |
| 333 | 835 | |
| 334 | - <!-- What's Not Included --> | |
| 335 | - <?php if (!empty($tripExcludes)): ?> | |
| 836 | + <?php $excludesList = $yatra_normalize_list($tripExcludes); ?> | |
| 837 | + <?php if (!empty($excludesList)): ?> | |
| 336 | 838 | <div class="section avoid-break"> |
| 337 | 839 | <div class="section-title"><?php esc_html_e('What\'s Not Included', 'yatra'); ?></div> |
| 338 | 840 | <div class="trip-description"> |
| 339 | - <?php | |
| 340 | - $excludes = is_array($tripExcludes) ? $tripExcludes : explode("\n", $tripExcludes); | |
| 341 | - foreach ($excludes as $exclude): | |
| 342 | - $exclude = trim($exclude); | |
| 343 | - if (!empty($exclude)): | |
| 344 | - ?> | |
| 345 | - <p>• <?php echo htmlspecialchars($exclude, ENT_QUOTES, 'UTF-8'); ?></p> | |
| 346 | - <?php | |
| 347 | - endif; | |
| 348 | - endforeach; | |
| 349 | - ?> | |
| 841 | + <?php foreach ($excludesList as $exclude): ?> | |
| 842 | + <p>• <?php echo htmlspecialchars($exclude, ENT_QUOTES, 'UTF-8'); ?></p> | |
| 843 | + <?php endforeach; ?> | |
| 350 | 844 | </div> |
| 351 | 845 | </div> |
| 352 | 846 | <?php endif; ?> |
| 353 | 847 | |
| 354 | - <!-- Footer --> | |
| 355 | - <table class="footer"> | |
| 356 | - <tr> | |
| 357 | - <td style="width: 60%;"> | |
| 358 | - <p><strong><?php echo htmlspecialchars($companyName ?: __('Travel Company', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></strong></p> | |
| 359 | - <p><?php echo htmlspecialchars($companyAddress, ENT_QUOTES, 'UTF-8'); ?></p> | |
| 360 | - <p> | |
| 361 | - <?php | |
| 362 | - echo esc_html( | |
| 363 | - sprintf( | |
| 364 | - /* translators: 1: label "Email", 2: company email, 3: label "Phone", 4: company phone */ | |
| 365 | - __('%1$s: %2$s | %3$s: %4$s', 'yatra'), | |
| 366 | - __('Email', 'yatra'), | |
| 367 | - $companyEmail, | |
| 368 | - __('Phone', 'yatra'), | |
| 369 | - $companyPhone | |
| 370 | - ) | |
| 371 | - ); | |
| 372 | - ?> | |
| 373 | - </p> | |
| 374 | - </td> | |
| 375 | - <td style="width: 40%; text-align: right;"> | |
| 376 | - <p><?php esc_html_e('This is a computer-generated document.', 'yatra'); ?></p> | |
| 377 | - <p><?php esc_html_e('No signature required.', 'yatra'); ?></p> | |
| 378 | - </td> | |
| 379 | - </tr> | |
| 380 | - </table> | |
| 848 | + <!-- Final-page disclaimer. The repeating company contact and | |
| 849 | + page counter live in #page-footer (every page); this small | |
| 850 | + right-aligned block sits AFTER the last content section so | |
| 851 | + it only prints once, at the very end of the document. --> | |
| 852 | + <div style="margin-top: 8mm; padding-top: 3mm; border-top: 1px solid #e5e7eb; text-align: right; font-size: 9px; color: #9ca3af;"> | |
| 853 | + <div><?php esc_html_e('This is a computer-generated document.', 'yatra'); ?></div> | |
| 854 | + <div><?php esc_html_e('No signature required.', 'yatra'); ?></div> | |
| 855 | + </div> | |
| 381 | 856 | </div> |
| 857 | + | |
| 858 | + <!-- Per-page header + footer. | |
| 859 | + CRITICAL: this script tag MUST live at the END of body, after | |
| 860 | + every content section. Dompdf's `processPageScript()` (called | |
| 861 | + internally by `page_text()` / `page_script()` / `page_line()`) | |
| 862 | + iterates the pages that ALREADY EXIST when those methods are | |
| 863 | + invoked — pages are added to that list one-by-one as content | |
| 864 | + flows during layout. If the script ran at the top of body, | |
| 865 | + only page 1 would exist at register-time, so the header / | |
| 866 | + footer / page counter would apply to page 1 only and | |
| 867 | + {PAGE_COUNT} would resolve to "1" everywhere. Placing the | |
| 868 | + script after all sections means all pages have been laid out | |
| 869 | + by the time it runs, so the chrome applies uniformly and the | |
| 870 | + counter is accurate. --> | |
| 871 | + <script type="text/php"> | |
| 872 | + if (isset($pdf)) { | |
| 873 | + $headerLeft = <?php echo var_export($_headerLeft, true); ?>; | |
| 874 | + $headerRight = <?php echo var_export($_headerRight, true); ?>; | |
| 875 | + $footerLeft = <?php echo var_export($_footerLeft, true); ?>; | |
| 876 | + $pageLabel = <?php echo var_export($_pageLabel, true); ?>; | |
| 877 | + | |
| 878 | + $pageWidth = $pdf->get_width(); | |
| 879 | + $pageHeight = $pdf->get_height(); | |
| 880 | + // 51pt ≈ 18mm — matches the body's horizontal padding so the | |
| 881 | + // header/footer chrome lines up with the body content's | |
| 882 | + // left/right edges. (@page margin is 0; body padding emulates | |
| 883 | + // the page margin for us — Dompdf applies body horizontal | |
| 884 | + // padding per line which works reliably on every page.) | |
| 885 | + $marginL = 51; | |
| 886 | + $marginR = $pageWidth - 51; | |
| 887 | + | |
| 888 | + $font = $fontMetrics->get_font('Noto Sans Devanagari', 'normal') ?: $fontMetrics->get_font('DejaVu Sans', 'normal'); | |
| 889 | + $fontBold = $fontMetrics->get_font('Noto Sans Devanagari', 'bold') ?: $fontMetrics->get_font('DejaVu Sans', 'bold'); | |
| 890 | + | |
| 891 | + // ---- Per-page HEADER (skip page 1) ---- | |
| 892 | + // Header text y=22pt (≈7.7mm from top), separator y=38pt | |
| 893 | + // (≈13.4mm). Both sit safely inside the 18mm top margin | |
| 894 | + // band, above where body content starts (51pt). | |
| 895 | + $pdf->page_script( | |
| 896 | + 'if ($PAGE_NUM > 1) {' . | |
| 897 | + ' $fontBold = $fontMetrics->get_font("Noto Sans Devanagari", "bold") ?: $fontMetrics->get_font("DejaVu Sans", "bold");' . | |
| 898 | + ' $pdf->text(' . $marginL . ', 22, ' . var_export($headerLeft, true) . ', $fontBold, 9, [0.12, 0.25, 0.69]);' . | |
| 899 | + ' $hr = ' . var_export($headerRight, true) . ';' . | |
| 900 | + ' if ($hr !== "") {' . | |
| 901 | + ' $rw = $fontMetrics->getTextWidth($hr, $fontBold, 9);' . | |
| 902 | + ' $pdf->text(' . $marginR . ' - $rw, 22, $hr, $fontBold, 9, [0.07, 0.09, 0.15]);' . | |
| 903 | + ' }' . | |
| 904 | + ' $pdf->line(' . $marginL . ', 38, ' . $marginR . ', 38, [0.90, 0.91, 0.92], 0.5);' . | |
| 905 | + '}' | |
| 906 | + ); | |
| 907 | + | |
| 908 | + // ---- Per-page FOOTER (every page) ---- | |
| 909 | + // Footer text y=pageHeight-26 (≈9mm from bottom), separator | |
| 910 | + // line y=pageHeight-38 (≈13mm from bottom). page_line and | |
| 911 | + // page_text both queue per-page callbacks now that we're | |
| 912 | + // running with all pages already laid out. | |
| 913 | + $footerY = $pageHeight - 26; | |
| 914 | + $footerLineY = $pageHeight - 38; | |
| 915 | + $pdf->page_line($marginL, $footerLineY, $marginR, $footerLineY, [0.90, 0.91, 0.92], 0.5); | |
| 916 | + | |
| 917 | + if ($footerLeft !== '') { | |
| 918 | + $pdf->page_text($marginL, $footerY, $footerLeft, $font, 9, [0.42, 0.45, 0.50]); | |
| 919 | + } | |
| 920 | + | |
| 921 | + // Estimate the substituted text width against a max-width | |
| 922 | + // sample so the right-aligned page counter stays anchored. | |
| 923 | + $sample = $pageLabel . '00 / 00'; | |
| 924 | + $tw = $fontMetrics->getTextWidth($sample, $font, 9); | |
| 925 | + $pdf->page_text($marginR - $tw, $footerY, $pageLabel . '{PAGE_NUM} / {PAGE_COUNT}', $font, 9, [0.23, 0.27, 0.34]); | |
| 926 | + } | |
| 927 | + </script> | |
| 382 | 928 | </body> |
| 383 | 929 | </html> |