Files
pta/app/Services/SimplePdf.php
Loic Masi 5fbf76868f pour prod
2026-08-07 16:13:43 +02:00

292 lines
8.6 KiB
PHP

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