Files
alrahma_sunday_school_api/app/Services/Badges/BadgePdfService.php
T
2026-06-04 20:49:14 -04:00

1325 lines
40 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Badges;
use chillerlan\QRCode\Output\QRGdImagePNG;
use chillerlan\QRCode\Output\QRMarkupSVG;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use Dompdf\Dompdf;
use Dompdf\Options;
use Illuminate\Http\Response;
use Throwable;
class BadgePdfService
{
private const BADGE_WIDTH_IN = 2.36;
private const BADGE_HEIGHT_IN = 3.54;
private const CELL_WIDTH_IN = 2.55;
private const CELL_HEIGHT_IN = 3.85;
private const PAGE_MARGIN_IN = 0.18;
/** @var list<string> */
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<string, mixed> $rolesMap
* @param array<string, mixed> $classesMap
* @return ?array<string, mixed>
*/
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 .= '<div class="page"><table class="grid">';
for ($rowIndex = 0; $rowIndex < 2; $rowIndex++) {
$badgesHtml .= '<tr>';
for ($colIndex = 0; $colIndex < 4; $colIndex++) {
$slot = ($rowIndex * 4) + $colIndex;
$badgesHtml .= '<td>';
if (isset($pageRows[$slot]) && is_array($pageRows[$slot])) {
$badgesHtml .= $this->buildBadgeHtml($pageRows[$slot], $schoolName, $motto, $footerBlock, $logoDataUri);
}
$badgesHtml .= '</td>';
}
$badgesHtml .= '</tr>';
}
$badgesHtml .= '</table></div>';
}
return '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Badges PDF</title><style>'
. $this->badgeCss()
. '</style></head><body>'
. $badgesHtml
. '</body></html>';
}
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'));
$schoolLevel = trim((string) config('badges.header_subtitle', ''));
$headerMotto = trim($motto) !== ''
? trim($motto)
: trim((string) config('badges.motto_fallback', ''));
$infoRows = '';
if ($showGrade) {
$infoRows .= '
<div class="info-row">
<span class="icon-badge">G</span>
<span class="info-copy">
<span class="label">' . $gradeLabel . '</span>
<span class="value">' . $grade . '</span>
</span>
</div>';
}
$infoRows .= '
<div class="info-row">
<span class="icon-badge">Y</span>
<span class="info-copy">
<span class="label">Academic Year</span>
<span class="value">' . $year . '</span>
</span>
</div>
<div class="info-row">
<span class="icon-badge">ID</span>
<span class="info-copy">
<span class="label">' . $this->escapeHtml($idLabel) . '</span>
<span class="value">' . $schoolId . '</span>
</span>
</div>';
return '
<div class="badge">
<div class="header-wrap">
<div class="header">
<div class="header-mark">'
. ($logoDataUri !== null
? '<img src="' . $logoDataUri . '" alt="School Logo">'
: '<span>AS</span>') .
'</div>
<div class="header-copy">
<div class="school-name">' . $this->escapeHtml($schoolName) . '</div>
' . ($schoolLevel !== ''
? '<div class="sub-school">' . $this->escapeHtml($schoolLevel) . '</div>'
: '') . '
' . ($headerMotto !== ''
? '<div class="motto">' . $this->escapeHtml($headerMotto) . '</div>'
: '') . '
</div>
</div>
<div class="header-accent"></div>
<div class="header-tail"></div>
</div>
<div class="body">
<div class="student-name">' . mb_strtoupper($title, 'UTF-8') . '</div>
<div class="name-divider">
<span class="divider-line"></span>
<span class="divider-dot"></span>
<span class="divider-line"></span>
</div>
<div class="role">' . mb_strtoupper($role, 'UTF-8') . '</div>
<table class="content">
<tr>
<td class="info">
' . $infoRows . '
</td>
<td class="qr-col">
<div class="qr-box">'
. ($qrSrc !== ''
? '<img src="' . $qrSrc . '" alt="QR Code">'
: '') .
'</div>
<div class="scan-pill">Scan to verify</div>
</td>
</tr>
</table>
<div class="barcode-wrap">
<div class="barcode-bars">' . $this->buildBarcodeHtml($barcodeValue) . '</div>
<div class="barcode-text">' . $barcodeText . '</div>
</div>
</div>
<div class="footer">
<span class="footer-mark">'
. ($logoDataUri !== null
? '<img src="' . $logoDataUri . '" alt="School Logo">'
: '<span>S</span>') .
'</span>
<span class="footer-copy">' . $footer . '</span>
</div>
</div>
';
}
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: #111;
}
.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.5px solid #166833;
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.10);
}
.header-wrap {
position: relative;
height: 0.80in;
}
.header {
background: linear-gradient(135deg, #0d6b32 0%, #13763a 45%, #0d5a29 100%);
color: #ffffff;
padding: 0.06in 0.08in 0.04in;
height: 0.58in;
border-radius: 0.18in 0.18in 0 0;
}
.header-accent {
position: absolute;
left: 0.36in;
right: 0.36in;
top: 0.45in;
height: 0.012in;
background: rgba(255, 211, 74, 0.45);
}
.header-tail {
width: 1.14in;
height: 0.10in;
margin: -0.01in auto 0;
background: #13763a;
border-radius: 0 0 0.04in 0.04in;
}
.header-mark {
float: left;
width: 0.30in;
height: 0.30in;
border: 2px solid #ffffff;
border-radius: 50%;
text-align: center;
overflow: hidden;
background: transparent;
}
.header-mark img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
display: block;
}
.header-mark span {
display: block;
line-height: 0.36in;
font-size: 12pt;
font-weight: 800;
}
.header-copy {
margin-left: 0.38in;
text-align: center;
}
.school-name {
font-size: 7.2pt;
font-weight: 800;
line-height: 1.1;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sub-school {
font-size: 5.0pt;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 1.9px;
margin-top: 0.03in;
}
.motto {
font-size: 4.4pt;
font-weight: 700;
margin-top: 0.03in;
letter-spacing: 0.4px;
min-height: 0.08in;
text-transform: uppercase;
color: #f4d34c;
}
.body {
position: relative;
height: 2.18in;
padding: 0.02in 0.09in 0.42in;
}
.student-name {
text-align: center;
color: #15512c;
font-size: 9.6pt;
font-weight: 800;
line-height: 1.05;
text-transform: uppercase;
margin: 0.03in 0 0.02in;
}
.name-divider {
text-align: center;
margin-bottom: 0.02in;
}
.divider-line {
display: inline-block;
width: 0.48in;
border-top: 1px solid #cfd6d1;
vertical-align: middle;
}
.divider-dot {
display: inline-block;
width: 0.04in;
height: 0.04in;
margin: 0 0.04in;
background: #1f6b3a;
transform: rotate(45deg);
vertical-align: middle;
}
.role {
text-align: center;
color: #1f48b3;
font-size: 5.5pt;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 1.1px;
margin-bottom: 0.05in;
}
.content {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 0.01in;
}
.content td {
vertical-align: top;
}
.info {
width: 58%;
padding-right: 0.04in;
border-right: 1px solid #d6d6d6;
}
.info-row {
margin-bottom: 0.06in;
padding-bottom: 0.00in;
}
.info-row:last-child {
border-bottom: 0;
margin-bottom: 0;
padding-bottom: 0;
}
.icon-badge {
display: inline-block;
width: 0.18in;
height: 0.18in;
line-height: 0.18in;
margin-right: 0.03in;
border-radius: 50%;
background: #156733;
color: #ffffff;
text-align: center;
font-size: 4.8pt;
font-weight: 800;
vertical-align: top;
}
.info-copy {
display: inline-block;
width: 0.78in;
}
.label {
display: block;
font-size: 4.5pt;
font-weight: 700;
color: #222222;
margin-bottom: 0.01in;
}
.value {
display: block;
font-size: 7.0pt;
font-weight: 800;
color: #111111;
word-wrap: break-word;
}
.qr-col {
width: 42%;
text-align: center;
padding-left: 0.04in;
}
.qr-box {
display: inline-block;
border: 1px solid #1f6b3a;
border-radius: 0.08in;
padding: 0.03in;
background: #ffffff;
min-width: 0.76in;
min-height: 0.76in;
box-shadow: inset 0 0 0 1px #dce8df;
}
.qr-box img {
width: 0.70in;
height: 0.70in;
display: block;
}
.scan-pill {
margin-top: 0.025in;
display: inline-block;
background: #156733;
color: #ffffff;
border-radius: 999px;
font-size: 4.5pt;
font-weight: 700;
text-transform: uppercase;
padding: 0.025in 0.06in 0.02in;
}
.barcode-wrap {
position: absolute;
left: 0.10in;
right: 0.10in;
bottom: 0.06in;
text-align: center;
border-top: 1px solid #d6ddd8;
padding-top: 0.06in;
}
.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 {
margin-top: 0.03in;
text-align: center;
font-size: 5.8pt;
font-weight: 700;
color: #111111;
}
.footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #0d6b32 0%, #0b5c2b 100%);
color: #ffffff;
padding: 0.05in 0.06in 0.05in;
font-size: 4.3pt;
font-weight: 700;
line-height: 1.2;
}
.footer-mark {
display: inline-block;
width: 0.15in;
height: 0.15in;
line-height: 0.15in;
margin-right: 0.05in;
border: 1.5px solid #ffffff;
border-radius: 50%;
text-align: center;
vertical-align: top;
font-size: 6pt;
overflow: hidden;
background: transparent;
}
.footer-mark img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
display: block;
}
.footer-mark span {
display: block;
}
.footer-copy {
display: inline-block;
width: 1.72in;
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[] = '<span class="barcode-bar" style="width:' . $width . '"></span>';
$bars[] = '<span class="barcode-bar" style="width:1px"></span>';
}
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 {
$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 = [21, 103, 51];
$greenDark = [13, 91, 43];
$greenAccent = [244, 211, 74];
$blueRole = [31, 72, 179];
$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($schoolName);
$headerSubtitlePdf = $this->formatter->toPdf((string) config('badges.header_subtitle', 'High School'));
$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($green[0], $green[1], $green[2]);
$pdf->Rect($x, $y, $w, $headerH, 'F');
$pdf->SetFillColor($greenDark[0], $greenDark[1], $greenDark[2]);
$pdf->Rect($x, $y + 0.18, $w, 0.18, 'F');
$pdf->Rect($x + 0.22, $y + $headerH - 0.03, $w - 0.44, 0.10, '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', 8.2);
$pdf->SetXY($x + 0.45, $y + 0.10);
$pdf->Cell($w - 0.55, 0.08, $schoolNamePdf, 0, 1, 'C');
$pdf->SetFont('Helvetica', 'B', 5.8);
$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.2);
$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;
$pdf->SetTextColor(21, 81, 44);
$pdf->SetFont('Helvetica', 'B', 10.2);
$pdf->SetXY($x + 0.07, $bodyTop);
$pdf->Cell($w - 0.14, 0.11, strtoupper($fullName), 0, 1, 'C');
$pdf->SetTextColor($blueRole[0], $blueRole[1], $blueRole[2]);
$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.20;
$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($greenDark[0], $greenDark[1], $greenDark[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';
$cursor = $x;
$gap = 0.01;
foreach (str_split($seed) as $char) {
$width = match (ord($char) % 4) {
0 => 0.01,
1 => 0.015,
2 => 0.02,
default => 0.025,
};
if (($cursor + $width) > ($x + $maxWidth)) {
break;
}
$pdf->SetFillColor(17, 17, 17);
$pdf->Rect($cursor, $y, $width, $height, 'F');
$cursor += $width + $gap;
if (($cursor + 0.01) > ($x + $maxWidth)) {
break;
}
$pdf->Rect($cursor, $y, 0.01, $height, 'F');
$cursor += 0.01 + $gap;
}
}
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 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);
}
}
}