pour prod

This commit is contained in:
Loic Masi
2026-08-07 16:13:43 +02:00
commit 5fbf76868f
157 changed files with 24085 additions and 0 deletions

View 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);
}
}

View 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 dheures 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);
}
}

View 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',
};
}
}

View 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 lacadémie %s et lanné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 lannée scolaire %s nont 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 lAPI 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 lAPI 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 lancienne 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 dinitialiser 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 dURL 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('LAPI 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 lAPI nest 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
View 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
);
}
}