467 lines
16 KiB
PHP
467 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Badges;
|
|
|
|
use chillerlan\QRCode\Output\QRGdImagePNG;
|
|
use chillerlan\QRCode\QRCode;
|
|
use chillerlan\QRCode\QROptions;
|
|
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 {
|
|
$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<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);
|
|
|
|
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<string, mixed> $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;
|
|
}
|
|
}
|