1661 lines
50 KiB
PHP
1661 lines
50 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'));
|
|
$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 .= '
|
|
<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($headerMainTitle).'</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: #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[] = '<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 {
|
|
$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);
|
|
}
|
|
}
|
|
}
|