*/ private array $tempQrFiles = []; public function __construct( protected BadgeStudentLookupService $studentLookupService, protected BadgeUserLookupService $userLookupService, protected BadgePrintLogService $printLogService, protected BadgeTextFormatter $formatter, ) {} /** * Letter landscape PDF, 8 badges per page — students and/or staff (FPDF + QR PNG). * * @param int[] $studentIds Primary keys on `students.id` * @param int[] $userIds Staff/admin/teacher keys on `users.id` */ public function generate( array $studentIds, array $userIds, ?string $schoolYear, array $rolesMap = [], array $classesMap = [], ?int $actorId = null ): Response { $studentIds = $this->formatter->normalizeIds($studentIds); $userIds = $this->formatter->normalizeIds($userIds); $schoolName = (string) config('badges.school_name', 'Al Rahma Sunday School'); $motto = (string) config('badges.motto', ''); $studentRoleLabel = (string) config('badges.role_label', 'Student'); $footerBlock = $this->footerBlock($schoolName); $logoPath = $this->preparedLogoPath(); $rows = []; $students = $this->studentLookupService->fetchForBadges($studentIds, $schoolYear); foreach ($students as $row) { try { $qrPayload = (string) ($row['school_id'] ?? ''); $rows[] = array_merge($row, [ '_badge_kind' => 'student', 'role_subline' => $studentRoleLabel, 'show_grade' => true, 'grade_label' => 'Grade', '_qr_src' => $this->renderQrSvgDataUri($qrPayload), '_qr_base64' => $this->renderQrPngBase64($qrPayload), ]); } catch (Throwable) { continue; } } foreach ($userIds as $uid) { $staffRow = $this->buildStaffBadgeRow((int) $uid, $schoolYear, $rolesMap, $classesMap); if ($staffRow === null) { continue; } try { $qrPayload = (string) ($staffRow['user_id_for_qr'] ?? $staffRow['user_id'] ?? ''); if ($qrPayload === '' || ! preg_match('/^[A-Za-z0-9_-]+$/', $qrPayload)) { $qrPayload = (string) (int) ($staffRow['user_id'] ?? 0); } $rows[] = array_merge($staffRow, [ '_badge_kind' => 'staff', '_qr_src' => $this->renderQrSvgDataUri($qrPayload), '_qr_base64' => $this->renderQrPngBase64($qrPayload), ]); } catch (Throwable) { continue; } } try { // Dompdf is substantially heavier here and can fatally exhaust memory on larger badge runs. // Prefer the fixed-coordinate FPDF renderer first, and keep Dompdf only as a secondary fallback. $binary = $this->renderWithFpdf( $rows, $schoolName, $motto, $studentRoleLabel, $footerBlock, $logoPath ); } catch (Throwable) { $binary = $this->renderWithDompdf( $rows, $schoolName, $motto, $footerBlock ); } $logIds = array_values(array_unique(array_merge($studentIds, $userIds))); $this->printLogService->logSafely( userIds: $logIds, actorId: $actorId, schoolYear: $schoolYear, rolesMap: $rolesMap, classesMap: $classesMap, copies: 1 ); return response((string) $binary, 200, [ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'inline; filename="Badges.pdf"', ]); } protected function renderWithDompdf( array $rows, string $schoolName, string $motto, string $footerBlock ): string { $html = $this->buildHtmlDocument($rows, $schoolName, $motto, $footerBlock); $options = new Options; $options->set('isHtml5ParserEnabled', true); $options->set('isRemoteEnabled', true); $options->set('defaultFont', 'DejaVu Sans'); $dompdf = new Dompdf($options); $dompdf->loadHtml($html, 'UTF-8'); $dompdf->setPaper('A4', 'landscape'); $dompdf->render(); return $dompdf->output(); } protected function renderWithFpdf( array $rows, string $schoolName, string $motto, string $studentRoleLabel, string $footerBlock, ?string $logoPath ): string { $this->tempQrFiles = []; try { if (! class_exists('FPDF', false)) { require_once base_path('app/ThirdParty/fpdf/fpdf.php'); } $pdf = new \FPDF('L', 'in', 'Letter'); $pdf->SetAutoPageBreak(false); $pages = array_chunk($rows, 8); if ($pages === []) { $pdf->AddPage('L', 'Letter'); $pdf->SetFont('Helvetica', '', 12); $pdf->SetTextColor(0, 0, 0); $pdf->SetXY(self::PAGE_MARGIN_IN, self::PAGE_MARGIN_IN); $pdf->MultiCell( $pdf->GetPageWidth() - 2 * self::PAGE_MARGIN_IN, 0.3, $this->formatter->toPdf( 'No valid badges: check user/student ids, roles, or school ids for QR.' ), 0, 'L' ); } else { foreach ($pages as $pageRows) { $pdf->AddPage('L', 'Letter'); $slots = array_pad($pageRows, 8, null); for ($gridRow = 0; $gridRow < 2; $gridRow++) { for ($gridCol = 0; $gridCol < 4; $gridCol++) { $idx = ($gridRow * 4) + $gridCol; $row = $slots[$idx]; if (! is_array($row)) { continue; } $qrBase64 = trim((string) ($row['_qr_base64'] ?? '')); if ($qrBase64 === '') { continue; } $tmpPath = $this->writeTempQrPng(base64_decode($qrBase64, true) ?: ''); if ($tmpPath === null) { continue; } $row['_qr_tmp'] = $tmpPath; $cellX = self::PAGE_MARGIN_IN + $gridCol * self::CELL_WIDTH_IN; $cellY = self::PAGE_MARGIN_IN + $gridRow * self::CELL_HEIGHT_IN; $badgeX = $cellX + (self::CELL_WIDTH_IN - self::BADGE_WIDTH_IN) / 2; $badgeY = $cellY + (self::CELL_HEIGHT_IN - self::BADGE_HEIGHT_IN) / 2; $this->drawBadge( $pdf, $row, $badgeX, $badgeY, self::BADGE_WIDTH_IN, self::BADGE_HEIGHT_IN, $schoolName, $motto, $studentRoleLabel, $footerBlock, $logoPath ); } } } } return $pdf->Output('S'); } finally { foreach ($this->tempQrFiles as $path) { if (is_string($path) && $path !== '' && is_file($path)) { @unlink($path); } } $this->tempQrFiles = []; } } /** * @param array $rolesMap * @param array $classesMap * @return ?array */ protected function buildStaffBadgeRow( int $userId, ?string $schoolYear, array $rolesMap, array $classesMap ): ?array { $info = $this->userLookupService->getUserInfoById($userId, $schoolYear); if (! $info || ! is_array($info)) { return null; } $resolvedId = (int) ($info['user_id'] ?? $userId); $postedRole = $rolesMap[$resolvedId] ?? null; $roleResolved = $postedRole !== null ? (string) $postedRole : $this->formatter->resolveRole($info); $roleResolved = $this->formatter->formatRole($roleResolved); $info['role_resolved'] = $roleResolved; if (! empty($classesMap[$resolvedId])) { $info['class_section_name'] = (string) $classesMap[$resolvedId]; } $class = $this->formatter->norm($info['class_section_name'] ?? ''); $class = $this->formatter->formatClass($class); $detectSrc = strtolower($this->formatter->norm( ($info['roles'] ?? '') !== '' ? (string) $info['roles'] : $roleResolved )); $isTeacherish = str_contains($detectSrc, 'teacher') || preg_match('/\bta\b/', $detectSrc); $isAdmin = str_contains($detectSrc, 'admin'); if ($isTeacherish && $class !== '') { if (strtolower($class) === 'youth') { $display = 'Youth '.$roleResolved; } elseif ($class === 'KG') { $display = 'KG '.$roleResolved; } else { $display = 'Grade '.$class.' '.$roleResolved; } } elseif ($roleResolved !== '') { $display = $roleResolved; } elseif ($class !== '') { $display = $class; } else { $display = 'STAFF'; } $gradeCol = $class !== '' ? $class : '—'; $year = $this->formatter->norm((string) ($info['school_year'] ?? ($schoolYear ?? ''))); $schoolId = trim((string) ($info['school_id'] ?? '')); if ($schoolId === '' || ! preg_match('/^[A-Za-z0-9_-]+$/', $schoolId)) { return null; } return [ 'user_id' => $resolvedId, 'user_id_for_qr' => $schoolId, 'fullname' => $info['name'] ?? 'STAFF', 'grade' => $gradeCol, 'academic_year' => $year !== '' ? $year : '—', 'school_id' => $schoolId, 'role_subline' => $display, 'show_grade' => true, 'grade_label' => 'Class / Grade', ]; } protected function buildHtmlDocument( array $rows, string $schoolName, string $motto, string $footerBlock ): string { $logoDataUri = $this->logoDataUri(); $badgesHtml = ''; $pages = array_chunk($rows, 8); if ($pages === []) { $pages = [[]]; } foreach ($pages as $pageRows) { $badgesHtml .= '
'; for ($rowIndex = 0; $rowIndex < 2; $rowIndex++) { $badgesHtml .= ''; for ($colIndex = 0; $colIndex < 4; $colIndex++) { $slot = ($rowIndex * 4) + $colIndex; $badgesHtml .= ''; } $badgesHtml .= ''; } $badgesHtml .= '
'; if (isset($pageRows[$slot]) && is_array($pageRows[$slot])) { $badgesHtml .= $this->buildBadgeHtml($pageRows[$slot], $schoolName, $motto, $footerBlock, $logoDataUri); } $badgesHtml .= '
'; } return 'Badges PDF' .$badgesHtml .''; } protected function buildBadgeHtml( array $row, string $schoolName, string $motto, string $footerBlock, ?string $logoDataUri ): string { $kind = (string) ($row['_badge_kind'] ?? 'student'); $idLabel = $kind === 'staff' ? 'User ID' : 'School ID'; $title = $this->escapeHtml((string) ($row['fullname'] ?? '')); $role = $this->escapeHtml((string) ($row['role_subline'] ?? 'Student')); $gradeLabel = $this->escapeHtml((string) ($row['grade_label'] ?? ($kind === 'staff' ? 'Class / Assignment' : 'Grade'))); $grade = $this->escapeHtml((string) ($row['grade'] ?? '—')); $year = $this->escapeHtml((string) ($row['academic_year'] ?? '—')); $schoolId = $this->escapeHtml((string) ($row['school_id'] ?? '—')); $qrSrc = trim((string) ($row['_qr_src'] ?? '')); $footer = nl2br($this->escapeHtml($footerBlock)); $barcodeValue = preg_replace('/[^A-Za-z0-9]/', '', (string) ($row['school_id'] ?? '')); $barcodeValue = $barcodeValue !== '' ? $barcodeValue : 'ID0000'; $barcodeText = $this->escapeHtml(str_repeat($barcodeValue, 4)); $showGrade = (bool) ($row['show_grade'] ?? ($kind === 'student')); $headerMainTitle = $this->headerMainTitle($schoolName); $schoolLevel = $this->headerSubtitle($schoolName); $headerMotto = trim($motto) !== '' ? trim($motto) : trim((string) config('badges.motto_fallback', 'Learn • Lead • Succeed')); $infoRows = ''; if ($showGrade) { $infoRows .= '
G '.$gradeLabel.' '.$grade.'
'; } $infoRows .= '
Y Academic Year '.$year.'
ID '.$this->escapeHtml($idLabel).' '.$schoolId.'
'; return '
' .($logoDataUri !== null ? 'School Logo' : 'AS'). '
'.$this->escapeHtml($headerMainTitle).'
'.($schoolLevel !== '' ? '
'.$this->escapeHtml($schoolLevel).'
' : '').' '.($headerMotto !== '' ? '
'.$this->escapeHtml($headerMotto).'
' : '').'
'.mb_strtoupper($title, 'UTF-8').'
'.mb_strtoupper($role, 'UTF-8').'
'.$infoRows.'
' .($qrSrc !== '' ? 'QR Code' : ''). '
Scan to verify
'.$this->buildBarcodeHtml($barcodeValue).'
'.$barcodeText.'
'; } protected function badgeCss(): string { return <<<'CSS' @page { size: A4 landscape; margin: 0.18in; } * { box-sizing: border-box; } body { margin: 0; font-family: DejaVu Sans, Arial, Helvetica, sans-serif; color: #111111; } .page { width: 100%; page-break-after: always; } .page:last-child { page-break-after: auto; } .grid { width: 100%; border-collapse: collapse; table-layout: fixed; } .grid td { width: 25%; vertical-align: top; padding: 0.03in; height: 3.32in; } .badge { width: 2.18in; height: 3.26in; border: 1.2px solid #d8d8d8; border-radius: 0.18in; overflow: hidden; position: relative; margin: 0 auto; background: #ffffff; box-shadow: 0 0.03in 0.10in rgba(0, 0, 0, 0.16); } .header-wrap { position: relative; height: 0.96in; } .header { background: linear-gradient(135deg, #063979 0%, #082f68 55%, #05285a 100%); color: #ffffff; padding: 0.07in 0.08in 0.04in; height: 0.70in; border-radius: 0.18in 0.18in 0 0; } .header:after { content: ""; position: absolute; left: 0; right: 0; top: 0.64in; height: 0.08in; background: #8db7e8; clip-path: polygon(0 0, 50% 100%, 100% 0, 100% 25%, 50% 100%, 0 25%); } .header-accent { position: absolute; left: 0; right: 0; top: 0.67in; height: 0.20in; background: #07336f; clip-path: polygon(0 0, 50% 100%, 100% 0); } .header-tail { display: none; } .header-mark { float: left; width: 0.39in; height: 0.39in; border: 2px solid #ffffff; border-radius: 0.02in; text-align: center; overflow: hidden; background: transparent; margin-top: 0.02in; } .header-mark img { width: 100%; height: 100%; object-fit: cover; display: block; } .header-mark span { display: block; line-height: 0.38in; font-size: 11pt; font-weight: 800; } .header-copy { margin-left: 0.45in; text-align: center; } .school-name { font-size: 17.5pt; font-weight: 900; line-height: 0.90; margin: 0; text-transform: uppercase; letter-spacing: 1px; color: #ffffff; } .sub-school { font-size: 8.6pt; font-weight: 900; text-transform: uppercase; letter-spacing: 1.7px; margin-top: 0.03in; color: #ffffff; } .sub-school:before, .sub-school:after { content: ""; display: inline-block; width: 0.22in; border-top: 1px solid #ffffff; margin: 0 0.05in 0.025in; } .motto { font-size: 6.5pt; font-weight: 900; margin-top: 0.03in; letter-spacing: 0.8px; min-height: 0.08in; text-transform: uppercase; color: #ffd44a; } .motto:before, .motto:after { content: ""; display: inline-block; width: 0.20in; border-top: 1px solid #ffd44a; margin: 0 0.05in 0.02in; } .body { position: relative; height: 1.94in; padding: 0.09in 0.09in 0.39in; } .student-name { text-align: center; color: #052d6f; font-size: 14.0pt; font-weight: 900; line-height: 1.05; text-transform: uppercase; margin: 0.01in 0 0.03in; letter-spacing: 1px; } .name-divider { text-align: center; margin-bottom: 0.03in; } .divider-line { display: inline-block; width: 0.65in; border-top: 1px solid #5b92d1; vertical-align: middle; } .divider-dot { display: inline-block; width: 0.045in; height: 0.045in; margin: 0 0.045in; background: #064297; transform: rotate(45deg); vertical-align: middle; } .role { text-align: center; color: #063979; font-size: 7.4pt; font-weight: 900; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 0.07in; } .content { width: 100%; border-collapse: collapse; table-layout: fixed; margin-top: 0.01in; } .content td { vertical-align: top; } .info { width: 52%; padding-right: 0.07in; border-right: 1px solid #cfcfcf; } .info-row { margin-bottom: 0.07in; padding-bottom: 0.04in; border-bottom: 1px solid #d7d7d7; } .info-row:last-child { border-bottom: 0; margin-bottom: 0; padding-bottom: 0; } .icon-badge { display: inline-block; width: 0.22in; height: 0.22in; line-height: 0.22in; margin-right: 0.05in; border-radius: 50%; background: #064297; color: #ffffff; text-align: center; font-size: 5.0pt; font-weight: 900; vertical-align: top; } .info-copy { display: inline-block; width: 0.70in; } .label { display: block; font-size: 4.7pt; font-weight: 900; color: #111111; margin-bottom: 0.01in; text-transform: uppercase; } .value { display: block; font-size: 8.2pt; font-weight: 900; color: #064297; word-wrap: break-word; } .qr-col { width: 48%; text-align: center; padding-left: 0.08in; } .qr-box { display: inline-block; border: 1.5px solid #064297; border-radius: 0.06in; padding: 0.04in; background: #ffffff; min-width: 0.78in; min-height: 0.78in; } .qr-box img { width: 0.70in; height: 0.70in; display: block; } .scan-pill { margin-top: 0.035in; display: inline-block; background: #064297; color: #ffffff; border-radius: 0.03in; font-size: 4.8pt; font-weight: 900; text-transform: uppercase; padding: 0.035in 0.07in 0.025in; } .barcode-wrap { position: absolute; left: 0.10in; right: 0.10in; bottom: 0.06in; text-align: center; border-top: 1px solid #3d7fc6; padding-top: 0.05in; } .barcode-bars { height: 0.14in; line-height: 0; margin: 0 auto; white-space: nowrap; overflow: hidden; } .barcode-bar { display: inline-block; height: 0.14in; margin-right: 1px; background: #111111; } .barcode-text { display: none; } .footer { position: absolute; left: 0; right: 0; bottom: 0; background: linear-gradient(135deg, #064297 0%, #052b64 100%); color: #ffffff; padding: 0.055in 0.07in 0.05in; font-size: 5.2pt; font-weight: 700; line-height: 1.2; } .footer-mark { display: inline-block; width: 0.25in; height: 0.25in; line-height: 0.25in; margin-right: 0.07in; border: 1.5px solid #ffffff; border-radius: 50%; text-align: center; vertical-align: top; font-size: 7pt; overflow: hidden; background: transparent; } .footer-mark img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; display: block; } .footer-copy { display: inline-block; width: 1.65in; padding-left: 0.07in; border-left: 1px solid rgba(255,255,255,0.8); vertical-align: top; } CSS; } protected function buildBarcodeHtml(string $value): string { $seed = $value !== '' ? strtoupper($value) : 'ID0000'; $bars = []; foreach (str_split($seed) as $char) { $width = match (ord($char) % 4) { 0 => '1px', 1 => '2px', 2 => '3px', default => '4px', }; $bars[] = ''; $bars[] = ''; } return implode('', $bars); } protected function escapeHtml(string $value): string { return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); } protected function writeTempQrPng(string $png): ?string { if ($png === '') { return null; } $tmpPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'badge_qr_'.bin2hex(random_bytes(8)).'.png'; if (@file_put_contents($tmpPath, $png) === false) { return null; } $this->tempQrFiles[] = $tmpPath; return $tmpPath; } protected function drawBadge( \FPDF $pdf, array $student, float $x, float $y, float $w, float $h, string $schoolName, string $motto, string $defaultStudentRoleLabel, string $footerBlock, ?string $logoPath ): void { $templatePath = $this->badgeTemplatePath($student); if ($templatePath !== null) { $this->drawBadgeFromTemplate( $pdf, $student, $x, $y, $w, $h, $defaultStudentRoleLabel, $footerBlock, $templatePath ); return; } // Safety fallback: if the template PNGs are not deployed yet, still render a usable badge. $this->drawBadgeGeneratedFallback( $pdf, $student, $x, $y, $w, $h, $schoolName, $motto, $defaultStudentRoleLabel, $footerBlock, $logoPath ); } /** * Pick the static image template used as the badge background. * * Deploy these exact filenames to public/ or one of the supported public asset folders: * - student_template.png * - teacher_template.png * - admin_template.png */ protected function badgeTemplatePath(array $row): ?string { $kind = strtolower((string) ($row['_badge_kind'] ?? 'student')); $role = strtolower(trim((string) ($row['role_subline'] ?? $row['role_resolved'] ?? $row['roles'] ?? ''))); if ($kind === 'student') { return $this->firstExistingPublicTemplate('student_template.png'); } if (str_contains($role, 'admin')) { return $this->firstExistingPublicTemplate('admin_template.png'); } return $this->firstExistingPublicTemplate('teacher_template.png'); } protected function firstExistingPublicTemplate(string $filename): ?string { return $this->formatter->firstExisting([ public_path($filename), public_path('badges/'.$filename), public_path('assets/images/'.$filename), public_path('assets/images/badges/'.$filename), storage_path('app/public/'.$filename), storage_path('app/public/badges/'.$filename), ]); } protected function drawBadgeFromTemplate( \FPDF $pdf, array $row, float $x, float $y, float $w, float $h, string $defaultStudentRoleLabel, string $footerBlock, string $templatePath ): void { try { $pdf->Image($templatePath, $x, $y, $w, $h); } catch (Throwable) { return; } $kind = strtolower((string) ($row['_badge_kind'] ?? 'student')); $roleProbe = strtolower((string) ($row['role_subline'] ?? '')); $isAdmin = $kind !== 'student' && str_contains($roleProbe, 'admin'); $name = strtoupper($this->formatter->toPdf((string) ($row['fullname'] ?? ''))); $firstValue = $this->formatter->toPdf((string) ($row['grade'] ?? '')); $year = $this->formatter->toPdf((string) ($row['academic_year'] ?? '')); $id = $this->formatter->toPdf((string) ($row['school_id'] ?? '')); $qrPath = (string) ($row['_qr_tmp'] ?? ''); $barcodeValue = preg_replace('/[^A-Za-z0-9]/', '', (string) ($row['school_id'] ?? '')); $barcodeValue = $barcodeValue !== '' ? $barcodeValue : 'ID0000'; // Template coordinate map, in inches, relative to the badge's top-left corner. // The PNG owns the visual design. PHP only overlays dynamic data. $black = [18, 18, 18]; $white = [255, 255, 255]; // The new template leaves more white space above the name block, so start the overlay lower. $this->fitCenteredText($pdf, $name, $x + 0.15, $y + 1.24, $w - 0.30, 0.18, 15.0, 7.0, $black, 'B'); // Values beside/under the static labels baked into the template. $valueX = $x + 0.48; $valueW = 0.78; $valueYOffset = 0.18; $firstValueYOffset = 0.01; $this->fitLeftText($pdf, $firstValue, $valueX, $y + 2.02 + $valueYOffset + $firstValueYOffset, $valueW, 0.08, 8.0, 5.0, $black, 'B'); $this->fitLeftText($pdf, $year, $valueX, $y + 2.37 + $valueYOffset, $valueW, 0.08, 8.0, 5.0, $black, 'B'); $this->fitLeftText($pdf, $id, $valueX, $y + 2.72 + $valueYOffset, $valueW, 0.08, 8.0, 4.8, $black, 'B'); // QR overlay, inside the template frame. if ($qrPath !== '' && is_file($qrPath)) { try { $pdf->Image($qrPath, $x + 1.45, $y + 1.90, 0.66, 0.66, 'PNG'); } catch (Throwable) { } } // Barcode overlay. Repeat seed so the barcode fills the long template box instead of becoming a sad tiny fence. $pdf->SetFillColor(17, 17, 17); $this->drawBarcodeBars($pdf, str_repeat($barcodeValue, 6), $x + 0.35, $y + 3.07, $w - 0.70, 0.13); // Footer dynamic text. The template already contains the logo and divider. $pdf->SetTextColor($white[0], $white[1], $white[2]); $pdf->SetFont('Helvetica', 'B', 4.2); $lines = preg_split('/\r\n|\r|\n/', $footerBlock) ?: [$footerBlock]; $footerY = $y + $h - 0.25; foreach ($lines as $line) { $line = trim((string) $line); if ($line === '') { continue; } $pdf->SetXY($x + 0.57, $footerY); $pdf->Cell($w - 0.65, 0.05, $this->formatter->toPdf($line), 0, 1, 'L'); $footerY += 0.055; } } /** * Shrink text until it fits in the available width. FPDF, bravely, does not do this for us. * * @param array{0:int,1:int,2:int} $rgb */ protected function fitCenteredText( \FPDF $pdf, string $text, float $x, float $y, float $w, float $h, float $maxPt, float $minPt, array $rgb, string $style = '' ): float { $text = trim($text); if ($text === '') { return $y; } $size = $maxPt; $lines = $this->splitCenteredTextIntoTwoLines($text) ?? [$text]; do { $pdf->SetFont('Helvetica', $style, $size); $maxLineWidth = 0.0; foreach ($lines as $line) { $maxLineWidth = max($maxLineWidth, $pdf->GetStringWidth($line)); } if ($maxLineWidth <= $w || $size <= $minPt) { break; } $size -= 0.5; } while ($size >= $minPt); $pdf->SetFont('Helvetica', $style, $size); $pdf->SetTextColor($rgb[0], $rgb[1], $rgb[2]); $lineSpacing = $h * 1.18; foreach ($lines as $index => $line) { $pdf->SetXY($x, $y + ($index * $lineSpacing)); $pdf->Cell($w, $h, $line, 0, 0, 'C'); } return $y + ((count($lines) - 1) * $lineSpacing) + $h; } /** * @return array{0:string,1:string}|null */ protected function splitCenteredTextIntoTwoLines(string $text): ?array { $words = array_values(array_filter(explode(' ', preg_replace('/\s+/', ' ', trim($text)) ?: ''), 'strlen')); if (count($words) < 2) { return null; } return [ $words[0], implode(' ', array_slice($words, 1)), ]; } /** * @param array{0:int,1:int,2:int} $rgb */ protected function fitLeftText( \FPDF $pdf, string $text, float $x, float $y, float $w, float $h, float $maxPt, float $minPt, array $rgb, string $style = '' ): void { $text = trim($text); if ($text === '') { return; } $size = $maxPt; do { $pdf->SetFont('Helvetica', $style, $size); if ($pdf->GetStringWidth($text) <= $w || $size <= $minPt) { break; } $size -= 0.5; } while ($size >= $minPt); $pdf->SetTextColor($rgb[0], $rgb[1], $rgb[2]); $pdf->SetXY($x, $y); $pdf->Cell($w, $h, $text, 0, 0, 'L'); } protected function drawBadgeGeneratedFallback( \FPDF $pdf, array $student, float $x, float $y, float $w, float $h, string $schoolName, string $motto, string $defaultStudentRoleLabel, string $footerBlock, ?string $logoPath ): void { $kind = (string) ($student['_badge_kind'] ?? 'student'); $idLabel = $kind === 'staff' ? 'USER ID' : 'SCHOOL ID'; $showGrade = (bool) ($student['show_grade'] ?? ($kind === 'student')); $gradeLabel = (string) ($student['grade_label'] ?? ($kind === 'staff' ? 'CLASS / ASSIGNMENT' : 'GRADE')); $green = [6, 66, 151]; $greenDark = [5, 43, 100]; $greenAccent = [255, 212, 74]; $blueRole = [6, 57, 121]; $qrPath = (string) ($student['_qr_tmp'] ?? ''); $fullName = $this->formatter->toPdf((string) ($student['fullname'] ?? '')); $grade = $this->formatter->toPdf((string) ($student['grade'] ?? '')); $year = $this->formatter->toPdf((string) ($student['academic_year'] ?? '')); $schoolId = $this->formatter->toPdf((string) ($student['school_id'] ?? '')); $barcodeValue = preg_replace('/[^A-Za-z0-9]/', '', (string) ($student['school_id'] ?? '')); $barcodeValue = $barcodeValue !== '' ? $barcodeValue : 'ID0000'; $barcodeText = $this->formatter->toPdf(str_repeat($barcodeValue, 4)); $schoolNamePdf = $this->formatter->toPdf($this->headerMainTitle($schoolName)); $headerSubtitlePdf = $this->formatter->toPdf($this->headerSubtitle($schoolName)); $mottoValue = trim($motto) !== '' ? $motto : (string) config('badges.motto_fallback', 'Learn • Lead • Succeed'); $mottoPdf = $this->formatter->toPdf($mottoValue); $rolePdf = $this->formatter->toPdf((string) ($student['role_subline'] ?? $defaultStudentRoleLabel)); $pdf->SetDrawColor($green[0], $green[1], $green[2]); $pdf->SetLineWidth(0.01); $pdf->Rect($x, $y, $w, $h); $headerH = 0.66; $footerH = 0.36; $pdf->SetFillColor($greenDark[0], $greenDark[1], $greenDark[2]); $pdf->Rect($x, $y, $w, $headerH, 'F'); $pdf->SetFillColor($green[0], $green[1], $green[2]); $pdf->Rect($x, $y + 0.04, $w, 0.20, 'F'); $pdf->SetFillColor(141, 183, 232); $pdf->Rect($x, $y + $headerH - 0.03, $w, 0.03, 'F'); $sealSize = 0.33; $sealX = $x + 0.09; $sealY = $y + 0.07; $pdf->SetFillColor(255, 255, 255); $pdf->Rect($sealX, $sealY, $sealSize, $sealSize, 'F'); $pdf->SetDrawColor(255, 255, 255); $pdf->Rect($sealX, $sealY, $sealSize, $sealSize, 'D'); if ($logoPath !== null && is_readable($logoPath)) { try { $pdf->Image($logoPath, $sealX + 0.02, $sealY + 0.02, $sealSize - 0.04, $sealSize - 0.04); } catch (Throwable) { } } $pdf->SetTextColor(255, 255, 255); $pdf->SetFont('Helvetica', 'B', 13.0); $pdf->SetXY($x + 0.45, $y + 0.10); $pdf->Cell($w - 0.55, 0.12, strtoupper($schoolNamePdf), 0, 1, 'C'); $pdf->SetFont('Helvetica', 'B', 7.2); $pdf->SetXY($x + 0.45, $y + 0.27); $pdf->Cell($w - 0.55, 0.06, strtoupper($headerSubtitlePdf), 0, 1, 'C'); if ($mottoPdf !== '') { $pdf->SetFont('Helvetica', 'B', 5.6); $pdf->SetTextColor($greenAccent[0], $greenAccent[1], $greenAccent[2]); $pdf->SetXY($x + 0.45, $y + 0.40); $pdf->Cell($w - 0.55, 0.06, strtoupper($mottoPdf), 0, 1, 'C'); } $bodyTop = $y + $headerH + 0.02; $nameBottomY = $this->fitCenteredText( $pdf, strtoupper($fullName), $x + 0.07, $bodyTop, $w - 0.14, 0.11, 13.2, 7.0, [18, 18, 18], 'B' ); $pdf->SetTextColor(18, 18, 18); $pdf->SetFont('Helvetica', 'B', 6); $roleTop = $nameBottomY + 0.01; $pdf->SetXY($x + 0.07, $roleTop); $pdf->Cell($w - 0.14, 0.07, strtoupper($rolePdf), 0, 1, 'C'); $ruleY = $roleTop + 0.10; $pdf->SetDrawColor(210, 214, 210); $pdf->Line($x + 0.10, $ruleY, $x + 0.58, $ruleY); $pdf->Line($x + $w - 0.58, $ruleY, $x + $w - 0.10, $ruleY); $infoLeft = $x + 0.08; $infoW = $w * 0.45; $labelY = $ruleY + 0.05; $infoRows = []; if ($showGrade) { $infoRows[] = [$gradeLabel, $grade]; } $infoRows[] = ['ACADEMIC YEAR', $year]; $infoRows[] = [$idLabel, $schoolId]; foreach ($infoRows as $index => [$label, $value]) { $iconY = $labelY + 0.03; $pdf->SetFillColor($green[0], $green[1], $green[2]); $pdf->SetDrawColor($green[0], $green[1], $green[2]); $pdf->Rect($infoLeft, $iconY - 0.07, 0.14, 0.14, 'F'); $pdf->SetTextColor(255, 255, 255); $pdf->SetFont('Helvetica', 'B', 5); $iconLabel = $label === 'ACADEMIC YEAR' ? 'Y' : ($label === $idLabel ? 'ID' : 'G'); $pdf->SetXY($infoLeft + 0.01, $iconY - 0.03); $pdf->Cell(0.12, 0.06, $iconLabel, 0, 0, 'C'); $textLeft = $infoLeft + 0.16; $pdf->SetTextColor(51, 51, 51); $pdf->SetFont('Helvetica', 'B', 5); $pdf->SetXY($textLeft, $labelY); $pdf->Cell($infoW, 0.05, $label, 0, 1, 'L'); $pdf->SetTextColor(17, 17, 17); $pdf->SetFont('Helvetica', 'B', 7.5); $pdf->SetX($textLeft); if ($index === array_key_last($infoRows)) { $pdf->MultiCell($infoW, 0.09, $value, 0, 'L'); $labelY = $pdf->GetY() + 0.015; } else { $pdf->Cell($infoW, 0.08, $value, 0, 1, 'L'); $labelY = $pdf->GetY() + 0.01; } } $dividerX = $x + ($w * 0.53); $pdf->SetDrawColor(214, 214, 214); $pdf->Line($dividerX, $ruleY + 0.02, $dividerX, $y + $h - $footerH - 0.30); $qrSize = 0.48; $qrX = $x + $w - 0.18 - $qrSize; $qrY = $ruleY + 0.07; if ($qrPath !== '' && is_file($qrPath)) { try { $pdf->SetDrawColor($green[0], $green[1], $green[2]); $pdf->Rect($qrX - 0.05, $qrY - 0.05, $qrSize + 0.10, $qrSize + 0.10, 'D'); $pdf->Image($qrPath, $qrX, $qrY, $qrSize, $qrSize, 'PNG'); } catch (Throwable) { } } $pillW = 0.56; $pillH = 0.10; $pillX = $qrX + ($qrSize / 2) - ($pillW / 2); $pillY = $qrY + $qrSize + 0.06; $pdf->SetFillColor($green[0], $green[1], $green[2]); $pdf->Rect($pillX, $pillY, $pillW, $pillH, 'F'); $pdf->SetTextColor(255, 255, 255); $pdf->SetFont('Helvetica', 'B', 4.4); $pdf->SetXY($pillX, $pillY + 0.02); $pdf->Cell($pillW, 0.05, 'SCAN TO VERIFY', 0, 0, 'C'); $barcodeY = $y + $h - $footerH - 0.34; $barsX = $x + 0.14; $barsW = $w - 0.28; $pdf->SetDrawColor(214, 221, 216); $pdf->Line($barsX, $barcodeY - 0.03, $barsX + $barsW, $barcodeY - 0.03); $this->drawBarcodeBars($pdf, $barcodeValue, $barsX, $barcodeY, $barsW, 0.14); $pdf->SetTextColor(17, 17, 17); $pdf->SetFont('Helvetica', 'B', 5.6); $pdf->SetXY($barsX, $barcodeY + 0.16); $pdf->Cell($barsW, 0.05, $barcodeText, 0, 0, 'C'); $fy = $y + $h - $footerH; $pdf->SetFillColor($green[0], $green[1], $green[2]); $pdf->Rect($x, $fy, $w, $footerH, 'F'); if ($logoPath !== null && is_readable($logoPath)) { try { $smallLogo = 0.16; $pdf->SetFillColor(255, 255, 255); $pdf->Rect($x + 0.09, $fy + 0.05, 0.18, 0.18, 'F'); $pdf->Image($logoPath, $x + 0.10, $fy + 0.06, $smallLogo, $smallLogo); } catch (Throwable) { } } $pdf->SetTextColor(255, 255, 255); $pdf->SetFont('Helvetica', 'B', 4.3); $lines = preg_split('/\r\n|\r|\n/', $footerBlock) ?: [$footerBlock]; $lineY = $fy + 0.08; $textX = $x + 0.30; $textW = $w - 0.36; foreach ($lines as $line) { $line = trim($line); if ($line === '') { continue; } $pdf->SetXY($textX, $lineY); $pdf->Cell($textW, 0.055, $this->formatter->toPdf($line), 0, 1, 'L'); $lineY += 0.055; } } protected function drawBarcodeBars( \FPDF $pdf, string $value, float $x, float $y, float $maxWidth, float $height ): void { $seed = $value !== '' ? strtoupper($value) : 'ID0000'; $gap = 0.01; $segments = []; $usedWidth = 0.0; foreach (str_split($seed) as $char) { $width = match (ord($char) % 4) { 0 => 0.01, 1 => 0.015, 2 => 0.02, default => 0.025, }; $barWidth = $width; $thinBarWidth = 0.01; $nextWidth = ($segments === [] ? 0.0 : $gap) + $barWidth + $gap + $thinBarWidth; if (($usedWidth + $nextWidth) > $maxWidth) { break; } if ($segments !== []) { $segments[] = ['gap', $gap]; $usedWidth += $gap; } $segments[] = ['bar', $barWidth]; $usedWidth += $barWidth; $segments[] = ['gap', $gap]; $usedWidth += $gap; $segments[] = ['bar', $thinBarWidth]; $usedWidth += $thinBarWidth; } $cursor = $x + max(($maxWidth - $usedWidth) / 2, 0.0); foreach ($segments as [$type, $segmentWidth]) { if ($type === 'gap') { $cursor += $segmentWidth; continue; } $pdf->SetFillColor(17, 17, 17); $pdf->Rect($cursor, $y, $segmentWidth, $height, 'F'); $cursor += $segmentWidth; } } protected function renderQrPng(string $payload): string { $qrOptions = new QROptions([ 'version' => 5, 'outputInterface' => QRGdImagePNG::class, 'outputBase64' => false, 'eccLevel' => QRCode::ECC_M, 'scale' => 4, ]); $qr = new QRCode($qrOptions); return $qr->render($payload); } protected function renderQrPngBase64(string $payload): string { try { return base64_encode($this->renderQrPng($payload)); } catch (Throwable) { return ''; } } protected function renderQrSvgDataUri(string $payload): string { $qrOptions = new QROptions([ 'version' => 5, 'outputInterface' => QRMarkupSVG::class, 'outputBase64' => true, 'eccLevel' => QRCode::ECC_M, 'drawLightModules' => false, 'svgAddXmlHeader' => false, 'connectPaths' => true, ]); $qr = new QRCode($qrOptions); return $qr->render($payload); } protected function logoFilePath(): ?string { return $this->formatter->firstExisting([ public_path('logo-circle.png'), public_path('logo.png'), public_path('assets/images/school_logo.png'), public_path('assets/images/logo.png'), ]); } protected function preparedLogoPath(): ?string { $path = $this->logoFilePath(); if ($path === null || ! is_readable($path)) { return null; } $png = $this->makeCircularPngBytesFromPath($path); if ($png === null) { return $path; } $tmpPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'badge_logo_'.bin2hex(random_bytes(8)).'.png'; if (@file_put_contents($tmpPath, $png) === false) { return $path; } $this->tempQrFiles[] = $tmpPath; return $tmpPath; } protected function logoDataUri(): ?string { $path = $this->logoFilePath(); if ($path === null || ! is_readable($path)) { return null; } $bytes = $this->makeCircularPngBytesFromPath($path); if ($bytes === null) { $bytes = @file_get_contents($path); } if ($bytes === false || $bytes === '') { return null; } return 'data:image/png;base64,'.base64_encode($bytes); } protected function headerMainTitle(string $schoolName): string { $name = trim($schoolName) !== '' ? trim($schoolName) : 'Al Rahma Sunday School'; $normalized = preg_replace('/\s+/', ' ', $name) ?: $name; if (preg_match('/^(.*?)(\s+Sunday\s+School)$/i', $normalized, $matches)) { return trim($matches[1]); } return $normalized; } protected function headerSubtitle(string $schoolName): string { $configured = trim((string) config('badges.header_subtitle', '')); if ($configured !== '') { return $configured; } $name = trim($schoolName) !== '' ? trim($schoolName) : 'Al Rahma Sunday School'; if (preg_match('/Sunday\s+School/i', $name)) { return 'Sunday School'; } return 'High School'; } protected function footerBlock(string $schoolName): string { $address = trim((string) config('badges.footer_address', '')); if ($address !== '') { return $schoolName."\n".$address; } return $schoolName; } protected function makeCircularPngBytesFromPath(string $path): ?string { if (! function_exists('imagecreatetruecolor') || ! function_exists('imagepng')) { return null; } $bytes = @file_get_contents($path); if ($bytes === false || $bytes === '') { return null; } $source = @imagecreatefromstring($bytes); if ($source === false) { return null; } try { $srcW = imagesx($source); $srcH = imagesy($source); if ($srcW <= 0 || $srcH <= 0) { return null; } $size = min($srcW, $srcH); $srcX = (int) floor(($srcW - $size) / 2); $srcY = (int) floor(($srcH - $size) / 2); $square = imagecreatetruecolor($size, $size); if ($square === false) { return null; } imagealphablending($square, false); imagesavealpha($square, true); $transparent = imagecolorallocatealpha($square, 0, 0, 0, 127); imagefill($square, 0, 0, $transparent); imagecopyresampled($square, $source, 0, 0, $srcX, $srcY, $size, $size, $size, $size); $circle = imagecreatetruecolor($size, $size); if ($circle === false) { imagedestroy($square); return null; } imagealphablending($circle, false); imagesavealpha($circle, true); $circleTransparent = imagecolorallocatealpha($circle, 0, 0, 0, 127); imagefill($circle, 0, 0, $circleTransparent); $radius = $size / 2; for ($x = 0; $x < $size; $x++) { for ($y = 0; $y < $size; $y++) { $dx = ($x + 0.5) - $radius; $dy = ($y + 0.5) - $radius; if ((($dx * $dx) + ($dy * $dy)) <= ($radius * $radius)) { $color = imagecolorat($square, $x, $y); imagesetpixel($circle, $x, $y, $color); } } } ob_start(); imagepng($circle); $png = ob_get_clean(); imagedestroy($square); imagedestroy($circle); return is_string($png) && $png !== '' ? $png : null; } finally { imagedestroy($source); } } }