*/ 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 { $this->tempQrFiles = []; try { if (!class_exists('FPDF', false)) { require_once base_path('app/ThirdParty/fpdf/fpdf.php'); } $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->logoFilePath(); $rowsWithQr = []; $students = $this->studentLookupService->fetchForBadges($studentIds, $schoolYear); foreach ($students as $row) { try { $png = $this->renderQrPng((string) $row['school_id']); $tmpPath = $this->writeTempQrPng($png); if ($tmpPath === null) { continue; } $rowsWithQr[] = array_merge($row, [ '_badge_kind' => 'student', 'role_subline' => $studentRoleLabel, '_qr_tmp' => $tmpPath, ]); } 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); } $png = $this->renderQrPng($qrPayload); $tmpPath = $this->writeTempQrPng($png); if ($tmpPath === null) { continue; } $rowsWithQr[] = array_merge($staffRow, [ '_badge_kind' => 'staff', '_qr_tmp' => $tmpPath, ]); } catch (Throwable) { continue; } } $pdf = new \FPDF('L', 'in', 'Letter'); $pdf->SetAutoPageBreak(false); $pages = array_chunk($rowsWithQr, 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 (for students) school id 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) || empty($row['_qr_tmp'])) { continue; } $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 ); } } } } $binary = $pdf->Output('S'); $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"', ]); } 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); 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 ?? ''))); return [ 'user_id' => $resolvedId, 'user_id_for_qr' => (string) $resolvedId, 'fullname' => $info['name'] ?? 'STAFF', 'grade' => $gradeCol, 'academic_year' => $year !== '' ? $year : '—', 'school_id' => (string) $resolvedId, 'role_subline' => $display, ]; } protected function writeTempQrPng(string $png): ?string { $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; } /** * @param array $student */ 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 { $kind = (string) ($student['_badge_kind'] ?? 'student'); $idLabel = $kind === 'staff' ? 'USER ID' : 'SCHOOL ID'; $blue = [11, 58, 130]; $blueLight = [27, 76, 160]; $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'] ?? '')); $schoolNamePdf = $this->formatter->toPdf($schoolName); $mottoPdf = $this->formatter->toPdf($motto); $rolePdf = $this->formatter->toPdf((string) ($student['role_subline'] ?? $defaultStudentRoleLabel)); $pdf->SetDrawColor($blue[0], $blue[1], $blue[2]); $pdf->SetLineWidth(0.003); $pdf->Rect($x, $y, $w, $h); $headerH = 0.55; $footerH = 0.38; $pdf->SetFillColor($blue[0], $blue[1], $blue[2]); $pdf->Rect($x, $y, $w, $headerH, 'F'); $logoSize = 0.12; if ($logoPath !== null && is_readable($logoPath)) { try { $pdf->Image($logoPath, $x + $w / 2 - $logoSize / 2, $y + 0.035, $logoSize, $logoSize); } catch (Throwable) { } } $pdf->SetTextColor(255, 255, 255); $pdf->SetFont('Helvetica', 'B', 8.5); $pdf->SetXY($x + 0.06, $y + 0.035 + $logoSize + 0.01); $pdf->Cell($w - 0.12, 0.1, $schoolNamePdf, 0, 0, 'C'); if ($mottoPdf !== '') { $pdf->SetFont('Helvetica', 'B', 5.2); $pdf->SetXY($x + 0.06, $y + $headerH - 0.12); $pdf->Cell($w - 0.12, 0.08, $mottoPdf, 0, 0, 'C'); } $bodyTop = $y + $headerH + 0.06; $pdf->SetTextColor(11, 47, 115); $pdf->SetFont('Helvetica', 'B', 9.5); $pdf->SetXY($x + 0.07, $bodyTop); $pdf->Cell($w - 0.14, 0.11, strtoupper($fullName), 0, 1, 'C'); $pdf->SetTextColor(27, 86, 177); $pdf->SetFont('Helvetica', 'B', 6); $pdf->SetX($x + 0.07); $pdf->Cell($w - 0.14, 0.07, strtoupper($rolePdf), 0, 1, 'C'); $ruleY = $bodyTop + 0.22; $pdf->SetDrawColor(215, 222, 234); $pdf->Line($x + 0.08, $ruleY, $x + $w - 0.08, $ruleY); $infoLeft = $x + 0.08; $infoW = $w * 0.52; $labelY = $ruleY + 0.05; $gradeLabel = $kind === 'staff' ? 'CLASS / ASSIGNMENT' : 'GRADE'; $pdf->SetTextColor(51, 51, 51); $pdf->SetFont('Helvetica', 'B', 5); $pdf->SetXY($infoLeft, $labelY); $pdf->Cell($infoW, 0.05, $gradeLabel, 0, 1, 'L'); $pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]); $pdf->SetFont('Helvetica', 'B', 7.5); $pdf->SetX($infoLeft); $pdf->Cell($infoW, 0.08, $grade, 0, 1, 'L'); $pdf->SetTextColor(51, 51, 51); $pdf->SetFont('Helvetica', 'B', 5); $pdf->SetX($infoLeft); $pdf->Cell($infoW, 0.05, 'ACADEMIC YEAR', 0, 1, 'L'); $pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]); $pdf->SetFont('Helvetica', 'B', 7.5); $pdf->SetX($infoLeft); $pdf->Cell($infoW, 0.08, $year, 0, 1, 'L'); $pdf->SetTextColor(51, 51, 51); $pdf->SetFont('Helvetica', 'B', 5); $pdf->SetX($infoLeft); $pdf->Cell($infoW, 0.05, $idLabel, 0, 1, 'L'); $pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]); $pdf->SetFont('Helvetica', 'B', 7.5); $pdf->SetX($infoLeft); $pdf->MultiCell($infoW, 0.09, $schoolId, 0, 'L'); $qrSize = 0.38; $qrX = $x + $w - 0.08 - $qrSize; $qrY = $ruleY + 0.04; if ($qrPath !== '' && is_file($qrPath)) { try { $pdf->SetDrawColor($blueLight[0], $blueLight[1], $blueLight[2]); $pdf->Rect($qrX - 0.03, $qrY - 0.03, $qrSize + 0.06, $qrSize + 0.06); $pdf->Image($qrPath, $qrX, $qrY, $qrSize, $qrSize, 'PNG'); } catch (Throwable) { } } $pdf->SetTextColor($blueLight[0], $blueLight[1], $blueLight[2]); $pdf->SetFont('Helvetica', 'B', 4.5); $pdf->SetXY($qrX - 0.05, $qrY + $qrSize + 0.02); $pdf->Cell($qrSize + 0.1, 0.05, 'SCAN TO VERIFY', 0, 0, 'C'); $fy = $y + $h - $footerH; $pdf->SetFillColor($blue[0], $blue[1], $blue[2]); $pdf->Rect($x, $fy, $w, $footerH, 'F'); $smallLogo = 0.1; if ($logoPath !== null && is_readable($logoPath)) { try { $pdf->Image($logoPath, $x + $w / 2 - $smallLogo / 2, $fy + 0.03, $smallLogo, $smallLogo); } catch (Throwable) { } } $pdf->SetTextColor(255, 255, 255); $pdf->SetFont('Helvetica', 'B', 4.5); $lines = preg_split('/\r\n|\r|\n/', $footerBlock) ?: [$footerBlock]; $lineY = $fy + 0.03 + $smallLogo + 0.015; foreach ($lines as $line) { $line = trim($line); if ($line === '') { continue; } $pdf->SetXY($x + 0.04, $lineY); $pdf->Cell($w - 0.08, 0.055, $this->formatter->toPdf($line), 0, 1, 'C'); $lineY += 0.055; } } 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 logoFilePath(): ?string { return $this->formatter->firstExisting([ public_path('assets/images/school_logo.png'), public_path('assets/images/logo.png'), ]); } protected function footerBlock(string $schoolName): string { $address = trim((string) config('badges.footer_address', '')); if ($address !== '') { return $schoolName . "\n" . $address; } return $schoolName; } }