394 lines
14 KiB
PHP
394 lines
14 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.
|
|
*
|
|
* 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;
|
|
|
|
/** @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;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
);
|
|
}
|
|
}
|