pour prod
This commit is contained in:
208
app/Services/AgentHoursContractPdfService.php
Normal file
208
app/Services/AgentHoursContractPdfService.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
|
||||
/**
|
||||
* Génère le document annuel de validation du quota horaire d'un agent.
|
||||
* Le PDF est autonome et ne nécessite aucune dépendance Composer.
|
||||
*/
|
||||
final class AgentHoursContractPdfService
|
||||
{
|
||||
private const PAGE_WIDTH = 595.28;
|
||||
private const PAGE_HEIGHT = 841.89;
|
||||
private const MARGIN = 42.0;
|
||||
|
||||
public function annualContract(array $agent, array $quota): string
|
||||
{
|
||||
$pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT);
|
||||
$periodLabel = (string) ($quota['periode_libelle'] ?? ('année ' . ($quota['annee'] ?? date('Y'))));
|
||||
$generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
|
||||
|
||||
$this->drawHeader($pdf, $agent, $periodLabel, $generatedAt);
|
||||
$this->drawAgentIdentity($pdf, $agent);
|
||||
$this->drawQuotaSummary($pdf, $quota);
|
||||
$this->drawValidationClause($pdf, $periodLabel, $generatedAt);
|
||||
$this->drawSignatures($pdf);
|
||||
|
||||
$pdf->text(
|
||||
self::MARGIN,
|
||||
813,
|
||||
sprintf('Document généré par PTA le %s - Référence agent : %s', $generatedAt->format('d/m/Y H:i'), (string) ($agent['matricule'] ?? '-')),
|
||||
7
|
||||
);
|
||||
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void
|
||||
{
|
||||
$pdf->text(self::MARGIN, 48, 'CONTRAT HORAIRE ANNUEL', 18, true);
|
||||
$pdf->text(self::MARGIN, 71, 'Validation du quota annuel de travail', 12, true);
|
||||
$pdf->text(self::MARGIN, 92, 'Période de référence : ' . $periodLabel, 10);
|
||||
$pdf->text(self::MARGIN + 315, 92, 'Édité le : ' . $generatedAt->format('d/m/Y'), 10);
|
||||
$pdf->line(self::MARGIN, 108, self::PAGE_WIDTH - self::MARGIN, 108, 0.35);
|
||||
|
||||
$pdf->text(
|
||||
self::MARGIN,
|
||||
130,
|
||||
sprintf('%s %s', (string) ($agent['prenom'] ?? ''), (string) ($agent['nom'] ?? '')),
|
||||
14,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private function drawAgentIdentity(SimplePdf $pdf, array $agent): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
$y = 150.0;
|
||||
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
|
||||
$h = 140.0;
|
||||
|
||||
$pdf->fillRect($x, $y, $w, 28, 0.93);
|
||||
$pdf->rect($x, $y, $w, $h, 0.65);
|
||||
$pdf->text($x + 12, $y + 19, 'Informations de l\'agent', 11, true);
|
||||
|
||||
$leftX = $x + 14;
|
||||
$rightX = $x + 278;
|
||||
$row1 = $y + 52;
|
||||
$row2 = $y + 78;
|
||||
$row3 = $y + 102;
|
||||
$row4 = $y + 128;
|
||||
|
||||
$qualification = !empty($agent['est_diplome'])
|
||||
? 'Diplômé' . (!empty($agent['diplome_libelle']) ? ' - ' . $agent['diplome_libelle'] : '')
|
||||
: 'Non diplômé';
|
||||
|
||||
$pdf->text($leftX, $row1, 'Matricule : ' . (($agent['matricule'] ?? '') ?: '-'), 9);
|
||||
$pdf->text($rightX, $row1, 'E-mail : ' . (($agent['email'] ?? '') ?: 'Non renseigné'), 9);
|
||||
$pdf->text($leftX, $row2, 'Téléphone : ' . (($agent['telephone'] ?? '') ?: 'Non renseigné'), 9);
|
||||
$pdf->text($rightX, $row2, 'Qualification : ' . $qualification, 9);
|
||||
$pdf->text($leftX, $row3, 'Lieu principal : ' . (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigné'), 9);
|
||||
$pdf->text($rightX, $row3, 'Début du contrat : ' . (!empty($agent['date_debut_contrat']) ? (new DateTimeImmutable((string) $agent['date_debut_contrat']))->format('d/m/Y') : 'Non renseigné'), 9);
|
||||
$address = (string) (($agent['adresse'] ?? '') ?: 'Non renseignée');
|
||||
$addressLines = $pdf->wrap('Adresse : ' . $address, $w - 28, 8.5);
|
||||
$pdf->text($leftX, $row4, (string) ($addressLines[0] ?? 'Adresse : Non renseignée'), 8.5);
|
||||
$pdf->text($rightX, $row4, 'Type du lieu : ' . $this->typeLabel((string) ($agent['structure_principale_type_affectation'] ?? '')), 8.5);
|
||||
}
|
||||
|
||||
private function drawQuotaSummary(SimplePdf $pdf, array $quota): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
$y = 318.0;
|
||||
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
|
||||
$headerH = 30.0;
|
||||
$rowH = 31.0;
|
||||
|
||||
$rows = [
|
||||
['Quota annuel de référence à 100 %', $this->minutesLabel((int) ($quota['quota_reference_minutes'] ?? 0))],
|
||||
['Quotité de travail applicable', $this->percentLabel((float) ($quota['quotite_travail'] ?? 100))],
|
||||
['Quota annuel cible de l\'agent', (string) ($quota['quota_cible']['libelle'] ?? $this->minutesLabel((int) ($quota['quota_cible_minutes'] ?? 0)))],
|
||||
['Heures validées dans PTA à la date d\'édition', (string) ($quota['valides']['libelle'] ?? $this->minutesLabel((int) ($quota['minutes_valides'] ?? 0)))],
|
||||
['Heures encore en brouillon', (string) ($quota['brouillons']['libelle'] ?? $this->minutesLabel((int) ($quota['minutes_brouillon'] ?? 0)))],
|
||||
['Solde restant à planifier / affecter', (string) ($quota['restantes']['libelle'] ?? $this->minutesLabel((int) ($quota['minutes_restantes'] ?? 0)))],
|
||||
];
|
||||
|
||||
$pdf->fillRect($x, $y, $w, $headerH, 0.90);
|
||||
$pdf->rect($x, $y, $w, $headerH + (count($rows) * $rowH), 0.60);
|
||||
$pdf->text($x + 12, $y + 20, 'Synthèse du quota horaire', 11, true);
|
||||
|
||||
$labelWidth = 360.0;
|
||||
$valueWidth = $w - $labelWidth;
|
||||
$cursorY = $y + $headerH;
|
||||
|
||||
foreach ($rows as $index => [$label, $value]) {
|
||||
if ($index % 2 === 1) {
|
||||
$pdf->fillRect($x, $cursorY, $w, $rowH, 0.975);
|
||||
}
|
||||
$pdf->line($x, $cursorY, $x + $w, $cursorY, 0.82);
|
||||
$pdf->line($x + $labelWidth, $cursorY, $x + $labelWidth, $cursorY + $rowH, 0.82);
|
||||
$pdf->text($x + 12, $cursorY + 20, (string) $label, 8.6, $index === 2);
|
||||
$pdf->text($x + $labelWidth + 12, $cursorY + 20, (string) $value, 9.2, $index === 2);
|
||||
$cursorY += $rowH;
|
||||
}
|
||||
}
|
||||
|
||||
private function drawValidationClause(SimplePdf $pdf, string $periodLabel, DateTimeImmutable $generatedAt): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
$y = 557.0;
|
||||
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
|
||||
|
||||
$pdf->text($x, $y, 'Validation', 11, true);
|
||||
|
||||
$paragraphs = [
|
||||
sprintf(
|
||||
'Le présent document fixe et récapitule le quota annuel de travail applicable à l\'agent pour la période %s. La signature de l\'agent et la contre-signature du responsable valent validation du quota annuel indiqué ci-dessus.',
|
||||
$periodLabel
|
||||
),
|
||||
'Les heures validées et les heures en brouillon constituent un état de suivi à la date d\'édition. Toute modification ultérieure du planning doit faire l\'objet d\'une nouvelle validation dans PTA lorsque cela est nécessaire.',
|
||||
];
|
||||
|
||||
$cursorY = $y + 22;
|
||||
foreach ($paragraphs as $paragraph) {
|
||||
foreach ($pdf->wrap($paragraph, $w, 9) as $line) {
|
||||
$pdf->text($x, $cursorY, $line, 9);
|
||||
$cursorY += 13;
|
||||
}
|
||||
$cursorY += 8;
|
||||
}
|
||||
|
||||
$pdf->text($x, 638, 'Fait à : ____________________________________', 9);
|
||||
$pdf->text($x + 286, 638, 'Le : ____ / ____ / ________', 9);
|
||||
$pdf->text($x, 658, 'Document établi le ' . $generatedAt->format('d/m/Y') . '.', 8);
|
||||
}
|
||||
|
||||
private function drawSignatures(SimplePdf $pdf): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
$y = 680.0;
|
||||
$gap = 18.0;
|
||||
$w = (self::PAGE_WIDTH - (self::MARGIN * 2) - $gap) / 2;
|
||||
$h = 120.0;
|
||||
|
||||
$pdf->rect($x, $y, $w, $h, 0.55);
|
||||
$pdf->rect($x + $w + $gap, $y, $w, $h, 0.55);
|
||||
|
||||
$pdf->fillRect($x, $y, $w, 28, 0.94);
|
||||
$pdf->fillRect($x + $w + $gap, $y, $w, 28, 0.94);
|
||||
|
||||
$pdf->text($x + 10, $y + 19, 'Signature de l\'agent', 10, true);
|
||||
$pdf->text($x + $w + $gap + 10, $y + 19, 'Contre-signature du responsable', 10, true);
|
||||
|
||||
$pdf->text($x + 10, $y + 48, 'Nom : ______________________________', 8.5);
|
||||
$pdf->text($x + 10, $y + 68, 'Mention « Lu et approuvé » :', 8.5);
|
||||
$pdf->text($x + 10, $y + 101, 'Signature :', 8.5);
|
||||
|
||||
$rightX = $x + $w + $gap + 10;
|
||||
$pdf->text($rightX, $y + 48, 'Nom / qualité : _____________________', 8.5);
|
||||
$pdf->text($rightX, $y + 68, 'Mention « Bon pour validation » :', 8.5);
|
||||
$pdf->text($rightX, $y + 101, 'Signature et cachet :', 8.5);
|
||||
}
|
||||
|
||||
private function typeLabel(string $type): string
|
||||
{
|
||||
return match (strtoupper($type)) {
|
||||
'EXTRASCOLAIRE' => 'Extrascolaire',
|
||||
'PERISCOLAIRE' => 'Périscolaire',
|
||||
default => 'Non renseigné',
|
||||
};
|
||||
}
|
||||
|
||||
private function percentLabel(float $rate): string
|
||||
{
|
||||
$formatted = rtrim(rtrim(number_format($rate, 2, ',', ' '), '0'), ',');
|
||||
return $formatted . ' %';
|
||||
}
|
||||
|
||||
private function minutesLabel(int $minutes): string
|
||||
{
|
||||
$sign = $minutes < 0 ? '-' : '';
|
||||
$absolute = abs($minutes);
|
||||
return sprintf('%s%d h %02d', $sign, intdiv($absolute, 60), $absolute % 60);
|
||||
}
|
||||
}
|
||||
184
app/Services/AgentTimeSummaryPdfService.php
Normal file
184
app/Services/AgentTimeSummaryPdfService.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
|
||||
/**
|
||||
* Génère un bilan annuel synthétique du temps d'un agent, ventilé par motif.
|
||||
*/
|
||||
final class AgentTimeSummaryPdfService
|
||||
{
|
||||
private const PAGE_WIDTH = 595.28;
|
||||
private const PAGE_HEIGHT = 841.89;
|
||||
private const MARGIN = 42.0;
|
||||
|
||||
public function annualSummary(array $agent, array $quota, array $rows): string
|
||||
{
|
||||
$pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT);
|
||||
$periodLabel = (string) ($quota['periode_libelle'] ?? ('année ' . ($quota['annee'] ?? date('Y'))));
|
||||
$generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
|
||||
$totals = $this->normalizeTotals($rows);
|
||||
|
||||
$this->drawHeader($pdf, $agent, $periodLabel, $generatedAt);
|
||||
$this->drawIdentity($pdf, $agent);
|
||||
$this->drawBreakdown($pdf, $totals);
|
||||
$this->drawResult($pdf, $quota, $totals);
|
||||
$this->drawFooter($pdf, $agent, $generatedAt);
|
||||
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void
|
||||
{
|
||||
$pdf->text(self::MARGIN, 34, 'PTA', 10, true);
|
||||
$pdf->text(self::MARGIN, 58, 'Compte-rendu du temps de l\'agent', 17, true);
|
||||
$pdf->text(self::MARGIN, 79, 'Synthèse des heures planifiées et décomptées pour la période ' . $periodLabel, 9.5);
|
||||
$pdf->text(self::PAGE_WIDTH - 190, 34, 'Édité le ' . $generatedAt->format('d/m/Y'), 8);
|
||||
$pdf->line(self::MARGIN, 94, self::PAGE_WIDTH - self::MARGIN, 94, 0.72);
|
||||
}
|
||||
|
||||
private function drawIdentity(SimplePdf $pdf, array $agent): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
$y = 118.0;
|
||||
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
|
||||
$h = 90.0;
|
||||
|
||||
$pdf->fillRect($x, $y, $w, 28, 0.93);
|
||||
$pdf->rect($x, $y, $w, $h, 0.68);
|
||||
$pdf->text($x + 12, $y + 19, 'Agent', 10.5, true);
|
||||
$pdf->text($x + 12, $y + 50, 'Nom : ' . (($agent['nom'] ?? '') ?: '-'), 9.2);
|
||||
$pdf->text($x + 270, $y + 50, 'Prénom : ' . (($agent['prenom'] ?? '') ?: '-'), 9.2);
|
||||
$pdf->text($x + 12, $y + 73, 'Matricule : ' . (($agent['matricule'] ?? '') ?: '-'), 9.2);
|
||||
$pdf->text($x + 270, $y + 73, 'Lieu principal : ' . (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigné'), 9.2);
|
||||
}
|
||||
|
||||
private function drawBreakdown(SimplePdf $pdf, array $totals): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
$y = 232.0;
|
||||
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
|
||||
$headerH = 31.0;
|
||||
$rowH = 43.0;
|
||||
|
||||
$rows = [
|
||||
['TRAVAIL', 'Heures travaillées', $totals['TRAVAIL']],
|
||||
['MALADIE', 'Absence', $totals['MALADIE']],
|
||||
['FORMATION', 'Formation', $totals['FORMATION']],
|
||||
['CONGE', 'Congé', $totals['CONGE']],
|
||||
['AUTRE', 'Absence (non décomptée)', $totals['AUTRE']],
|
||||
];
|
||||
|
||||
$pdf->fillRect($x, $y, $w, $headerH, 0.92);
|
||||
$pdf->rect($x, $y, $w, $headerH + (count($rows) * $rowH), 0.65);
|
||||
$pdf->text($x + 12, $y + 21, 'Répartition du temps', 11, true);
|
||||
|
||||
$cursorY = $y + $headerH;
|
||||
foreach ($rows as [$code, $label, $minutes]) {
|
||||
$palette = $this->palette($code);
|
||||
$pdf->line($x, $cursorY, $x + $w, $cursorY, 0.83);
|
||||
$pdf->fillRectColor($x + 10, $cursorY + 11, 10, 20, $palette['accent']);
|
||||
$pdf->text($x + 32, $cursorY + 25, $label, 9.4, true);
|
||||
$pdf->text($x + $w - 130, $cursorY + 25, $this->minutesLabel($minutes), 10, true);
|
||||
$cursorY += $rowH;
|
||||
}
|
||||
}
|
||||
|
||||
private function drawResult(SimplePdf $pdf, array $quota, array $totals): void
|
||||
{
|
||||
$counted = $totals['TRAVAIL'] + $totals['MALADIE'] + $totals['FORMATION'] + $totals['CONGE'] + $totals['OTHER_COUNTED'];
|
||||
$target = (int) ($quota['quota_cible_minutes'] ?? 0);
|
||||
$remaining = $target - $counted;
|
||||
$validated = (int) ($quota['minutes_valides'] ?? 0);
|
||||
$draft = (int) ($quota['minutes_brouillon'] ?? 0);
|
||||
|
||||
$x = self::MARGIN;
|
||||
$y = 507.0;
|
||||
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
|
||||
|
||||
$pdf->line($x, $y, $x + $w, $y, 0.55);
|
||||
$pdf->text($x, $y + 32, 'Résultat - heures décomptées', 12, true);
|
||||
$pdf->text($x + $w - 150, $y + 32, $this->minutesLabel($counted), 13, true);
|
||||
$pdf->text($x, $y + 55, sprintf('Dont %s validées et %s en brouillon.', $this->minutesLabel($validated), $this->minutesLabel($draft)), 8.5);
|
||||
|
||||
$boxY = $y + 82;
|
||||
$boxH = 126.0;
|
||||
$pdf->fillRect($x, $boxY, $w, $boxH, 0.965);
|
||||
$pdf->rect($x, $boxY, $w, $boxH, 0.65);
|
||||
$pdf->text($x + 14, $boxY + 27, 'Quota à effectuer sur la période', 10, true);
|
||||
$pdf->text($x + $w - 160, $boxY + 27, $this->minutesLabel($target), 11, true);
|
||||
$pdf->line($x + 12, $boxY + 43, $x + $w - 12, $boxY + 43, 0.85);
|
||||
$pdf->text($x + 14, $boxY + 72, 'Heures décomptées à ce jour', 10, true);
|
||||
$pdf->text($x + $w - 160, $boxY + 72, $this->minutesLabel($counted), 11, true);
|
||||
$pdf->line($x + 12, $boxY + 88, $x + $w - 12, $boxY + 88, 0.85);
|
||||
$pdf->text($x + 14, $boxY + 114, $remaining >= 0 ? 'Heures restant à effectuer' : 'Dépassement du quota', 10.5, true);
|
||||
$pdf->textColor($x + $w - 160, $boxY + 114, $this->minutesLabel(abs($remaining)), $remaining >= 0 ? '#1D4ED8' : '#B91C1C', 12, true);
|
||||
|
||||
$noteY = $boxY + $boxH + 27;
|
||||
foreach ($pdf->wrap('Le résultat ci-dessus additionne uniquement les motifs qui décomptent le quota annuel. Les absences non décomptées apparaissent dans le détail mais ne réduisent pas le nombre d’heures restant à effectuer.', $w, 8.5) as $line) {
|
||||
$pdf->text($x, $noteY, $line, 8.5);
|
||||
$noteY += 12;
|
||||
}
|
||||
}
|
||||
|
||||
private function drawFooter(SimplePdf $pdf, array $agent, DateTimeImmutable $generatedAt): void
|
||||
{
|
||||
$pdf->text(
|
||||
self::MARGIN,
|
||||
812,
|
||||
sprintf('Document généré par PTA le %s - Référence agent : %s', $generatedAt->format('d/m/Y H:i'), (string) ($agent['matricule'] ?? '-')),
|
||||
7
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeTotals(array $rows): array
|
||||
{
|
||||
$totals = [
|
||||
'TRAVAIL' => 0,
|
||||
'MALADIE' => 0,
|
||||
'FORMATION' => 0,
|
||||
'CONGE' => 0,
|
||||
'AUTRE' => 0,
|
||||
'OTHER_COUNTED' => 0,
|
||||
];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$code = strtoupper((string) ($row['motif_code'] ?? 'AUTRE'));
|
||||
$minutes = (int) ($row['minutes_total'] ?? 0);
|
||||
if (array_key_exists($code, $totals) && $code !== 'OTHER_COUNTED') {
|
||||
$totals[$code] += $minutes;
|
||||
continue;
|
||||
}
|
||||
if ((int) ($row['compte_dans_quota'] ?? 0) === 1) {
|
||||
$totals['OTHER_COUNTED'] += $minutes;
|
||||
} else {
|
||||
$totals['AUTRE'] += $minutes;
|
||||
}
|
||||
}
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
/** @return array{accent:string} */
|
||||
private function palette(string $code): array
|
||||
{
|
||||
return match (strtoupper($code)) {
|
||||
'TRAVAIL' => ['accent' => '#2563EB'],
|
||||
'FORMATION' => ['accent' => '#7C3AED'],
|
||||
'CONGE' => ['accent' => '#059669'],
|
||||
'MALADIE' => ['accent' => '#DC2626'],
|
||||
default => ['accent' => '#64748B'],
|
||||
};
|
||||
}
|
||||
|
||||
private function minutesLabel(int $minutes): string
|
||||
{
|
||||
$sign = $minutes < 0 ? '-' : '';
|
||||
$absolute = abs($minutes);
|
||||
return sprintf('%s%d h %02d', $sign, intdiv($absolute, 60), $absolute % 60);
|
||||
}
|
||||
}
|
||||
426
app/Services/PlanningPdfService.php
Normal file
426
app/Services/PlanningPdfService.php
Normal file
@@ -0,0 +1,426 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class PlanningPdfService
|
||||
{
|
||||
private const MARGIN = 28.0;
|
||||
private const WEEK_DAYS = [1 => 'Lundi', 2 => 'Mardi', 3 => 'Mercredi', 4 => 'Jeudi', 5 => 'Vendredi'];
|
||||
|
||||
/**
|
||||
* Compatibilité avec l'ancien export d'une seule semaine.
|
||||
*/
|
||||
public function agentWeek(array $agent, array $entries, array $week, array $quota): string
|
||||
{
|
||||
return $this->agentWeeks($agent, $entries, [$week], $quota);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un planning compact de 1 à 7 semaines. Avec 7 semaines, le rendu
|
||||
* tient volontairement sur deux pages A4 paysage maximum (4 + 3 semaines).
|
||||
*/
|
||||
public function agentWeeks(array $agent, array $entries, array $weeks, array $quota): string
|
||||
{
|
||||
$weeks = array_values(array_slice($weeks, 0, 7));
|
||||
if ($weeks === []) {
|
||||
throw new \InvalidArgumentException('Au moins une semaine est nécessaire pour générer le planning.');
|
||||
}
|
||||
|
||||
$pdf = new SimplePdf();
|
||||
$entriesByDate = $this->entriesByDate($entries);
|
||||
$chunks = array_chunk($weeks, 4);
|
||||
|
||||
foreach ($chunks as $pageIndex => $pageWeeks) {
|
||||
if ($pageIndex > 0) {
|
||||
$pdf->addPage();
|
||||
}
|
||||
|
||||
$this->drawAgentMultiWeekHeader($pdf, $agent, $weeks, $quota, $pageIndex + 1, count($chunks));
|
||||
|
||||
$sectionY = 96.0;
|
||||
foreach ($pageWeeks as $week) {
|
||||
$this->drawCompactWeek($pdf, $week, $entriesByDate, $sectionY);
|
||||
$sectionY += 112.0;
|
||||
}
|
||||
|
||||
$this->drawLegend($pdf, self::MARGIN, 563);
|
||||
$pdf->text(
|
||||
self::MARGIN,
|
||||
584,
|
||||
sprintf('Document genere depuis PTA - %d semaine%s affichee%s.', count($weeks), count($weeks) > 1 ? 's' : '', count($weeks) > 1 ? 's' : ''),
|
||||
7
|
||||
);
|
||||
}
|
||||
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
public function structureWeek(array $structure, array $agents, array $week): string
|
||||
{
|
||||
$pdf = new SimplePdf();
|
||||
$this->drawDocumentHeader(
|
||||
$pdf,
|
||||
'Planning hebdomadaire du lieu d\'affectation',
|
||||
(string) $structure['nom'] . ' - ' . $this->typeLabel((string) ($structure['type_affectation'] ?? '')),
|
||||
$this->weekLabel($week)
|
||||
);
|
||||
|
||||
$weekDays = $this->weekDays($week);
|
||||
$margin = self::MARGIN;
|
||||
$tableY = 100.0;
|
||||
$agentWidth = 126.0;
|
||||
$quotaWidth = 84.0;
|
||||
$usableWidth = $pdf->width() - ($margin * 2);
|
||||
$dayWidth = ($usableWidth - $agentWidth - $quotaWidth) / 5;
|
||||
$headerHeight = 38.0;
|
||||
$bottomLimit = 548.0;
|
||||
|
||||
$drawHeader = function () use ($pdf, $weekDays, $margin, $tableY, $agentWidth, $quotaWidth, $dayWidth, $headerHeight): void {
|
||||
$x = $margin;
|
||||
$headers = [['Agent', $agentWidth]];
|
||||
foreach ($weekDays as $day) {
|
||||
$headers[] = [$day['label'] . ' ' . $day['display'], $dayWidth];
|
||||
}
|
||||
$headers[] = ['Quota restant', $quotaWidth];
|
||||
|
||||
foreach ($headers as [$label, $width]) {
|
||||
$pdf->fillRect($x, $tableY, $width, $headerHeight, 0.92);
|
||||
$pdf->rect($x, $tableY, $width, $headerHeight, 0.72);
|
||||
$lines = $pdf->wrap((string) $label, $width - 10, 8);
|
||||
$lineY = $tableY + 15;
|
||||
foreach (array_slice($lines, 0, 2) as $line) {
|
||||
$pdf->text($x + 5, $lineY, $line, 8, true);
|
||||
$lineY += 10;
|
||||
}
|
||||
$x += $width;
|
||||
}
|
||||
};
|
||||
|
||||
$drawHeader();
|
||||
$y = $tableY + $headerHeight;
|
||||
|
||||
foreach ($agents as $agent) {
|
||||
$entriesByDate = $this->entriesByDate($agent['entries'] ?? []);
|
||||
$dayBlocks = [];
|
||||
$maxCellHeight = 34.0;
|
||||
|
||||
foreach ($weekDays as $day) {
|
||||
$blocks = [];
|
||||
foreach ($entriesByDate[$day['date']] ?? [] as $entry) {
|
||||
$labelLines = $this->structureEntryLines($pdf, $entry, $dayWidth - 17);
|
||||
$blockHeight = max(16.0, 6.0 + (count($labelLines) * 8.0));
|
||||
$blocks[] = [
|
||||
'entry' => $entry,
|
||||
'lines' => $labelLines,
|
||||
'height' => $blockHeight,
|
||||
];
|
||||
}
|
||||
$dayBlocks[] = $blocks;
|
||||
|
||||
$cellHeight = 8.0;
|
||||
foreach ($blocks as $block) {
|
||||
$cellHeight += $block['height'] + 4.0;
|
||||
}
|
||||
$maxCellHeight = max($maxCellHeight, $cellHeight);
|
||||
}
|
||||
|
||||
$agentLines = $pdf->wrap(
|
||||
sprintf('%s %s - %s', $agent['prenom'], $agent['nom'], $agent['matricule']),
|
||||
$agentWidth - 10,
|
||||
7.5
|
||||
);
|
||||
$rowHeight = max(44.0, $maxCellHeight, 14.0 + (count($agentLines) * 9.0));
|
||||
|
||||
if ($y + $rowHeight > $bottomLimit) {
|
||||
$pdf->addPage();
|
||||
$this->drawDocumentHeader(
|
||||
$pdf,
|
||||
'Planning hebdomadaire du lieu d\'affectation - suite',
|
||||
(string) $structure['nom'] . ' - ' . $this->typeLabel((string) ($structure['type_affectation'] ?? '')),
|
||||
$this->weekLabel($week)
|
||||
);
|
||||
$drawHeader();
|
||||
$y = $tableY + $headerHeight;
|
||||
}
|
||||
|
||||
$x = $margin;
|
||||
$widths = [$agentWidth, $dayWidth, $dayWidth, $dayWidth, $dayWidth, $dayWidth, $quotaWidth];
|
||||
foreach ($widths as $width) {
|
||||
$pdf->rect($x, $y, $width, $rowHeight, 0.80);
|
||||
$x += $width;
|
||||
}
|
||||
|
||||
$lineY = $y + 13;
|
||||
foreach ($agentLines as $index => $line) {
|
||||
$pdf->text($margin + 5, $lineY, $line, 7.5, $index === 0);
|
||||
$lineY += 9;
|
||||
}
|
||||
|
||||
foreach ($dayBlocks as $dayIndex => $blocks) {
|
||||
$cellX = $margin + $agentWidth + ($dayIndex * $dayWidth);
|
||||
if ($blocks === []) {
|
||||
$pdf->text($cellX + 6, $y + 18, '-', 7.2);
|
||||
continue;
|
||||
}
|
||||
|
||||
$blockY = $y + 5;
|
||||
foreach ($blocks as $block) {
|
||||
$entry = $block['entry'];
|
||||
$palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE'));
|
||||
$blockHeight = (float) $block['height'];
|
||||
$pdf->fillRectColor($cellX + 4, $blockY, $dayWidth - 8, $blockHeight, $palette['background']);
|
||||
$pdf->fillRectColor($cellX + 4, $blockY, 3.0, $blockHeight, $palette['accent']);
|
||||
|
||||
$textY = $blockY + 10;
|
||||
foreach ($block['lines'] as $lineIndex => $line) {
|
||||
$isFirst = $lineIndex === 0;
|
||||
$pdf->text($cellX + 10, $textY, $line, 7.0, $isFirst);
|
||||
$textY += 8;
|
||||
}
|
||||
$blockY += $blockHeight + 4;
|
||||
}
|
||||
}
|
||||
|
||||
$quotaText = (string) ($agent['quota']['restantes']['libelle'] ?? '-');
|
||||
$pdf->text($margin + $agentWidth + (5 * $dayWidth) + 5, $y + 18, $quotaText, 8, true);
|
||||
$pdf->text($margin + $agentWidth + (5 * $dayWidth) + 5, $y + 31, (($agent['lieu_principal'] ?? false) ? 'Principal' : 'Ponctuel'), 7);
|
||||
$y += $rowHeight;
|
||||
}
|
||||
|
||||
if ($agents === []) {
|
||||
$pdf->text($margin, $y + 24, 'Aucun agent rattache ou planifie sur ce lieu pour cette semaine.', 10);
|
||||
}
|
||||
|
||||
$this->drawLegend($pdf, self::MARGIN, 562);
|
||||
$pdf->text(self::MARGIN, 584, 'Document genere depuis PTA - Planning du lundi au vendredi.', 7);
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
private function drawAgentMultiWeekHeader(
|
||||
SimplePdf $pdf,
|
||||
array $agent,
|
||||
array $weeks,
|
||||
array $quota,
|
||||
int $page,
|
||||
int $pageCount
|
||||
): void {
|
||||
$first = $weeks[0];
|
||||
$last = $weeks[count($weeks) - 1];
|
||||
$periodStart = (new DateTimeImmutable((string) $first['date_debut']))->format('d/m/Y');
|
||||
$periodEnd = (new DateTimeImmutable((string) $last['date_debut']))->modify('+4 days')->format('d/m/Y');
|
||||
$principalLocation = (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigne');
|
||||
$principalType = $this->typeLabel((string) ($agent['structure_principale_type_affectation'] ?? ''));
|
||||
|
||||
$pdf->text(self::MARGIN, 25, 'PTA', 9, true);
|
||||
$pdf->text(self::MARGIN, 44, 'Planning de l\'agent - vue multi-semaines', 16, true);
|
||||
$pdf->text(self::MARGIN, 60, sprintf('%s %s - %s', $agent['prenom'], $agent['nom'], $agent['matricule']), 9.5, true);
|
||||
$pdf->text(self::MARGIN, 74, 'Lieu principal : ' . $principalLocation . ' - ' . $principalType, 8);
|
||||
$pdf->text($pdf->width() - 260, 44, sprintf('Du %s au %s', $periodStart, $periodEnd), 9.5, true);
|
||||
$pdf->text($pdf->width() - 260, 60, 'Quota restant : ' . ($quota['restantes']['libelle'] ?? '-'), 8.5, true);
|
||||
if ($pageCount > 1) {
|
||||
$pdf->text($pdf->width() - 260, 74, sprintf('Page %d / %d', $page, $pageCount), 8);
|
||||
}
|
||||
$pdf->line(self::MARGIN, 86, $pdf->width() - self::MARGIN, 86, 0.72);
|
||||
}
|
||||
|
||||
/** @param array<string, array<int, array>> $entriesByDate */
|
||||
private function drawCompactWeek(SimplePdf $pdf, array $week, array $entriesByDate, float $y): void
|
||||
{
|
||||
$weekDays = $this->weekDays($week);
|
||||
$usableWidth = $pdf->width() - (self::MARGIN * 2);
|
||||
$colWidth = $usableWidth / 5;
|
||||
$weekTitleHeight = 16.0;
|
||||
$dayHeaderHeight = 16.0;
|
||||
$bodyHeight = 77.0;
|
||||
|
||||
$pdf->fillRect(self::MARGIN, $y, $usableWidth, $weekTitleHeight, 0.965);
|
||||
$pdf->rect(self::MARGIN, $y, $usableWidth, $weekTitleHeight + $dayHeaderHeight + $bodyHeight, 0.78);
|
||||
$pdf->text(self::MARGIN + 6, $y + 11, $this->weekLabel($week), 8.5, true);
|
||||
|
||||
$dayHeaderY = $y + $weekTitleHeight;
|
||||
$bodyY = $dayHeaderY + $dayHeaderHeight;
|
||||
|
||||
foreach ($weekDays as $index => $day) {
|
||||
$colX = self::MARGIN + ($index * $colWidth);
|
||||
$pdf->fillRect($colX, $dayHeaderY, $colWidth, $dayHeaderHeight, 0.92);
|
||||
$pdf->rect($colX, $dayHeaderY, $colWidth, $dayHeaderHeight + $bodyHeight, 0.82);
|
||||
$pdf->text($colX + 5, $dayHeaderY + 11, $this->shortDayLabel($day['label']) . ' ' . substr($day['display'], 0, 5), 7.2, true);
|
||||
|
||||
$dayEntries = $entriesByDate[$day['date']] ?? [];
|
||||
if ($dayEntries === []) {
|
||||
$pdf->text($colX + 6, $bodyY + 17, 'Aucune affectation', 6.5);
|
||||
continue;
|
||||
}
|
||||
|
||||
$cursorY = $bodyY + 4;
|
||||
$rendered = 0;
|
||||
foreach ($dayEntries as $entryIndex => $entry) {
|
||||
$lines = $this->compactAgentEntryLines($pdf, $entry, $colWidth - 18);
|
||||
$cardHeight = max(18.0, 5.0 + (count($lines) * 7.2));
|
||||
if ($cursorY + $cardHeight > $bodyY + $bodyHeight - 4) {
|
||||
$remaining = count($dayEntries) - $rendered;
|
||||
if ($remaining > 0) {
|
||||
$pdf->text($colX + 7, $bodyY + $bodyHeight - 7, '+' . $remaining . ' autre(s) affectation(s)', 6.2, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE'));
|
||||
$pdf->fillRectColor($colX + 4, $cursorY, $colWidth - 8, $cardHeight, $palette['background']);
|
||||
$pdf->fillRectColor($colX + 4, $cursorY, 3.0, $cardHeight, $palette['accent']);
|
||||
|
||||
$lineY = $cursorY + 9;
|
||||
foreach ($lines as $lineIndex => $line) {
|
||||
$bold = $lineIndex === 0;
|
||||
$pdf->text($colX + 10, $lineY, $line, 6.5, $bold);
|
||||
$lineY += 7.2;
|
||||
}
|
||||
|
||||
$cursorY += $cardHeight + 3.0;
|
||||
$rendered++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private function compactAgentEntryLines(SimplePdf $pdf, array $entry, float $width): array
|
||||
{
|
||||
$lines = [sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin'])];
|
||||
$motifLabel = $this->motifDisplayLabel($entry);
|
||||
if ($motifLabel !== '') {
|
||||
foreach ($pdf->wrap($motifLabel, $width, 6.3) as $line) {
|
||||
$lines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
$location = (string) ($entry['structure_nom'] ?? 'Lieu non renseigne');
|
||||
foreach ($pdf->wrap('Lieu : ' . $location, $width, 6.1) as $line) {
|
||||
$lines[] = $line;
|
||||
}
|
||||
|
||||
if (($entry['statut'] ?? 'VALIDE') === 'BROUILLON') {
|
||||
$lines[] = 'Brouillon';
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private function structureEntryLines(SimplePdf $pdf, array $entry, float $width): array
|
||||
{
|
||||
$first = sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin']);
|
||||
$motif = $this->motifDisplayLabel($entry);
|
||||
if ($motif !== '') {
|
||||
$first .= ' ' . $motif;
|
||||
}
|
||||
return $pdf->wrap($first, $width, 7.0);
|
||||
}
|
||||
|
||||
private function motifDisplayLabel(array $entry): string
|
||||
{
|
||||
return strtoupper((string) ($entry['motif_code'] ?? '')) === 'TRAVAIL'
|
||||
? ''
|
||||
: (string) ($entry['motif_libelle'] ?? 'Affectation');
|
||||
}
|
||||
|
||||
/** @return array{accent:string,background:string,label:string} */
|
||||
private function motifPalette(string $code): array
|
||||
{
|
||||
return match (strtoupper($code)) {
|
||||
'TRAVAIL' => ['accent' => '#2563EB', 'background' => '#EFF6FF', 'label' => 'Travail'],
|
||||
'FORMATION' => ['accent' => '#7C3AED', 'background' => '#F5F3FF', 'label' => 'Formation'],
|
||||
'CONGE' => ['accent' => '#059669', 'background' => '#ECFDF5', 'label' => 'Conge'],
|
||||
'MALADIE' => ['accent' => '#DC2626', 'background' => '#FEF2F2', 'label' => 'Absence'],
|
||||
default => ['accent' => '#64748B', 'background' => '#F8FAFC', 'label' => 'Autre absence'],
|
||||
};
|
||||
}
|
||||
|
||||
private function drawLegend(SimplePdf $pdf, float $x, float $y): void
|
||||
{
|
||||
$items = [
|
||||
['TRAVAIL', 'Travail'],
|
||||
['FORMATION', 'Formation'],
|
||||
['CONGE', 'Conge'],
|
||||
['MALADIE', 'Absence'],
|
||||
['AUTRE', 'Autre absence'],
|
||||
];
|
||||
|
||||
foreach ($items as [$code, $label]) {
|
||||
$palette = $this->motifPalette($code);
|
||||
$pdf->fillRectColor($x, $y - 7, 8, 8, $palette['accent']);
|
||||
$pdf->text($x + 12, $y, $label, 6.5);
|
||||
$x += 76;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, array>> */
|
||||
private function entriesByDate(array $entries): array
|
||||
{
|
||||
$grouped = [];
|
||||
foreach ($entries as $entry) {
|
||||
$grouped[(string) $entry['date_jour']][] = $entry;
|
||||
}
|
||||
foreach ($grouped as &$dayEntries) {
|
||||
usort($dayEntries, static fn (array $a, array $b): int => strcmp((string) $a['heure_debut'], (string) $b['heure_debut']));
|
||||
}
|
||||
unset($dayEntries);
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
private function drawDocumentHeader(SimplePdf $pdf, string $title, string $subtitle, string $weekLabel): void
|
||||
{
|
||||
$pdf->text(self::MARGIN, 34, 'PTA', 10, true);
|
||||
$pdf->text(self::MARGIN, 55, $title, 18, true);
|
||||
$pdf->text(self::MARGIN, 72, $subtitle, 10);
|
||||
$pdf->text($pdf->width() - 250, 55, $weekLabel, 10, true);
|
||||
$pdf->line(self::MARGIN, 90, $pdf->width() - self::MARGIN, 90, 0.72);
|
||||
}
|
||||
|
||||
/** @return array<int, array{date:string,label:string,display:string}> */
|
||||
private function weekDays(array $week): array
|
||||
{
|
||||
$start = new DateTimeImmutable((string) $week['date_debut']);
|
||||
$days = [];
|
||||
for ($dayNumber = 1; $dayNumber <= 5; $dayNumber++) {
|
||||
$date = $start->modify('+' . ($dayNumber - 1) . ' days');
|
||||
$days[] = [
|
||||
'date' => $date->format('Y-m-d'),
|
||||
'label' => self::WEEK_DAYS[$dayNumber],
|
||||
'display' => $date->format('d/m/Y'),
|
||||
];
|
||||
}
|
||||
return $days;
|
||||
}
|
||||
|
||||
private function weekLabel(array $week): string
|
||||
{
|
||||
$start = new DateTimeImmutable((string) $week['date_debut']);
|
||||
$end = $start->modify('+4 days');
|
||||
return sprintf('Semaine du %s au %s', $start->format('d/m/Y'), $end->format('d/m/Y'));
|
||||
}
|
||||
|
||||
private function shortDayLabel(string $label): string
|
||||
{
|
||||
return match ($label) {
|
||||
'Lundi' => 'Lun.',
|
||||
'Mardi' => 'Mar.',
|
||||
'Mercredi' => 'Mer.',
|
||||
'Jeudi' => 'Jeu.',
|
||||
'Vendredi' => 'Ven.',
|
||||
default => $label,
|
||||
};
|
||||
}
|
||||
|
||||
private function typeLabel(string $type): string
|
||||
{
|
||||
return match (strtoupper($type)) {
|
||||
'EXTRASCOLAIRE' => 'Extrascolaire',
|
||||
'PERISCOLAIRE' => 'Periscolaire',
|
||||
default => 'Non renseigne',
|
||||
};
|
||||
}
|
||||
}
|
||||
484
app/Services/SchoolHolidayApiService.php
Normal file
484
app/Services/SchoolHolidayApiService.php
Normal file
@@ -0,0 +1,484 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class SchoolHolidayApiService
|
||||
{
|
||||
private const DATASET = 'fr-en-calendrier-scolaire';
|
||||
|
||||
/**
|
||||
* data.gouv.fr publie actuellement v2.0 comme URL de base officielle.
|
||||
* v2.1 et l'ancienne API 1.0 restent des solutions de repli afin que
|
||||
* la page continue à fonctionner lors d'une évolution du portail.
|
||||
*/
|
||||
private const EXPLORE_ENDPOINTS = [
|
||||
'https://data.education.gouv.fr/api/explore/v2.0/catalog/datasets/' . self::DATASET . '/records',
|
||||
'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/' . self::DATASET . '/records',
|
||||
];
|
||||
|
||||
private const LEGACY_ENDPOINT = 'https://data.education.gouv.fr/api/records/1.0/search/';
|
||||
|
||||
private const ACADEMY_ZONES = [
|
||||
'aix-marseille' => 'Zone B',
|
||||
'amiens' => 'Zone B',
|
||||
'besancon' => 'Zone A',
|
||||
'bordeaux' => 'Zone A',
|
||||
'clermont-ferrand' => 'Zone A',
|
||||
'creteil' => 'Zone C',
|
||||
'dijon' => 'Zone A',
|
||||
'grenoble' => 'Zone A',
|
||||
'lille' => 'Zone B',
|
||||
'limoges' => 'Zone A',
|
||||
'lyon' => 'Zone A',
|
||||
'montpellier' => 'Zone C',
|
||||
'nancy-metz' => 'Zone B',
|
||||
'nantes' => 'Zone B',
|
||||
'nice' => 'Zone B',
|
||||
'normandie' => 'Zone B',
|
||||
'orleans-tours' => 'Zone B',
|
||||
'paris' => 'Zone C',
|
||||
'poitiers' => 'Zone A',
|
||||
'reims' => 'Zone B',
|
||||
'rennes' => 'Zone B',
|
||||
'strasbourg' => 'Zone B',
|
||||
'toulouse' => 'Zone C',
|
||||
'versailles' => 'Zone C',
|
||||
];
|
||||
|
||||
/**
|
||||
* Compatibilité avec le code existant : retourne uniquement les périodes.
|
||||
*/
|
||||
public function previewCalendarYear(string $academy, int $calendarYear): array
|
||||
{
|
||||
return $this->previewCalendarYearDetailed($academy, $calendarYear)['periods'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne toutes les périodes recouvrant une année civile, accompagnées
|
||||
* d'avertissements non bloquants. Une année civile chevauche deux années
|
||||
* scolaires. L'absence de publication de l'une d'elles ne doit donc plus
|
||||
* empêcher l'affichage des dates déjà disponibles.
|
||||
*
|
||||
* @return array{periods: array, warnings: array, school_years: array}
|
||||
*/
|
||||
public function previewCalendarYearDetailed(string $academy, int $calendarYear): array
|
||||
{
|
||||
if ($calendarYear < 2000 || $calendarYear > 2100) {
|
||||
throw new RuntimeException('Année civile invalide.');
|
||||
}
|
||||
|
||||
$academy = trim($academy);
|
||||
if ($academy === '') {
|
||||
throw new RuntimeException('Académie manquante.');
|
||||
}
|
||||
|
||||
$schoolYears = [
|
||||
($calendarYear - 1) . '-' . $calendarYear,
|
||||
$calendarYear . '-' . ($calendarYear + 1),
|
||||
];
|
||||
|
||||
$all = [];
|
||||
$warnings = [];
|
||||
$successfulQueries = 0;
|
||||
|
||||
foreach ($schoolYears as $schoolYear) {
|
||||
try {
|
||||
$periods = $this->previewSchoolYear($academy, $schoolYear);
|
||||
$successfulQueries++;
|
||||
|
||||
if ($periods === []) {
|
||||
$warnings[] = sprintf(
|
||||
'Aucune date publiée pour l’académie %s et l’année scolaire %s.',
|
||||
$academy,
|
||||
$schoolYear
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($periods as $period) {
|
||||
$key = $period['libelle'] . '|' . $period['date_debut'] . '|' . $period['date_fin'];
|
||||
$all[$key] = $period;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$warnings[] = sprintf(
|
||||
'Les dates de l’année scolaire %s n’ont pas pu être chargées : %s',
|
||||
$schoolYear,
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($successfulQueries === 0) {
|
||||
throw new RuntimeException(implode(' ', $warnings));
|
||||
}
|
||||
|
||||
$yearStart = sprintf('%04d-01-01', $calendarYear);
|
||||
$yearEnd = sprintf('%04d-12-31', $calendarYear);
|
||||
|
||||
$periods = array_values(array_filter(
|
||||
$all,
|
||||
static fn(array $period): bool => $period['date_debut'] <= $yearEnd && $period['date_fin'] >= $yearStart
|
||||
));
|
||||
|
||||
usort($periods, static fn(array $a, array $b): int => strcmp($a['date_debut'], $b['date_debut']));
|
||||
|
||||
return [
|
||||
'periods' => $periods,
|
||||
'warnings' => array_values(array_unique($warnings)),
|
||||
'school_years' => $schoolYears,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibilité avec l'ancienne API interne V5.17.
|
||||
*/
|
||||
public function preview(string $academy, string $schoolYear): array
|
||||
{
|
||||
return $this->previewSchoolYear($academy, $schoolYear);
|
||||
}
|
||||
|
||||
private function previewSchoolYear(string $academy, string $schoolYear): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// 1. Recherche par académie. On essaie plusieurs graphies afin de ne
|
||||
// pas rendre la recherche dépendante des accents ou du type de tiret.
|
||||
foreach ($this->academyVariants($academy) as $academyVariant) {
|
||||
try {
|
||||
$records = $this->fetchExploreRecords([
|
||||
'limit' => 100,
|
||||
'lang' => 'fr',
|
||||
'timezone' => 'Europe/Paris',
|
||||
'order_by' => 'start_date',
|
||||
'where' => 'annee_scolaire=' . $this->quoteWhereValue($schoolYear),
|
||||
'refine' => 'location:' . $this->quoteWhereValue($academyVariant),
|
||||
]);
|
||||
|
||||
$periods = $this->normalize($records, $academy, $schoolYear, false);
|
||||
if ($periods !== []) {
|
||||
return $periods;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Repli par zone. Il est utile lorsque le portail fournit une
|
||||
// période commune à toute la zone, sans ligne spécifique à l'académie.
|
||||
$zone = $this->zoneForAcademy($academy);
|
||||
if ($zone !== null) {
|
||||
try {
|
||||
$records = $this->fetchExploreRecords([
|
||||
'limit' => 100,
|
||||
'lang' => 'fr',
|
||||
'timezone' => 'Europe/Paris',
|
||||
'order_by' => 'start_date',
|
||||
'where' => 'annee_scolaire=' . $this->quoteWhereValue($schoolYear),
|
||||
'refine' => 'zones:' . $this->quoteWhereValue($zone),
|
||||
]);
|
||||
|
||||
$periods = $this->normalize($records, $academy, $schoolYear, true);
|
||||
if ($periods !== []) {
|
||||
return $periods;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Dernier repli sur l'ancienne API Opendatasoft. Elle utilise un
|
||||
// format JSON différent, normalisé dans fetchLegacyRecords().
|
||||
foreach ($this->academyVariants($academy) as $academyVariant) {
|
||||
try {
|
||||
$records = $this->fetchLegacyRecords($academyVariant, $schoolYear);
|
||||
$periods = $this->normalize($records, $academy, $schoolYear, false);
|
||||
if ($periods !== []) {
|
||||
return $periods;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw new RuntimeException(end($errors) ?: 'Erreur inconnue de l’API du calendrier scolaire.');
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function fetchExploreRecords(array $parameters): array
|
||||
{
|
||||
$query = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
|
||||
$lastError = null;
|
||||
|
||||
foreach (self::EXPLORE_ENDPOINTS as $endpoint) {
|
||||
try {
|
||||
$payload = $this->fetchJson($endpoint . '?' . $query);
|
||||
$records = $payload['results'] ?? null;
|
||||
if (!is_array($records)) {
|
||||
throw new RuntimeException('Réponse inattendue de l’API Explore.');
|
||||
}
|
||||
return $records;
|
||||
} catch (RuntimeException $e) {
|
||||
$lastError = $e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException($lastError?->getMessage() ?? 'API Explore indisponible.');
|
||||
}
|
||||
|
||||
private function fetchLegacyRecords(string $academy, string $schoolYear): array
|
||||
{
|
||||
$query = http_build_query([
|
||||
'dataset' => self::DATASET,
|
||||
'rows' => 100,
|
||||
'sort' => 'start_date',
|
||||
'refine.location' => $academy,
|
||||
'refine.annee_scolaire' => $schoolYear,
|
||||
], '', '&', PHP_QUERY_RFC3986);
|
||||
|
||||
$payload = $this->fetchJson(self::LEGACY_ENDPOINT . '?' . $query);
|
||||
$records = $payload['records'] ?? null;
|
||||
if (!is_array($records)) {
|
||||
throw new RuntimeException('Réponse inattendue de l’ancienne API du calendrier scolaire.');
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn(mixed $record): ?array => is_array($record) && is_array($record['fields'] ?? null)
|
||||
? $record['fields']
|
||||
: null,
|
||||
$records
|
||||
)));
|
||||
}
|
||||
|
||||
private function normalize(array $records, string $academy, string $schoolYear, bool $zoneFallback): array
|
||||
{
|
||||
$periods = [];
|
||||
$requestedAcademy = $this->normalizeAcademy($academy);
|
||||
|
||||
foreach ($records as $record) {
|
||||
if (!is_array($record)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Certaines vacances communes sont publiées avec population "-".
|
||||
// En filtrant uniquement "Élèves", elles disparaissaient du résultat.
|
||||
if (!$this->isStudentPopulation($record['population'] ?? '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recordAcademy = trim($this->stringValue($record['location'] ?? ''));
|
||||
if (!$zoneFallback && $recordAcademy !== '' && $this->normalizeAcademy($recordAcademy) !== $requestedAcademy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$description = trim($this->stringValue($record['description'] ?? ''));
|
||||
if (!$this->isVacationDescription($description)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$startRaw = $this->stringValue($record['start_date'] ?? '');
|
||||
$endRaw = $this->stringValue($record['end_date'] ?? '');
|
||||
$start = $this->dateOnly($startRaw);
|
||||
$returnDate = $this->dateOnly($endRaw);
|
||||
if ($start === null || $returnDate === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// L'API donne la date de reprise. PTA utilise des bornes inclusives,
|
||||
// donc le dernier jour de vacances est la veille de la reprise.
|
||||
$end = (new DateTimeImmutable($returnDate))->modify('-1 day')->format('Y-m-d');
|
||||
if ($end < $start) {
|
||||
$end = $start;
|
||||
}
|
||||
|
||||
$zone = trim($this->stringValue($record['zones'] ?? ($record['zone'] ?? '')));
|
||||
$recordSchoolYear = trim($this->stringValue($record['annee_scolaire'] ?? $schoolYear));
|
||||
|
||||
$key = $description . '|' . $start . '|' . $end;
|
||||
$periods[$key] = [
|
||||
'libelle' => $description,
|
||||
'date_debut' => $start,
|
||||
'date_fin' => $end,
|
||||
'date_reprise_api' => $returnDate,
|
||||
'annee_scolaire' => $recordSchoolYear !== '' ? $recordSchoolYear : $schoolYear,
|
||||
'academie' => $recordAcademy !== '' && !$zoneFallback ? $recordAcademy : $academy,
|
||||
'zone' => $zone !== '' ? $zone : ($this->zoneForAcademy($academy) ?? ''),
|
||||
'source' => 'DATA_GOUV',
|
||||
];
|
||||
}
|
||||
|
||||
$periods = array_values($periods);
|
||||
usort($periods, static fn(array $a, array $b): int => strcmp($a['date_debut'], $b['date_debut']));
|
||||
return $periods;
|
||||
}
|
||||
|
||||
private function isVacationDescription(string $description): bool
|
||||
{
|
||||
$normalized = $this->normalizeText($description);
|
||||
if ($normalized === '') {
|
||||
return false;
|
||||
}
|
||||
if (str_contains($normalized, 'rentree') || str_contains($normalized, 'prerentree')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (['vacance', 'conge', 'pont', 'ete austral', 'hiver austral'] as $keyword) {
|
||||
if (str_contains($normalized, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function academyVariants(string $academy): array
|
||||
{
|
||||
$academy = trim(preg_replace('/\s+/u', ' ', $academy) ?? $academy);
|
||||
$variants = [$academy];
|
||||
$variants[] = str_replace(['–', '—', '‑'], '-', $academy);
|
||||
$variants[] = $this->stripAccents($academy);
|
||||
$variants[] = str_replace('-', ' ', $academy);
|
||||
$variants[] = str_replace('-', ' ', $this->stripAccents($academy));
|
||||
|
||||
return array_values(array_unique(array_filter(array_map('trim', $variants))));
|
||||
}
|
||||
|
||||
private function zoneForAcademy(string $academy): ?string
|
||||
{
|
||||
$key = $this->normalizeAcademy($academy);
|
||||
return self::ACADEMY_ZONES[str_replace(' ', '-', $key)] ?? null;
|
||||
}
|
||||
|
||||
private function fetchJson(string $url): array
|
||||
{
|
||||
$body = false;
|
||||
$status = 0;
|
||||
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
throw new RuntimeException('Impossible d’initialiser cURL.');
|
||||
}
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 8,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_ENCODING => '',
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: PTA/7.3'],
|
||||
]);
|
||||
$body = curl_exec($curl);
|
||||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
if ($body === false) {
|
||||
throw new RuntimeException($error ?: 'Erreur réseau cURL.');
|
||||
}
|
||||
} else {
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => 20,
|
||||
'ignore_errors' => true,
|
||||
'header' => "Accept: application/json\r\nUser-Agent: PTA/7.3\r\n",
|
||||
],
|
||||
]);
|
||||
$body = @file_get_contents($url, false, $context);
|
||||
if ($body === false) {
|
||||
throw new RuntimeException('La lecture d’URL distante est désactivée sur le serveur PHP. Activez cURL ou allow_url_fopen.');
|
||||
}
|
||||
foreach ($http_response_header ?? [] as $header) {
|
||||
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $header, $matches)) {
|
||||
$status = (int) $matches[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($status >= 400) {
|
||||
throw new RuntimeException('L’API a répondu avec le code HTTP ' . $status . '.');
|
||||
}
|
||||
|
||||
$data = json_decode((string) $body, true);
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException('Le contenu reçu depuis l’API n’est pas un JSON valide.');
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function quoteWhereValue(string $value): string
|
||||
{
|
||||
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
|
||||
}
|
||||
|
||||
private function dateOnly(string $value): ?string
|
||||
{
|
||||
if (!preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $matches)) {
|
||||
return null;
|
||||
}
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
private function stringValue(mixed $value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return implode(', ', array_map(static fn(mixed $item): string => (string) $item, $value));
|
||||
}
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
|
||||
private function isStudentPopulation(mixed $value): bool
|
||||
{
|
||||
$values = is_array($value) ? $value : [$value];
|
||||
foreach ($values as $item) {
|
||||
$population = $this->normalizeText((string) $item);
|
||||
if ($population === '' || in_array($population, ['-', 'eleves', 'tous', 'tout public'], true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function normalizeAcademy(string $value): string
|
||||
{
|
||||
$value = str_replace(['–', '—', '‑', '_', '-'], ' ', $value);
|
||||
return $this->normalizeText($value);
|
||||
}
|
||||
|
||||
private function normalizeText(string $value): string
|
||||
{
|
||||
$value = $this->stripAccents($value);
|
||||
$value = str_replace(['–', '—', '‑', '_'], '-', $value);
|
||||
$value = strtolower($value);
|
||||
$value = preg_replace('/[^a-z0-9-]+/', ' ', $value) ?? $value;
|
||||
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
private function stripAccents(string $value): string
|
||||
{
|
||||
return strtr($value, [
|
||||
'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', 'Å' => 'A',
|
||||
'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a',
|
||||
'Ç' => 'C', 'ç' => 'c',
|
||||
'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E',
|
||||
'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e',
|
||||
'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I',
|
||||
'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i',
|
||||
'Ñ' => 'N', 'ñ' => 'n',
|
||||
'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'O',
|
||||
'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o',
|
||||
'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'U',
|
||||
'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u',
|
||||
'Ý' => 'Y', 'Ÿ' => 'Y', 'ý' => 'y', 'ÿ' => 'y',
|
||||
'Œ' => 'OE', 'œ' => 'oe', 'Æ' => 'AE', 'æ' => 'ae',
|
||||
]);
|
||||
}
|
||||
}
|
||||
291
app/Services/SimplePdf.php
Normal file
291
app/Services/SimplePdf.php
Normal file
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Petit générateur PDF autonome, volontairement limité aux besoins des plannings.
|
||||
* Il utilise les polices standard PDF Helvetica / Helvetica-Bold et ne requiert
|
||||
* aucune dépendance Composer.
|
||||
*/
|
||||
final class SimplePdf
|
||||
{
|
||||
public const A4_LANDSCAPE_WIDTH = 841.89;
|
||||
public const A4_LANDSCAPE_HEIGHT = 595.28;
|
||||
|
||||
/** @var array<int, string> */
|
||||
private array $pages = [];
|
||||
private string $currentPage = '';
|
||||
|
||||
public function __construct(
|
||||
private float $width = self::A4_LANDSCAPE_WIDTH,
|
||||
private float $height = self::A4_LANDSCAPE_HEIGHT,
|
||||
) {
|
||||
$this->addPage();
|
||||
}
|
||||
|
||||
public function width(): float
|
||||
{
|
||||
return $this->width;
|
||||
}
|
||||
|
||||
public function height(): float
|
||||
{
|
||||
return $this->height;
|
||||
}
|
||||
|
||||
public function addPage(): void
|
||||
{
|
||||
if ($this->currentPage !== '') {
|
||||
$this->pages[] = $this->currentPage;
|
||||
}
|
||||
$this->currentPage = '';
|
||||
}
|
||||
|
||||
public function text(float $x, float $y, string $text, float $size = 10, bool $bold = false): void
|
||||
{
|
||||
$font = $bold ? 'F2' : 'F1';
|
||||
$encoded = $this->escapeText($text);
|
||||
$pdfY = $this->height - $y;
|
||||
$this->currentPage .= sprintf(
|
||||
"BT /%s %.2F Tf 1 0 0 1 %.2F %.2F Tm (%s) Tj ET\n",
|
||||
$font,
|
||||
$size,
|
||||
$x,
|
||||
$pdfY,
|
||||
$encoded
|
||||
);
|
||||
}
|
||||
|
||||
public function line(float $x1, float $y1, float $x2, float $y2, float $gray = 0.75): void
|
||||
{
|
||||
$this->currentPage .= sprintf(
|
||||
"%.3F G %.2F %.2F m %.2F %.2F l S\n",
|
||||
$gray,
|
||||
$x1,
|
||||
$this->height - $y1,
|
||||
$x2,
|
||||
$this->height - $y2
|
||||
);
|
||||
}
|
||||
|
||||
public function rect(float $x, float $y, float $w, float $h, float $strokeGray = 0.75): void
|
||||
{
|
||||
$this->currentPage .= sprintf(
|
||||
"%.3F G %.2F %.2F %.2F %.2F re S\n",
|
||||
$strokeGray,
|
||||
$x,
|
||||
$this->height - $y - $h,
|
||||
$w,
|
||||
$h
|
||||
);
|
||||
}
|
||||
|
||||
public function fillRect(float $x, float $y, float $w, float $h, float $gray = 0.95): void
|
||||
{
|
||||
$this->currentPage .= sprintf(
|
||||
"%.3F g %.2F %.2F %.2F %.2F re f 0 g\n",
|
||||
$gray,
|
||||
$x,
|
||||
$this->height - $y - $h,
|
||||
$w,
|
||||
$h
|
||||
);
|
||||
}
|
||||
|
||||
public function fillRectColor(float $x, float $y, float $w, float $h, string $hex): void
|
||||
{
|
||||
[$r, $g, $b] = $this->hexToRgb($hex);
|
||||
$this->currentPage .= sprintf(
|
||||
"%.3F %.3F %.3F rg %.2F %.2F %.2F %.2F re f 0 g\n",
|
||||
$r,
|
||||
$g,
|
||||
$b,
|
||||
$x,
|
||||
$this->height - $y - $h,
|
||||
$w,
|
||||
$h
|
||||
);
|
||||
}
|
||||
|
||||
public function rectColor(float $x, float $y, float $w, float $h, string $hex, float $lineWidth = 1.0): void
|
||||
{
|
||||
[$r, $g, $b] = $this->hexToRgb($hex);
|
||||
$this->currentPage .= sprintf(
|
||||
"%.3F %.3F %.3F RG %.2F w %.2F %.2F %.2F %.2F re S 0 G 1 w\n",
|
||||
$r,
|
||||
$g,
|
||||
$b,
|
||||
$lineWidth,
|
||||
$x,
|
||||
$this->height - $y - $h,
|
||||
$w,
|
||||
$h
|
||||
);
|
||||
}
|
||||
|
||||
public function textColor(float $x, float $y, string $text, string $hex, float $size = 10, bool $bold = false): void
|
||||
{
|
||||
[$r, $g, $b] = $this->hexToRgb($hex);
|
||||
$font = $bold ? 'F2' : 'F1';
|
||||
$encoded = $this->escapeText($text);
|
||||
$pdfY = $this->height - $y;
|
||||
$this->currentPage .= sprintf(
|
||||
"BT %.3F %.3F %.3F rg /%s %.2F Tf 1 0 0 1 %.2F %.2F Tm (%s) Tj ET 0 g\n",
|
||||
$r,
|
||||
$g,
|
||||
$b,
|
||||
$font,
|
||||
$size,
|
||||
$x,
|
||||
$pdfY,
|
||||
$encoded
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function wrap(string $text, float $maxWidth, float $fontSize = 9): array
|
||||
{
|
||||
$text = trim(preg_replace('/\s+/u', ' ', $text) ?? $text);
|
||||
if ($text === '') {
|
||||
return [''];
|
||||
}
|
||||
|
||||
// Helvetica moyenne : environ 0,52 em par caractère. Une estimation
|
||||
// conservatrice évite les débordements sans embarquer les métriques AFM.
|
||||
$maxChars = max(4, (int) floor($maxWidth / max(1.0, $fontSize * 0.52)));
|
||||
$words = preg_split('/\s+/u', $text) ?: [$text];
|
||||
$lines = [];
|
||||
$line = '';
|
||||
|
||||
foreach ($words as $word) {
|
||||
$candidate = $line === '' ? $word : $line . ' ' . $word;
|
||||
if ($this->stringLength($candidate) <= $maxChars) {
|
||||
$line = $candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($line !== '') {
|
||||
$lines[] = $line;
|
||||
}
|
||||
|
||||
while ($this->stringLength($word) > $maxChars) {
|
||||
$lines[] = $this->stringSlice($word, 0, $maxChars);
|
||||
$word = $this->stringSlice($word, $maxChars);
|
||||
}
|
||||
$line = $word;
|
||||
}
|
||||
|
||||
if ($line !== '') {
|
||||
$lines[] = $line;
|
||||
}
|
||||
|
||||
return $lines ?: [''];
|
||||
}
|
||||
|
||||
public function output(): string
|
||||
{
|
||||
if ($this->currentPage !== '' || $this->pages === []) {
|
||||
$this->pages[] = $this->currentPage;
|
||||
$this->currentPage = '';
|
||||
}
|
||||
|
||||
$objects = [];
|
||||
$objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
|
||||
$objects[3] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>';
|
||||
$objects[4] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>';
|
||||
|
||||
$pageObjectIds = [];
|
||||
$nextObjectId = 5;
|
||||
|
||||
foreach ($this->pages as $content) {
|
||||
$pageObjectId = $nextObjectId++;
|
||||
$contentObjectId = $nextObjectId++;
|
||||
$pageObjectIds[] = $pageObjectId;
|
||||
|
||||
$objects[$pageObjectId] = sprintf(
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2F %.2F] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents %d 0 R >>',
|
||||
$this->width,
|
||||
$this->height,
|
||||
$contentObjectId
|
||||
);
|
||||
$objects[$contentObjectId] = "<< /Length " . strlen($content) . ">>\nstream\n" . $content . "endstream";
|
||||
}
|
||||
|
||||
$kids = implode(' ', array_map(static fn (int $id): string => $id . ' 0 R', $pageObjectIds));
|
||||
$objects[2] = sprintf('<< /Type /Pages /Kids [%s] /Count %d >>', $kids, count($pageObjectIds));
|
||||
ksort($objects);
|
||||
|
||||
$pdf = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";
|
||||
$offsets = [0 => 0];
|
||||
|
||||
foreach ($objects as $id => $body) {
|
||||
$offsets[$id] = strlen($pdf);
|
||||
$pdf .= $id . " 0 obj\n" . $body . "\nendobj\n";
|
||||
}
|
||||
|
||||
$xrefOffset = strlen($pdf);
|
||||
$maxObjectId = max(array_keys($objects));
|
||||
$pdf .= "xref\n0 " . ($maxObjectId + 1) . "\n";
|
||||
$pdf .= "0000000000 65535 f \n";
|
||||
for ($id = 1; $id <= $maxObjectId; $id++) {
|
||||
$offset = $offsets[$id] ?? 0;
|
||||
$pdf .= sprintf('%010d 00000 n ', $offset) . "\n";
|
||||
}
|
||||
|
||||
$pdf .= "trailer\n<< /Size " . ($maxObjectId + 1) . " /Root 1 0 R >>\n";
|
||||
$pdf .= "startxref\n" . $xrefOffset . "\n%%EOF";
|
||||
|
||||
return $pdf;
|
||||
}
|
||||
|
||||
private function stringLength(string $value): int
|
||||
{
|
||||
return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value);
|
||||
}
|
||||
|
||||
private function stringSlice(string $value, int $start, ?int $length = null): string
|
||||
{
|
||||
if (function_exists('mb_substr')) {
|
||||
return mb_substr($value, $start, $length, 'UTF-8');
|
||||
}
|
||||
return $length === null ? substr($value, $start) : substr($value, $start, $length);
|
||||
}
|
||||
|
||||
/** @return array{0:float,1:float,2:float} */
|
||||
private function hexToRgb(string $hex): array
|
||||
{
|
||||
$hex = ltrim(trim($hex), '#');
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) {
|
||||
$hex = '000000';
|
||||
}
|
||||
|
||||
return [
|
||||
hexdec(substr($hex, 0, 2)) / 255,
|
||||
hexdec(substr($hex, 2, 2)) / 255,
|
||||
hexdec(substr($hex, 4, 2)) / 255,
|
||||
];
|
||||
}
|
||||
|
||||
private function escapeText(string $text): string
|
||||
{
|
||||
$converted = function_exists('iconv')
|
||||
? iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text)
|
||||
: false;
|
||||
if ($converted === false) {
|
||||
$converted = preg_replace('/[^\x20-\x7E]/', '?', $text) ?? $text;
|
||||
}
|
||||
|
||||
return str_replace(
|
||||
['\\', '(', ')', "\r", "\n"],
|
||||
['\\\\', '\\(', '\\)', '', ' '],
|
||||
$converted
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user