Ajout des commentaires dans le code
This commit is contained in:
@@ -10,13 +10,36 @@ 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.
|
||||
*
|
||||
* Document CONTRACTUEL, destiné à être signé : il n'affiche que le volume
|
||||
* annualisé dû sur la période et la clause de validation, jamais le détail des
|
||||
* créneaux planifiés.
|
||||
*
|
||||
* Seul PDF de l'application en A4 PORTRAIT (les plannings sont en paysage) :
|
||||
* c'est le format attendu d'une pièce à signer et à classer.
|
||||
*
|
||||
* Contrairement aux PDF de planning, les libellés portent ici leurs ACCENTS :
|
||||
* la valeur juridique du document prime, et iconv est présent dans le conteneur
|
||||
* fourni. À vérifier si l'application est déployée sur un PHP sans iconv.
|
||||
*/
|
||||
final class AgentHoursContractPdfService
|
||||
{
|
||||
/** A4 PORTRAIT en points PDF, contrairement aux plannings en paysage. */
|
||||
private const PAGE_WIDTH = 595.28;
|
||||
private const PAGE_HEIGHT = 841.89;
|
||||
private const MARGIN = 42.0;
|
||||
|
||||
/**
|
||||
* Assemble le contrat horaire annuel.
|
||||
*
|
||||
* La période affichée est celle du quota (année civile) et non l'année de
|
||||
* contrat ; le libellé est repris tel quel de QuotaModel.
|
||||
*
|
||||
* @param array<string, mixed> $agent
|
||||
* @param array<string, mixed> $quota Quota enrichi (forDateForApi).
|
||||
*
|
||||
* @return string Octets du PDF.
|
||||
*/
|
||||
public function annualContract(array $agent, array $quota): string
|
||||
{
|
||||
$pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT);
|
||||
@@ -39,6 +62,7 @@ final class AgentHoursContractPdfService
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
/** Titre, période de référence, date d'édition et identité de l'agent. */
|
||||
private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void
|
||||
{
|
||||
$pdf->text(self::MARGIN, 48, 'CONTRAT HORAIRE ANNUEL', 18, true);
|
||||
@@ -56,6 +80,7 @@ final class AgentHoursContractPdfService
|
||||
);
|
||||
}
|
||||
|
||||
/** Bloc d'identité : matricule, poste, contrat, lieu, quotité. */
|
||||
private function drawAgentIdentity(SimplePdf $pdf, array $agent): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
@@ -90,6 +115,7 @@ final class AgentHoursContractPdfService
|
||||
$pdf->text($rightX, $row4, 'Type du lieu : ' . $this->typeLabel((string) ($agent['structure_principale_type_affectation'] ?? '')), 8.5);
|
||||
}
|
||||
|
||||
/** Volume annualisé dû : référence, quotité appliquée, quota cible. */
|
||||
private function drawQuotaSummary(SimplePdf $pdf, array $quota): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
@@ -127,6 +153,11 @@ final class AgentHoursContractPdfService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clause de validation : le texte que les parties reconnaissent en signant.
|
||||
* Toute modification a une portée juridique — à ne pas retoucher pour des
|
||||
* raisons de mise en page.
|
||||
*/
|
||||
private function drawValidationClause(SimplePdf $pdf, string $periodLabel, DateTimeImmutable $generatedAt): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
@@ -157,6 +188,7 @@ final class AgentHoursContractPdfService
|
||||
$pdf->text($x, 658, 'Document établi le ' . $generatedAt->format('d/m/Y') . '.', 8);
|
||||
}
|
||||
|
||||
/** Cartouches de signature, laissés vides pour un remplissage manuscrit. */
|
||||
private function drawSignatures(SimplePdf $pdf): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
@@ -184,6 +216,7 @@ final class AgentHoursContractPdfService
|
||||
$pdf->text($rightX, $y + 101, 'Signature et cachet :', 8.5);
|
||||
}
|
||||
|
||||
/** Libellé du type de lieu d'affectation. */
|
||||
private function typeLabel(string $type): string
|
||||
{
|
||||
return match (strtoupper($type)) {
|
||||
@@ -193,12 +226,14 @@ final class AgentHoursContractPdfService
|
||||
};
|
||||
}
|
||||
|
||||
/** Quotité en pourcentage, telle qu'elle figure au contrat. */
|
||||
private function percentLabel(float $rate): string
|
||||
{
|
||||
$formatted = rtrim(rtrim(number_format($rate, 2, ',', ' '), '0'), ',');
|
||||
return $formatted . ' %';
|
||||
}
|
||||
|
||||
/** 450 → « 7 h 30 ». Gère les valeurs négatives. */
|
||||
private function minutesLabel(int $minutes): string
|
||||
{
|
||||
$sign = $minutes < 0 ? '-' : '';
|
||||
|
||||
@@ -10,6 +10,13 @@ use DateTimeZone;
|
||||
|
||||
/**
|
||||
* Génère un bilan annuel synthétique du temps d'un agent, ventilé par motif.
|
||||
*
|
||||
* Complément du contrat horaire : là où celui-ci fixe le volume DÛ, ce bilan
|
||||
* montre le volume RÉELLEMENT planifié et sa ventilation. A4 portrait également.
|
||||
*
|
||||
* La ventilation s'appuie sur App\Core\MotifCategories, partagé avec l'écran et
|
||||
* les autres PDF : tous les motifs de travail effectif alimentent une seule
|
||||
* ligne « Heures travaillées », le détail par motif figurant en dessous.
|
||||
*/
|
||||
final class AgentTimeSummaryPdfService
|
||||
{
|
||||
@@ -17,6 +24,16 @@ final class AgentTimeSummaryPdfService
|
||||
private const PAGE_HEIGHT = 841.89;
|
||||
private const MARGIN = 42.0;
|
||||
|
||||
/**
|
||||
* Assemble le bilan d'heures annuel.
|
||||
*
|
||||
* @param array<string, mixed> $agent
|
||||
* @param array<string, mixed> $quota Quota enrichi (forDateForApi).
|
||||
* @param array<int, array<string, mixed>> $rows Cumuls par motif issus de
|
||||
* AgentModel::timeSummaryForRange().
|
||||
*
|
||||
* @return string Octets du PDF.
|
||||
*/
|
||||
public function annualSummary(array $agent, array $quota, array $rows): string
|
||||
{
|
||||
$pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT);
|
||||
@@ -33,6 +50,7 @@ final class AgentTimeSummaryPdfService
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
/** Titre, période couverte et date d'édition. */
|
||||
private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void
|
||||
{
|
||||
$pdf->text(self::MARGIN, 34, 'PTA', 10, true);
|
||||
@@ -42,6 +60,7 @@ final class AgentTimeSummaryPdfService
|
||||
$pdf->line(self::MARGIN, 94, self::PAGE_WIDTH - self::MARGIN, 94, 0.72);
|
||||
}
|
||||
|
||||
/** Bloc d'identité de l'agent. */
|
||||
private function drawIdentity(SimplePdf $pdf, array $agent): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
@@ -58,6 +77,10 @@ final class AgentTimeSummaryPdfService
|
||||
$pdf->text($x + 270, $y + 73, 'Lieu principal : ' . (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigné'), 9.2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tableau de ventilation : une ligne par catégorie, chacune avec sa couleur,
|
||||
* puis le détail des motifs de travail sous la ligne « Heures travaillées ».
|
||||
*/
|
||||
private function drawBreakdown(SimplePdf $pdf, array $totals): void
|
||||
{
|
||||
$x = self::MARGIN;
|
||||
@@ -109,6 +132,14 @@ final class AgentTimeSummaryPdfService
|
||||
* @param array<string, int> $detail
|
||||
* @return string[]
|
||||
*/
|
||||
/**
|
||||
* Met en forme le détail des motifs de travail pour tenir dans la largeur
|
||||
* disponible.
|
||||
*
|
||||
* @param array<string, int> $detail Minutes indexées par libellé de motif.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function detailLines(SimplePdf $pdf, array $detail, float $width): array
|
||||
{
|
||||
arsort($detail);
|
||||
@@ -143,6 +174,10 @@ final class AgentTimeSummaryPdfService
|
||||
return $pdf->wrap($truncated ? $text . ' …' : $text, $width, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bloc de conclusion : quota cible, temps décompté, et écart.
|
||||
* C'est la seule partie du document qui confronte le réalisé à l'attendu.
|
||||
*/
|
||||
private function drawResult(SimplePdf $pdf, array $quota, array $totals): void
|
||||
{
|
||||
// Le décompte suit le drapeau compte_dans_quota du motif, comme QuotaModel :
|
||||
@@ -182,6 +217,7 @@ final class AgentTimeSummaryPdfService
|
||||
}
|
||||
}
|
||||
|
||||
/** Pied de page : origine du document, matricule, horodatage. */
|
||||
private function drawFooter(SimplePdf $pdf, array $agent, DateTimeImmutable $generatedAt): void
|
||||
{
|
||||
$pdf->text(
|
||||
@@ -197,6 +233,15 @@ final class AgentTimeSummaryPdfService
|
||||
* (ALP matin, ALP soir, restauration, mercredi périscolaire, extrascolaire,
|
||||
* préparations, ménage de fond…) alimentent la ligne « Heures travaillées », et pas
|
||||
* seulement le motif générique TRAVAIL.
|
||||
*
|
||||
* Le total « DECOMPTE » est calculé indépendamment des catégories, sur le
|
||||
* seul drapeau `compte_dans_quota` : c'est lui qui se compare au quota
|
||||
* cible, et il ne se déduit pas de la somme des catégories.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*
|
||||
* @return array<string, mixed> Une clé par catégorie, plus « DECOMPTE » et
|
||||
* « DETAIL_TRAVAIL » (minutes par libellé de motif).
|
||||
*/
|
||||
private function normalizeTotals(array $rows): array
|
||||
{
|
||||
@@ -230,7 +275,11 @@ final class AgentTimeSummaryPdfService
|
||||
return $totals;
|
||||
}
|
||||
|
||||
/** @return array{accent:string} */
|
||||
/**
|
||||
* Couleur d'accent d'une catégorie, alignée sur celle des PDF de planning.
|
||||
*
|
||||
* @return array{accent:string}
|
||||
*/
|
||||
private function palette(string $code): array
|
||||
{
|
||||
return match (strtoupper($code)) {
|
||||
@@ -242,6 +291,7 @@ final class AgentTimeSummaryPdfService
|
||||
};
|
||||
}
|
||||
|
||||
/** 450 → « 7 h 30 ». Gère les valeurs négatives (écart de quota). */
|
||||
private function minutesLabel(int $minutes): string
|
||||
{
|
||||
$sign = $minutes < 0 ? '-' : '';
|
||||
|
||||
@@ -7,13 +7,35 @@ namespace App\Services;
|
||||
use App\Core\MotifCategories;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Mise en page des plannings au format PDF, par agent ou par lieu.
|
||||
*
|
||||
* Deux documents, tous deux en A4 PAYSAGE (SimplePdf par défaut) :
|
||||
* - agentWeeks() : un agent sur 1 à 7 semaines, en blocs compacts ;
|
||||
* - structureWeek() : un lieu sur une semaine, en tableau agents × jours, avec
|
||||
* saut de page géré à la main.
|
||||
*
|
||||
* CONTRAINTE TRANSVERSE — les libellés sont écrits SANS ACCENT dans tout ce
|
||||
* fichier (« Document genere », « Periscolaire »). Ce n'est pas une négligence :
|
||||
* SimplePdf convertit en Windows-1252 avec translittération, et cette écriture
|
||||
* garantit un rendu identique même quand iconv n'est pas disponible. Les données
|
||||
* venant de la base, elles, gardent leurs accents.
|
||||
*
|
||||
* Toutes les coordonnées sont en POINTS PDF, origine en HAUT à gauche (voir
|
||||
* SimplePdf). Les valeurs numériques éparpillées dans les méthodes de dessin
|
||||
* sont des positions absolues ajustées visuellement : les modifier suppose de
|
||||
* regénérer un document pour vérifier.
|
||||
*/
|
||||
final class PlanningPdfService
|
||||
{
|
||||
/** Marge extérieure commune à tous les documents. */
|
||||
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.
|
||||
* Conservée pour d'éventuels appels externes ; le code actuel passe
|
||||
* directement par agentWeeks().
|
||||
*/
|
||||
public function agentWeek(array $agent, array $entries, array $week, array $quota): string
|
||||
{
|
||||
@@ -23,6 +45,24 @@ final class PlanningPdfService
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* Le découpage en pages est fait par array_chunk(..., 4) : QUATRE SEMAINES
|
||||
* PAR PAGE, chaque bloc occupant 112 points de haut à partir de y = 96. Ce
|
||||
* plafond de quatre est une contrainte physique, SimplePdf ne sachant pas
|
||||
* reporter un tableau sur la page suivante. Passer à cinq exigerait de
|
||||
* réduire la hauteur des blocs.
|
||||
*
|
||||
* Au-delà de sept semaines, la liste est silencieusement TRONQUÉE : le
|
||||
* contrôleur n'accepte de toute façon que 6 ou 7.
|
||||
*
|
||||
* @param array<string, mixed> $agent
|
||||
* @param array<int, array<string, mixed>> $entries Créneaux de toute la période.
|
||||
* @param array<int, array<string, mixed>> $weeks Semaines à rendre.
|
||||
* @param array<string, mixed> $quota
|
||||
*
|
||||
* @return string Octets du PDF.
|
||||
*
|
||||
* @throws \InvalidArgumentException Si aucune semaine n'est fournie.
|
||||
*/
|
||||
public function agentWeeks(array $agent, array $entries, array $weeks, array $quota): string
|
||||
{
|
||||
@@ -60,6 +100,24 @@ final class PlanningPdfService
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* Planning d'un lieu sur une semaine : un tableau agents × cinq jours.
|
||||
*
|
||||
* Contrairement à agentWeeks(), la hauteur des lignes n'est PAS connue à
|
||||
* l'avance — elle dépend du nombre de créneaux de chaque agent et du
|
||||
* découpage des libellés. La méthode mesure donc chaque ligne avant de la
|
||||
* tracer, et déclenche une nouvelle page dès que $bottomLimit serait
|
||||
* dépassé. La closure $drawHeader existe pour cette raison : l'en-tête du
|
||||
* tableau doit être redessiné en tête de CHAQUE page, sans quoi les colonnes
|
||||
* ne seraient plus identifiables.
|
||||
*
|
||||
* @param array<string, mixed> $structure
|
||||
* @param array<int, array<string, mixed>> $agents Chaque agent porte ses
|
||||
* propres clés `entries` et `quota`.
|
||||
* @param array<string, mixed> $week
|
||||
*
|
||||
* @return string Octets du PDF.
|
||||
*/
|
||||
public function structureWeek(array $structure, array $agents, array $week): string
|
||||
{
|
||||
$pdf = new SimplePdf();
|
||||
@@ -201,6 +259,10 @@ final class PlanningPdfService
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* En-tête du planning agent : identité, lieu principal, période couverte,
|
||||
* quota restant, et pagination si le document tient sur plusieurs pages.
|
||||
*/
|
||||
private function drawAgentMultiWeekHeader(
|
||||
SimplePdf $pdf,
|
||||
array $agent,
|
||||
@@ -228,7 +290,19 @@ final class PlanningPdfService
|
||||
$pdf->line(self::MARGIN, 86, $pdf->width() - self::MARGIN, 86, 0.72);
|
||||
}
|
||||
|
||||
/** @param array<string, array<int, array>> $entriesByDate */
|
||||
/**
|
||||
* Dessine UN bloc de semaine : bandeau de titre, en-têtes de jours, puis les
|
||||
* créneaux de chaque journée dans une colonne.
|
||||
*
|
||||
* La hauteur du corps est FIXE (77 points) pour que les blocs s'empilent à
|
||||
* pas régulier. Les créneaux qui n'y tiennent pas ne sont pas rendus — le
|
||||
* compteur $rendered plus bas s'en charge. Une journée très chargée peut
|
||||
* donc apparaître tronquée : c'est le compromis retenu pour garantir sept
|
||||
* semaines sur deux pages.
|
||||
*
|
||||
* @param array<string, array<int, array>> $entriesByDate Créneaux indexés par date.
|
||||
* @param float $y Ordonnée du haut du bloc.
|
||||
*/
|
||||
private function drawCompactWeek(SimplePdf $pdf, array $week, array $entriesByDate, float $y): void
|
||||
{
|
||||
$weekDays = $this->weekDays($week);
|
||||
@@ -287,7 +361,13 @@ final class PlanningPdfService
|
||||
}
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
/**
|
||||
* Lignes d'un créneau dans la vue agent : horaires, motif si informatif,
|
||||
* lieu, et la mention « Brouillon » quand le planning n'est pas validé —
|
||||
* un document imprimé doit dire s'il est définitif.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function compactAgentEntryLines(SimplePdf $pdf, array $entry, float $width): array
|
||||
{
|
||||
$lines = [sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin'])];
|
||||
@@ -310,7 +390,12 @@ final class PlanningPdfService
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
/**
|
||||
* Lignes d'un créneau dans la vue lieu : plus condensées que dans la vue
|
||||
* agent, le lieu étant déjà celui du document.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function structureEntryLines(SimplePdf $pdf, array $entry, float $width): array
|
||||
{
|
||||
$first = sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin']);
|
||||
@@ -333,7 +418,11 @@ final class PlanningPdfService
|
||||
: (string) ($entry['motif_libelle'] ?? 'Affectation');
|
||||
}
|
||||
|
||||
/** @return array{accent:string,background:string,label:string} */
|
||||
/**
|
||||
* Palette d'un créneau, déduite de son motif.
|
||||
*
|
||||
* @return array{accent:string,background:string,label:string}
|
||||
*/
|
||||
private function entryPalette(array $entry): array
|
||||
{
|
||||
return $this->motifPalette(
|
||||
@@ -342,7 +431,15 @@ final class PlanningPdfService
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{accent:string,background:string,label:string} */
|
||||
/**
|
||||
* Couleurs par CATÉGORIE de motif — travail en bleu, formation en violet,
|
||||
* congé en vert, absence en rouge, reste en gris.
|
||||
*
|
||||
* Le classement passe par App\Core\MotifCategories, partagé avec l'écran et
|
||||
* les autres PDF : une même catégorie garde partout la même couleur.
|
||||
*
|
||||
* @return array{accent:string,background:string,label:string}
|
||||
*/
|
||||
private function motifPalette(string $code, bool $countsInQuota = false): array
|
||||
{
|
||||
return match (MotifCategories::category($code, $countsInQuota)) {
|
||||
@@ -354,6 +451,12 @@ final class PlanningPdfService
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Légende des couleurs, en une ligne au pied du document.
|
||||
*
|
||||
* Les entrées sont espacées selon la LARGEUR ESTIMÉE de leur libellé et non
|
||||
* à pas fixe : la première est trop longue pour une colonne régulière.
|
||||
*/
|
||||
private function drawLegend(SimplePdf $pdf, float $x, float $y): void
|
||||
{
|
||||
$items = [
|
||||
@@ -374,7 +477,15 @@ final class PlanningPdfService
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, array>> */
|
||||
/**
|
||||
* Indexe les créneaux par date et les trie par heure de début.
|
||||
*
|
||||
* Le tri est refait ici bien que le SQL trie déjà : les créneaux peuvent
|
||||
* avoir été fusionnés depuis plusieurs sources (plusieurs lieux, plusieurs
|
||||
* plannings) avant d'arriver.
|
||||
*
|
||||
* @return array<string, array<int, array>>
|
||||
*/
|
||||
private function entriesByDate(array $entries): array
|
||||
{
|
||||
$grouped = [];
|
||||
@@ -388,6 +499,7 @@ final class PlanningPdfService
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/** En-tête commun du planning par lieu : titre, sous-titre, semaine, filet. */
|
||||
private function drawDocumentHeader(SimplePdf $pdf, string $title, string $subtitle, string $weekLabel): void
|
||||
{
|
||||
$pdf->text(self::MARGIN, 34, 'PTA', 10, true);
|
||||
@@ -397,7 +509,12 @@ final class PlanningPdfService
|
||||
$pdf->line(self::MARGIN, 90, $pdf->width() - self::MARGIN, 90, 0.72);
|
||||
}
|
||||
|
||||
/** @return array<int, array{date:string,label:string,display:string}> */
|
||||
/**
|
||||
* Les cinq jours ouvrés d'une semaine, à partir de sa date de début.
|
||||
* S'arrête au vendredi, le week-end n'étant pas planifiable.
|
||||
*
|
||||
* @return array<int, array{date:string,label:string,display:string}>
|
||||
*/
|
||||
private function weekDays(array $week): array
|
||||
{
|
||||
$start = new DateTimeImmutable((string) $week['date_debut']);
|
||||
@@ -413,6 +530,7 @@ final class PlanningPdfService
|
||||
return $days;
|
||||
}
|
||||
|
||||
/** « Semaine du 16/02/2026 au 20/02/2026 » — bornée au vendredi. */
|
||||
private function weekLabel(array $week): string
|
||||
{
|
||||
$start = new DateTimeImmutable((string) $week['date_debut']);
|
||||
@@ -420,6 +538,7 @@ final class PlanningPdfService
|
||||
return sprintf('Semaine du %s au %s', $start->format('d/m/Y'), $end->format('d/m/Y'));
|
||||
}
|
||||
|
||||
/** Abrège le nom du jour pour les colonnes étroites : « Lundi » → « Lun. ». */
|
||||
private function shortDayLabel(string $label): string
|
||||
{
|
||||
return match ($label) {
|
||||
@@ -432,6 +551,7 @@ final class PlanningPdfService
|
||||
};
|
||||
}
|
||||
|
||||
/** Libellé du type de lieu, sans accent (contrainte d'encodage du PDF). */
|
||||
private function typeLabel(string $type): string
|
||||
{
|
||||
return match (strtoupper($type)) {
|
||||
|
||||
@@ -8,8 +8,26 @@ use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Import des vacances scolaires depuis l'API publique data.education.gouv.fr.
|
||||
*
|
||||
* Ce service ne fait que PROPOSER : il ne touche jamais la base. L'écriture
|
||||
* relève de VacationModel, après validation manuelle par un utilisateur. Ce
|
||||
* découpage est délibéré — les libellés et découpages publiés varient d'une
|
||||
* académie et d'une année à l'autre, et rien de tout cela n'entre en base sans
|
||||
* relecture humaine.
|
||||
*
|
||||
* TOLÉRANCE AUX PANNES, principe directeur de tout le fichier. Le portail public
|
||||
* change de version d'API, de graphie d'académie et de format de réponse sans
|
||||
* préavis. Chaque étape est donc doublée d'un repli, et un échec partiel produit
|
||||
* un AVERTISSEMENT plutôt qu'une erreur : mieux vaut proposer les dates
|
||||
* disponibles que rien du tout.
|
||||
*
|
||||
* Nécessite un accès réseau SORTANT depuis le conteneur php.
|
||||
*/
|
||||
final class SchoolHolidayApiService
|
||||
{
|
||||
/** Jeu de données public du calendrier scolaire. */
|
||||
private const DATASET = 'fr-en-calendrier-scolaire';
|
||||
|
||||
/**
|
||||
@@ -24,35 +42,24 @@ final class SchoolHolidayApiService
|
||||
|
||||
private const LEGACY_ENDPOINT = 'https://data.education.gouv.fr/api/records/1.0/search/';
|
||||
|
||||
/**
|
||||
* Zone de vacances (A, B ou C) de chaque académie métropolitaine.
|
||||
*
|
||||
* Table locale servant de REPLI quand l'API ne publie pas de ligne propre à
|
||||
* l'académie mais seulement une ligne commune à sa zone. À mettre à jour si
|
||||
* le découpage officiel évolue — il a déjà changé par le passé, et les
|
||||
* académies d'outre-mer n'y figurent pas.
|
||||
*/
|
||||
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',
|
||||
'dijon' => 'Zone A'
|
||||
];
|
||||
|
||||
/**
|
||||
* Compatibilité avec le code existant : retourne uniquement les périodes.
|
||||
* Compatibilité avec le code existant : retourne uniquement les périodes,
|
||||
* sans les avertissements. Le code actuel appelle plutôt la variante
|
||||
* détaillée, qui permet d'afficher ce qui n'a pas pu être chargé.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function previewCalendarYear(string $academy, int $calendarYear): array
|
||||
{
|
||||
@@ -65,7 +72,19 @@ final class SchoolHolidayApiService
|
||||
* scolaires. L'absence de publication de l'une d'elles ne doit donc plus
|
||||
* empêcher l'affichage des dates déjà disponibles.
|
||||
*
|
||||
* Une année civile couvre la FIN d'une année scolaire et le DÉBUT de la
|
||||
* suivante : les deux sont donc interrogées, puis les périodes fusionnées et
|
||||
* dédoublonnées sur (libellé, début, fin). Ne sont conservées que celles qui
|
||||
* INTERSECTENT l'année civile demandée.
|
||||
*
|
||||
* L'exception n'est levée que si AUCUNE des deux requêtes n'a abouti : un
|
||||
* seul succès suffit à rendre un résultat exploitable, l'échec de l'autre
|
||||
* partant en avertissement.
|
||||
*
|
||||
* @return array{periods: array, warnings: array, school_years: array}
|
||||
*
|
||||
* @throws RuntimeException Année ou académie invalide, ou API totalement
|
||||
* injoignable.
|
||||
*/
|
||||
public function previewCalendarYearDetailed(string $academy, int $calendarYear): array
|
||||
{
|
||||
@@ -135,13 +154,28 @@ final class SchoolHolidayApiService
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibilité avec l'ancienne API interne V5.17.
|
||||
* Compatibilité avec l'ancienne API interne V5.17, qui raisonnait en année
|
||||
* scolaire et non en année civile.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function preview(string $academy, string $schoolYear): array
|
||||
{
|
||||
return $this->previewSchoolYear($academy, $schoolYear);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interroge l'API pour UNE année scolaire, avec trois stratégies en cascade.
|
||||
*
|
||||
* On s'arrête au PREMIER résultat non vide ; les erreurs intermédiaires sont
|
||||
* accumulées et seule la dernière est remontée si tout échoue. Un résultat
|
||||
* vide sans erreur n'est pas une anomalie : l'académie peut simplement ne
|
||||
* rien avoir publié.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*
|
||||
* @throws RuntimeException Si toutes les stratégies ont échoué en erreur.
|
||||
*/
|
||||
private function previewSchoolYear(string $academy, string $schoolYear): array
|
||||
{
|
||||
$errors = [];
|
||||
@@ -212,6 +246,16 @@ final class SchoolHolidayApiService
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interroge l'API Explore, en essayant successivement chaque version
|
||||
* d'endpoint jusqu'à obtenir une réponse exploitable.
|
||||
*
|
||||
* @param array<string, mixed> $parameters Paramètres de requête Explore.
|
||||
*
|
||||
* @return array<int, mixed> Contenu brut de la clé `results`.
|
||||
*
|
||||
* @throws RuntimeException Si aucun endpoint n'a répondu correctement.
|
||||
*/
|
||||
private function fetchExploreRecords(array $parameters): array
|
||||
{
|
||||
$query = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
|
||||
@@ -233,6 +277,17 @@ final class SchoolHolidayApiService
|
||||
throw new RuntimeException($lastError?->getMessage() ?? 'API Explore indisponible.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dernier repli : l'ancienne API Opendatasoft 1.0.
|
||||
*
|
||||
* Son format diffère — les champs utiles sont imbriqués sous `fields` — et
|
||||
* sont remis à plat ici pour que normalize() traite les deux sources de la
|
||||
* même façon.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*
|
||||
* @throws RuntimeException Si la réponse n'a pas la forme attendue.
|
||||
*/
|
||||
private function fetchLegacyRecords(string $academy, string $schoolYear): array
|
||||
{
|
||||
$query = http_build_query([
|
||||
@@ -257,6 +312,30 @@ final class SchoolHolidayApiService
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit les enregistrements bruts de l'API en périodes exploitables.
|
||||
*
|
||||
* Trois filtres successifs :
|
||||
* 1. la POPULATION doit concerner les élèves — un enregistrement réservé
|
||||
* aux enseignants ne fait pas de vacances pour l'accueil de loisirs ;
|
||||
* 2. hors repli par zone, l'ACADÉMIE doit correspondre à celle demandée,
|
||||
* comparée après normalisation (accents et tirets neutralisés) ;
|
||||
* 3. la DESCRIPTION doit ressembler à des vacances (voir
|
||||
* isVacationDescription()), afin d'écarter rentrées et pré-rentrées.
|
||||
*
|
||||
* CONVERSION DE DATE ESSENTIELLE : l'API publie la DATE DE REPRISE des
|
||||
* cours, alors que PTA raisonne en bornes INCLUSIVES. Le dernier jour de
|
||||
* vacances est donc la veille de la reprise. Sans cette correction, chaque
|
||||
* période gagnerait un jour et le premier jour de classe serait qualifié
|
||||
* d'extrascolaire.
|
||||
*
|
||||
* @param array<int, mixed> $records
|
||||
* @param bool $zoneFallback true quand les données viennent du repli par
|
||||
* zone : le filtre sur l'académie est alors levé, et l'académie
|
||||
* demandée est conservée dans le résultat.
|
||||
*
|
||||
* @return array<int, array<string, mixed>> Triées par date de début.
|
||||
*/
|
||||
private function normalize(array $records, string $academy, string $schoolYear, bool $zoneFallback): array
|
||||
{
|
||||
$periods = [];
|
||||
@@ -319,6 +398,18 @@ final class SchoolHolidayApiService
|
||||
return $periods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnaît une période de vacances à partir de sa description.
|
||||
*
|
||||
* Filtrage par MOTS-CLÉS faute de champ de type dans le jeu de données. Les
|
||||
* rentrées et pré-rentrées sont exclues explicitement : ce sont des
|
||||
* événements ponctuels, pas des périodes. « été austral » et « hiver
|
||||
* austral » couvrent les académies d'outre-mer.
|
||||
*
|
||||
* Une description non reconnue est SILENCIEUSEMENT ignorée : un libellé
|
||||
* inédit peut donc faire disparaître une période, à surveiller si des
|
||||
* vacances manquent à l'appel.
|
||||
*/
|
||||
private function isVacationDescription(string $description): bool
|
||||
{
|
||||
$normalized = $this->normalizeText($description);
|
||||
@@ -337,6 +428,17 @@ final class SchoolHolidayApiService
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graphies alternatives d'un nom d'académie à essayer successivement.
|
||||
*
|
||||
* Le portail n'est pas constant : « Nancy-Metz », « Nancy Metz »,
|
||||
* « Orléans-Tours » ou « Orleans-Tours » selon les enregistrements, avec
|
||||
* parfois un tiret cadratin plutôt qu'un trait d'union. On essaie donc
|
||||
* l'original, les tirets uniformisés, la version sans accents, et les
|
||||
* variantes à espaces.
|
||||
*
|
||||
* @return array<int, string> Sans doublon, dans l'ordre d'essai.
|
||||
*/
|
||||
private function academyVariants(string $academy): array
|
||||
{
|
||||
$academy = trim(preg_replace('/\s+/u', ' ', $academy) ?? $academy);
|
||||
@@ -349,12 +451,35 @@ final class SchoolHolidayApiService
|
||||
return array_values(array_unique(array_filter(array_map('trim', $variants))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zone de vacances d'une académie, ou null si elle est inconnue de la table
|
||||
* (outre-mer, ou nom mal orthographié).
|
||||
*/
|
||||
private function zoneForAcademy(string $academy): ?string
|
||||
{
|
||||
$key = $this->normalizeAcademy($academy);
|
||||
return self::ACADEMY_ZONES[str_replace(' ', '-', $key)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectue la requête HTTP et décode le JSON.
|
||||
*
|
||||
* Deux implémentations : cURL quand l'extension est présente, sinon
|
||||
* file_get_contents() avec un contexte de flux. Ce second chemin ne
|
||||
* fonctionne que si `allow_url_fopen` est actif, d'où le message explicite
|
||||
* en cas d'échec — c'est la panne la plus fréquente sur un hébergement
|
||||
* mutualisé.
|
||||
*
|
||||
* Délais volontairement courts (8 s de connexion, 20 s au total) : l'appel
|
||||
* est SYNCHRONE dans une requête web, l'utilisateur attend devant son écran.
|
||||
*
|
||||
* `ignore_errors` et CURLOPT_RETURNTRANSFER permettent de lire le corps même
|
||||
* sur un code d'erreur, mais tout code >= 400 est traité comme un échec.
|
||||
*
|
||||
* @return array<mixed> Charge utile décodée.
|
||||
*
|
||||
* @throws RuntimeException Erreur réseau, code HTTP >= 400, ou JSON invalide.
|
||||
*/
|
||||
private function fetchJson(string $url): array
|
||||
{
|
||||
$body = false;
|
||||
@@ -412,11 +537,23 @@ final class SchoolHolidayApiService
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Échappe une valeur pour la clause `where` de l'API Explore, dont la
|
||||
* syntaxe est proche du SQL : les guillemets et les barres obliques
|
||||
* inverses doivent être protégés pour ne pas altérer la requête distante.
|
||||
*/
|
||||
private function quoteWhereValue(string $value): string
|
||||
{
|
||||
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la partie date d'un horodatage ISO.
|
||||
* L'API renvoie tantôt « 2026-02-21 », tantôt
|
||||
* « 2026-02-21T23:00:00+00:00 » ; seule la date nous intéresse.
|
||||
*
|
||||
* @return string|null null si aucune date n'est reconnaissable.
|
||||
*/
|
||||
private function dateOnly(string $value): ?string
|
||||
{
|
||||
if (!preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $matches)) {
|
||||
@@ -425,6 +562,11 @@ final class SchoolHolidayApiService
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ramène une valeur d'API à une chaîne.
|
||||
* Certains champs (zones, population) sont tantôt scalaires, tantôt des
|
||||
* tableaux selon la version de l'API ; les tableaux sont aplatis.
|
||||
*/
|
||||
private function stringValue(mixed $value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
@@ -434,6 +576,13 @@ final class SchoolHolidayApiService
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Vrai si l'enregistrement concerne les élèves.
|
||||
*
|
||||
* Acceptent : la valeur vide, « - », « Élèves », « Tous », « Tout public ».
|
||||
* Les vacances communes sont en effet publiées avec une population « - » ;
|
||||
* ne retenir que « Élèves » les faisait disparaître du résultat.
|
||||
*/
|
||||
private function isStudentPopulation(mixed $value): bool
|
||||
{
|
||||
$values = is_array($value) ? $value : [$value];
|
||||
@@ -446,12 +595,21 @@ final class SchoolHolidayApiService
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forme canonique d'un nom d'académie pour comparaison : tirets et
|
||||
* soulignés deviennent des espaces, accents et casse neutralisés.
|
||||
* « Nancy-Metz », « nancy metz » et « NANCY_METZ » se rejoignent ainsi.
|
||||
*/
|
||||
private function normalizeAcademy(string $value): string
|
||||
{
|
||||
$value = str_replace(['–', '—', '‑', '_', '-'], ' ', $value);
|
||||
return $this->normalizeText($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forme canonique d'un texte : sans accent, en minuscules, réduit aux
|
||||
* lettres, chiffres et tirets, espaces compactés.
|
||||
*/
|
||||
private function normalizeText(string $value): string
|
||||
{
|
||||
$value = $this->stripAccents($value);
|
||||
@@ -462,6 +620,14 @@ final class SchoolHolidayApiService
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire les accents par une table de correspondance explicite.
|
||||
*
|
||||
* Table écrite à la main plutôt qu'iconv ou intl : ces extensions ne sont
|
||||
* pas garanties partout, et leur comportement de translittération varie
|
||||
* selon la locale du système. Une table donne un résultat identique
|
||||
* quel que soit l'hébergement.
|
||||
*/
|
||||
private function stripAccents(string $value): string
|
||||
{
|
||||
return strtr($value, [
|
||||
|
||||
@@ -8,9 +8,27 @@ 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.
|
||||
*
|
||||
* SYSTÈME DE COORDONNÉES — le point le plus important à comprendre ici. Le
|
||||
* format PDF place l'origine EN BAS À GAUCHE, l'axe Y montant. Cette classe
|
||||
* expose au contraire une origine EN HAUT À GAUCHE, Y descendant, comme en CSS :
|
||||
* c'est ainsi qu'on raisonne pour composer une page. La conversion
|
||||
* (`height - y`) est faite dans CHAQUE méthode de dessin. Toute nouvelle
|
||||
* primitive doit l'appliquer aussi, sinon elle dessinera à l'envers.
|
||||
*
|
||||
* Ce que cette classe ne sait PAS faire, et qui contraint les appelants :
|
||||
* - aucune image, aucune police embarquée (Helvetica et Helvetica-Bold seules) ;
|
||||
* - aucun retour à la ligne automatique : voir wrap(), qui ESTIME la largeur ;
|
||||
* - aucun saut de page automatique : l'appelant appelle addPage() lui-même et
|
||||
* doit surveiller ses hauteurs. C'est pourquoi le PDF de planning agent est
|
||||
* plafonné à 6 ou 7 semaines.
|
||||
*
|
||||
* Le texte est converti en Windows-1252 (voir escapeText()) : les caractères
|
||||
* hors de ce jeu, émoji compris, sont translittérés ou remplacés.
|
||||
*/
|
||||
final class SimplePdf
|
||||
{
|
||||
/** Dimensions A4 paysage en POINTS PDF (1 pt = 1/72 pouce). */
|
||||
public const A4_LANDSCAPE_WIDTH = 841.89;
|
||||
public const A4_LANDSCAPE_HEIGHT = 595.28;
|
||||
|
||||
@@ -35,6 +53,12 @@ final class SimplePdf
|
||||
return $this->height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre une nouvelle page.
|
||||
*
|
||||
* La page en cours est empilée si elle contient quelque chose. Appeler
|
||||
* addPage() deux fois de suite ne produit donc pas de page blanche.
|
||||
*/
|
||||
public function addPage(): void
|
||||
{
|
||||
if ($this->currentPage !== '') {
|
||||
@@ -43,6 +67,13 @@ final class SimplePdf
|
||||
$this->currentPage = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit du texte en noir. ($x, $y) est le point de BASE (« baseline ») du
|
||||
* premier caractère, mesuré depuis le HAUT de la page.
|
||||
*
|
||||
* @param float $size Corps en points.
|
||||
* @param bool $bold true pour Helvetica-Bold.
|
||||
*/
|
||||
public function text(float $x, float $y, string $text, float $size = 10, bool $bold = false): void
|
||||
{
|
||||
$font = $bold ? 'F2' : 'F1';
|
||||
@@ -58,6 +89,11 @@ final class SimplePdf
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace un trait.
|
||||
*
|
||||
* @param float $gray Niveau de gris, 0 = noir, 1 = blanc.
|
||||
*/
|
||||
public function line(float $x1, float $y1, float $x2, float $y2, float $gray = 0.75): void
|
||||
{
|
||||
$this->currentPage .= sprintf(
|
||||
@@ -70,6 +106,10 @@ final class SimplePdf
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace le CONTOUR d'un rectangle. ($x, $y) est son coin supérieur gauche ;
|
||||
* la conversion vers le coin inférieur gauche attendu par PDF est faite ici.
|
||||
*/
|
||||
public function rect(float $x, float $y, float $w, float $h, float $strokeGray = 0.75): void
|
||||
{
|
||||
$this->currentPage .= sprintf(
|
||||
@@ -82,6 +122,7 @@ final class SimplePdf
|
||||
);
|
||||
}
|
||||
|
||||
/** Rectangle PLEIN en niveau de gris (fonds de cellules, bandeaux). */
|
||||
public function fillRect(float $x, float $y, float $w, float $h, float $gray = 0.95): void
|
||||
{
|
||||
$this->currentPage .= sprintf(
|
||||
@@ -94,6 +135,12 @@ final class SimplePdf
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rectangle plein en couleur.
|
||||
*
|
||||
* @param string $hex Couleur « #RRGGBB » ou « #RGB » ; une valeur invalide
|
||||
* retombe sur le noir plutôt que d'échouer.
|
||||
*/
|
||||
public function fillRectColor(float $x, float $y, float $w, float $h, string $hex): void
|
||||
{
|
||||
[$r, $g, $b] = $this->hexToRgb($hex);
|
||||
@@ -109,6 +156,7 @@ final class SimplePdf
|
||||
);
|
||||
}
|
||||
|
||||
/** Contour de rectangle en couleur, d'épaisseur réglable. */
|
||||
public function rectColor(float $x, float $y, float $w, float $h, string $hex, float $lineWidth = 1.0): void
|
||||
{
|
||||
[$r, $g, $b] = $this->hexToRgb($hex);
|
||||
@@ -125,6 +173,7 @@ final class SimplePdf
|
||||
);
|
||||
}
|
||||
|
||||
/** Texte en couleur. Mêmes conventions que text(). */
|
||||
public function textColor(float $x, float $y, string $text, string $hex, float $size = 10, bool $bold = false): void
|
||||
{
|
||||
[$r, $g, $b] = $this->hexToRgb($hex);
|
||||
@@ -145,7 +194,19 @@ final class SimplePdf
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
* Découpe un texte en lignes tenant dans une largeur donnée.
|
||||
*
|
||||
* APPROXIMATION ASSUMÉE : la largeur réelle d'un caractère Helvetica varie
|
||||
* (un « i » et un « W » n'occupent pas la même place). Mesurer exactement
|
||||
* exigerait d'embarquer les métriques AFM de la police, ce qui va à
|
||||
* l'encontre du parti pris « aucune dépendance ». On retient donc une
|
||||
* moyenne de 0,52 em par caractère, volontairement CONSERVATRICE : le texte
|
||||
* peut être coupé un peu tôt, mais ne déborde jamais de sa cellule.
|
||||
*
|
||||
* Un mot plus long que la largeur disponible est coupé de force, sans quoi
|
||||
* il déborderait à coup sûr.
|
||||
*
|
||||
* @return string[] Toujours au moins une ligne, éventuellement vide.
|
||||
*/
|
||||
public function wrap(string $text, float $maxWidth, float $fontSize = 9): array
|
||||
{
|
||||
@@ -186,6 +247,21 @@ final class SimplePdf
|
||||
return $lines ?: [''];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble le document et retourne les octets du PDF.
|
||||
*
|
||||
* Structure produite, conforme à un PDF 1.4 minimal :
|
||||
* objet 1 = catalogue, 2 = arbre des pages, 3/4 = les deux polices,
|
||||
* puis, pour chaque page, un objet /Page et un objet de contenu.
|
||||
*
|
||||
* Vient ensuite la table de références croisées (« xref »), qui donne le
|
||||
* DÉCALAGE EN OCTETS de chaque objet depuis le début du fichier — d'où les
|
||||
* strlen() successifs. Le tout se termine par le trailer et startxref, qui
|
||||
* indique où trouver cette table.
|
||||
*
|
||||
* À ne appeler qu'UNE FOIS : la méthode consomme la page courante en la
|
||||
* poussant dans la pile. Un second appel ajouterait une page vide.
|
||||
*/
|
||||
public function output(): string
|
||||
{
|
||||
if ($this->currentPage !== '' || $this->pages === []) {
|
||||
@@ -242,11 +318,17 @@ final class SimplePdf
|
||||
return $pdf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Longueur en CARACTÈRES, avec repli sur les octets si mbstring est absent.
|
||||
* Le repli surestime la longueur d'un texte accentué, ce qui ne fait que
|
||||
* couper un peu plus tôt — jamais déborder.
|
||||
*/
|
||||
private function stringLength(string $value): int
|
||||
{
|
||||
return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value);
|
||||
}
|
||||
|
||||
/** Extraction sûre en UTF-8, avec repli sur substr() sans mbstring. */
|
||||
private function stringSlice(string $value, int $start, ?int $length = null): string
|
||||
{
|
||||
if (function_exists('mb_substr')) {
|
||||
@@ -255,7 +337,13 @@ final class SimplePdf
|
||||
return $length === null ? substr($value, $start) : substr($value, $start, $length);
|
||||
}
|
||||
|
||||
/** @return array{0:float,1:float,2:float} */
|
||||
/**
|
||||
* Convertit « #RRGGBB » ou « #RGB » en composantes RVB de 0 à 1, échelle
|
||||
* attendue par les opérateurs PDF. Une valeur invalide donne du noir plutôt
|
||||
* qu'une erreur : un PDF illisible serait pire qu'une couleur fausse.
|
||||
*
|
||||
* @return array{0:float,1:float,2:float}
|
||||
*/
|
||||
private function hexToRgb(string $hex): array
|
||||
{
|
||||
$hex = ltrim(trim($hex), '#');
|
||||
@@ -273,6 +361,20 @@ final class SimplePdf
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare une chaîne pour l'insertion dans un flux PDF.
|
||||
*
|
||||
* Deux opérations distinctes :
|
||||
* 1. CONVERSION en Windows-1252, encodage déclaré des polices. //TRANSLIT
|
||||
* approche les caractères absents (« œ » → « oe »), //IGNORE écarte le
|
||||
* reste. Sans iconv, tout ce qui n'est pas ASCII imprimable devient « ? ».
|
||||
* 2. ÉCHAPPEMENT des caractères qui ont un sens dans la syntaxe PDF : la
|
||||
* barre oblique inverse et les parenthèses, qui délimitent les chaînes.
|
||||
* Un texte contenant une parenthèse non échappée corromprait le document.
|
||||
*
|
||||
* Les retours à la ligne sont supprimés ou remplacés par un espace : le
|
||||
* découpage en lignes relève de wrap(), pas du contenu.
|
||||
*/
|
||||
private function escapeText(string $text): string
|
||||
{
|
||||
$converted = function_exists('iconv')
|
||||
|
||||
Reference in New Issue
Block a user