*/ 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; } /** * 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 !== '') { $this->pages[] = $this->currentPage; } $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'; $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 ); } /** * 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( "%.3F G %.2F %.2F m %.2F %.2F l S\n", $gray, $x1, $this->height - $y1, $x2, $this->height - $y2 ); } /** * 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( "%.3F G %.2F %.2F %.2F %.2F re S\n", $strokeGray, $x, $this->height - $y - $h, $w, $h ); } /** 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( "%.3F g %.2F %.2F %.2F %.2F re f 0 g\n", $gray, $x, $this->height - $y - $h, $w, $h ); } /** * 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); $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 ); } /** 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); $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 ); } /** 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); $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 ); } /** * 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 { $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 ?: ['']; } /** * 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 === []) { $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; } /** * 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')) { return mb_substr($value, $start, $length, 'UTF-8'); } return $length === null ? substr($value, $start) : substr($value, $start, $length); } /** * 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), '#'); 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, ]; } /** * 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') ? 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 ); } }