Ajout des commentaires dans le code

This commit is contained in:
PTA
2026-08-20 13:22:49 +02:00
parent 4201894e28
commit 5d8c0993a6
154 changed files with 5071 additions and 47 deletions

View File

@@ -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')