*/ 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 ); } }