1: <?php
2: namespace TIC\DpdfBundle\Base;
3:
4: use Twig\Environment;
5: use Symfony\Contracts\Translation\TranslatorInterface;
6: use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
7:
8: use Symfony\Component\HttpFoundation\Response;
9: use Symfony\Component\HttpFoundation\StreamedResponse;
10:
11: /**
12: * Classe parent pour les services de génération de PDF
13: * (à partir d'un contenu HMTL ou d'une vue Twig).
14: */
15: abstract class PDFService
16: {
17: protected $twig;
18: protected $encore;
19: protected $temp_files = array();
20: protected $debug = false;
21:
22: /**
23: * Types mimes & extensions de fichiers disponibles
24: */
25: const MIME_TYPES = array(
26: 'PDF' => [ ".pdf", "application/pdf" ],
27: 'PNG' => [ ".png", "image/png" ],
28: # 'JPG' => [ ".jpg", "image/jpeg" ],
29: );
30:
31: /**
32: * Valeurs par défaut (options & chemins) pour les générations Twig|HTML vers PDF|PNG
33: */
34: protected $confs = array(
35: 'debug' => false, // dump des options, debug CSS et fichier de log
36: 'generator' => "auto", // auto|pdf = PDFLib|CPDF|wkhtmltopdf ; image|png = GD|wkhtmltoimage
37: 'pdflib_license' => null, // [dompdf] si usage de PDFLib avec une licence
38: 'media_type' => "screen", // CSS3 media type: screen | print
39: 'paper_size' => "A4", // page format: A4, letter...
40: 'orientation' => "portrait", // page orientation: portrait | landscape
41: 'default_font' => "DejaVu Sans", // [dompdf] helvetica, times-roman, courier...
42: 'font_ratio' => 1.1, // [dompdf] ratio sur la hauteur (pour rendu proche des navigateurs)
43: 'dpi' => 96, // DPI setting (images & fonts)
44: 'default_twigview' => "layout_print", // nom de la vue twig par défault (sans l'extension ".html.twig")
45: 'default_filename' => "document", // nom du fichier de sortie (sans l'extension ".pdf")
46: 'base_url' => null, // URL complète absolue pour résoudre les liens relatifs
47: );
48: protected $paths = array(
49: 'root_dir' => null, // racine des fichiers accessibles en chargement (chroot|allow)
50: 'path_source' => null, // dossier parent par défaut pour les fichiers chargés (HTML)
51: 'path_output' => null, // dossier parent par défaut pour les fichiers générés (PDF|PNG)
52: 'path_logdir' => null, // dossier parent pour les fichiers de log (debug)
53: 'path_tmpdir' => null, // dossier parent pour les fichiers temporaires (défaut sys_get_temp_dir)
54: 'wkhtml_pdf' => __DIR__ . '../../../../bin/wkhtmltopdf-amd64', // [wkhtml]
55: 'wkhtml_image' => __DIR__ . '../../../../bin/wkhtmltoimage-amd64', // [wkhtml]
56: );
57:
58: /**
59: * Construction du service en chargeant la configuration des parameters.
60: *
61: * @param array $confs Liste des options pour le paramétrage du service
62: * @param array $paths Liste des chemins pour le paramétrage du service
63: * @param Environment $twig Instance du moteur de rendu pour générer les vues HTML
64: * @param TranslatorInterface $translator Service d'internationalisation pour changer la locale
65: * @param EntrypointLookupInterface $encore Accès à la réinitialisation de Webpack Encore
66: */
67: public function __construct(?array $confs = [], ?array $paths = [], ?Environment $twig = null, ?TranslatorInterface $translator = null, ?EntrypointLookupInterface $encore = null)
68: {
69: $this->twig = $twig;
70: $this->trans = $translator;
71: $this->encore = $encore;
72: $this->confs = \array_merge($this->confs, $confs);
73: $this->paths = \array_merge($this->paths, $paths);
74: if (isset($this->confs['debug'])) $this->debug = (bool)$this->confs['debug'];
75: # \register_shutdown_function([$this, 'purgeTempFiles']); // ne fonctionne pas et empêche le destruct (qui marche sinon) !
76: }
77:
78: public function __destruct()
79: {
80: $this->purgeTempFiles();
81: }
82:
83: /**
84: * Suppression des fichiers temporaires (sauf en mode debug).
85: */
86: protected function purgeTempFiles()
87: {
88: if ($this->debug) return;
89:
90: foreach ($this->temp_files as $file_path) {
91: if (\file_exists($file_path)) \unlink($file_path);
92: }
93: $this->temp_files = array();
94: }
95:
96: /**
97: * Création d'un fichier temporaire avec le contenu donné.
98: *
99: * @param string $prefix Chaine pour le début du nom du fichier (sous-dossier si commence par un /)
100: * @param string $suffix Chaine à utiliser comme extension du nom du fichier
101: * @param string $file_data Contenu à placer dans le fichier à générer
102: * @return string Chemin absolu du fichier temporaire (généré/à générer)
103: */
104: protected function createTempFile(string $prefix = "", ?string $suffix = null, string $file_data = null): string
105: {
106: $folder = "";
107: if (\strlen($prefix) && (\substr($prefix,0,1) === "/")) {
108: $folder = $prefix;
109: $prefix = "";
110: }
111:
112: $tmpdir = \sys_get_temp_dir();
113: if (! empty($this->paths['path_tmpdir'])) {
114: if (! \is_dir($this->paths['path_tmpdir'].$folder)) @\mkdir($this->paths['path_tmpdir'].$folder, 0750, true);
115: $tmpdir = $this->paths['path_tmpdir'].$folder;
116: }
117:
118: if (! \is_writable($tmpdir))
119: throw new RuntimeException(\sprintf("Unable to write in directory: %s\n", $tmpdir));
120:
121: $file_path = $tmpdir . "/" . \uniqid($prefix, true);
122: if (null !== $suffix) $file_path.= "." . $suffix;
123:
124: if (null !== $file_data) \file_put_contents($file_path, $file_data);
125: $this->temp_files[] = $file_path;
126: return $file_path;
127: }
128:
129: // ------------------------------------------------------------------------------------------ CONFIG
130:
131: /**
132: * Initialisation du moteur html2pdf avec ses options.
133: *
134: * @param array $pdf_options Surcharge des options du moteur html2pdf
135: * @return PDFService
136: */
137: abstract public function initEngine(array $pdf_options = []): PDFService;
138:
139: // ------------------------------------------------------------------------------------------ LOADER
140:
141: /**
142: * Initialisation du moteur html2pdf à partir d'un fichier HTML.
143: *
144: * @param string $html_file Chemin du fichier HTML à charger
145: * @param array $pdf_options Surcharge des options du moteur html2pdf
146: * @return PDFService
147: */
148: abstract public function loadHtmlFile(string $html_file, array $pdf_options = []): PDFService;
149:
150: /**
151: * Initialisation du moteur html2pdf à partir d'un contenu HTML.
152: *
153: * @param string $html_data Contenu du document HTML à charger
154: * @param array $pdf_options Surcharge des options du moteur html2pdf
155: * @return PDFService
156: */
157: abstract public function loadHtmlData(string $html_data, array $pdf_options = []): PDFService;
158:
159: /**
160: * Initialisation du moteur html2pdf à partir d'une vue Twig à générer.
161: * NOTE: avec gestion de la locale et reset de webpack encore (en cas d'usages multiples)
162: *
163: * @param string $twig_view Chemin du template twig à générer
164: * @param array $view_params Variables à injecter dans le template (peut contenir 'locale')
165: * @param array $pdf_options Surcharge des options du moteur html2pdf
166: * @return PDFService
167: */
168: public function loadTwigView(?string $twig_view = null, array $view_params = [], array $pdf_options = []): PDFService
169: {
170: if (! $this->twig) throw new \Exception("Twig environment not found!");
171: if ($this->encore) $this->encore->reset(); // @see symfony/webpack-encore-bundle#33
172:
173: if ($this->trans && isset($view_params['locale']) && \strlen($view_params['locale'])) {
174: $orig_locale = $this->trans->getLocale();
175: $this->trans->setLocale($view_params['locale']);
176: }
177: $html_data = $this->twig->render($this->fixTwigView($twig_view), $view_params);
178: if (isset($orig_locale)) $this->trans->setLocale($orig_locale);
179:
180: return $this->loadHtmlData($html_data, $pdf_options);
181: }
182:
183: // ------------------------------------------------------------------------------------------ RENDER
184:
185: /**
186: * Returns the PDF data as a string.
187: *
188: * @param bool $nocompress Désactivation de la compression PDF
189: * @return string Données binaires du document PDF
190: */
191: abstract public function renderData(bool $nocompress = false): string;
192:
193: /**
194: * Returns the PDF in a local file.
195: *
196: * @param string $filepath Chemin du fichier PDF à enregistrer
197: * @param bool $nocompress Désactivation de la compression PDF
198: * @return string Chemin du fichier PDF généré
199: */
200: public function renderFile(?string $filepath = null, bool $nocompress = false): string
201: {
202: $filepath = $this->fixFilepath($filepath);
203: \file_put_contents($filepath, $this->renderData($nocompress));
204: return $filepath;
205: }
206:
207: /**
208: * Returns the PDF in a symfony Response object (clean integration).
209: *
210: * @param string $filename Nom du document PDF à télécharger
211: * @param bool $inline Affichage du PDF sans forcer son enregistrement
212: * @param bool $nocompress Désactivation de la compression PDF
213: * @return Response Objet à retourner par le controlleur
214: */
215: public function renderResponse(?string $filename = null, bool $inline = false, bool $nocompress = false): Response
216: {
217: return new Response(
218: $this->renderData($nocompress),
219: 200,
220: $this->getHttpHeaders($filename, $inline)
221: );
222: }
223:
224: /**
225: * Returns the PDF in a symfony StreamedResponse object (less memory usage).
226: *
227: * @param string $filename Nom du document PDF à télécharger
228: * @param bool $inline Affichage du PDF sans forcer son enregistrement
229: * @param bool $nocompress Désactivation de la compression PDF
230: * @return StreamedResponse Objet à retourner par le controlleur
231: */
232: public function renderStream(?string $filename = null, bool $inline = false, bool $nocompress = false): StreamedResponse
233: {
234: return new StreamedResponse(
235: function() use ($nocompress) { echo $this->renderData($nocompress); },
236: 200,
237: $this->getHttpHeaders($filename, $inline)
238: );
239: }
240:
241: // ------------------------------------------------------------------------------------------ COMBOS
242:
243: /**
244: * Initialisation à partir d'une vue Twig pour générer un PDF retourné dans une variable.
245: *
246: * @param string $twig_view Chemin du template twig à générer
247: * @param array $view_params Variables à injecter dans le template (peut contenir 'locale')
248: * @param array $pdf_options Surcharge des options du moteur html2pdf
249: * @return string Données binaires du document PDF
250: */
251: public function view2data(?string $twig_view = null, array $view_params = [], array $pdf_options = []): string
252: {
253: return $this->loadTwigView($twig_view, $view_params, $pdf_options)->renderData();
254: }
255:
256: /**
257: * Initialisation à partir d'une vue Twig pour générer un PDF retourné dans un fichier local.
258: *
259: * @param string $filepath Chemin du fichier PDF à enregistrer
260: * @param string $twig_view Chemin du template twig à générer
261: * @param array $view_params Variables à injecter dans le template (peut contenir 'locale')
262: * @param array $pdf_options Surcharge des options du moteur html2pdf
263: * @return string Chemin du fichier PDF généré
264: */
265: public function view2file(?string $filepath, ?string $twig_view = null, array $view_params = [], array $pdf_options = []): string
266: {
267: return $this->loadTwigView($twig_view, $view_params, $pdf_options)->renderFile($filepath);
268: }
269:
270: /**
271: * Initialisation à partir d'une vue Twig pour générer un PDF retourné dans une StreamedResponse.
272: *
273: * @param string $filename Nom du document PDF à télécharger
274: * @param string $twig_view Chemin du template twig à générer
275: * @param array $view_params Variables à injecter dans le template (peut contenir 'locale')
276: * @param array $pdf_options Surcharge des options du moteur html2pdf
277: * @return StreamedResponse Objet à retourner par le controlleur
278: */
279: public function view2http(?string $filename, ?string $twig_view = null, array $view_params = [], array $pdf_options = []): StreamedResponse
280: {
281: return $this->loadTwigView($twig_view, $view_params, $pdf_options)->renderStream($filename);
282: }
283:
284: // ------------------------------------------------------------------------------------------ CHECKS
285:
286: protected function fixTwigView(?string $twig_view = null): string
287: {
288: if ($twig_view === null) $twig_view = $this->confs['default_twigview'];
289: return \preg_match('/[^\/]+\.[^\/]+$/', $twig_view) ? $twig_view : $twig_view . ".html.twig";
290: }
291:
292: protected function fixHtmlFile(string $html_file): string
293: {
294: // si relatif : utilisation de l'option "path_source" pour ajouter le dossier parent par défaut
295: return (\substr($html_file,0,1) !== '/') ? $this->paths['path_source'] . '/' . $html_file : $html_file;
296: }
297:
298: protected function fixFilepath(?string $filepath = null): string
299: {
300: list($extension) = $this->getFileType();
301:
302: // récupération de l'éventuelle partie "dossier" du chemin $filepath
303: $folder = \is_string($filepath) ? \dirname($filepath . "X") : ".";
304: // si relatif : utilisation de l'option "path_output" pour ajouter le dossier parent par défaut
305: if (\substr($folder,0,1) !== '/') $folder = $this->paths['path_output'] . '/' . $folder;
306: // si dossier non existant : création automatique (récursive)
307: if (! \is_dir($folder)) @\mkdir($folder, 0750, true);
308:
309: // récupération de l'éventuelle partie "fichier" du chemin $filepath
310: $filename = (\is_string($filepath) && \substr($filepath,-1) !== '/') ? \trim(\basename($filepath, $extension)) : "";
311: // si vide : génération automatique d'un nom de fichier (datetime + random)
312: if (! \strlen($filename)) $filename = \sprintf('%s-%s', \date('Ymd_His'), \bin2hex(\random_bytes(8)));
313: // retourne le chemin complet (absolu) avec extension automatique
314: return \str_replace('/./', '/', $folder) . '/' . $filename . $extension;
315: }
316:
317: protected function fixFilename(?string $filename = null, ?string $extension = null): string
318: {
319: if ($extension === null) list($extension) = $this->getFileType();
320: $filename = \is_string($filename) ? \trim(\basename($filename, $extension)) : "";
321: if (! \strlen($filename)) $filename = \trim(\basename($this->confs['default_filename'], $extension));
322: return \str_replace('"', '', $filename) . $extension;
323: }
324:
325: protected function getHttpHeaders(?string $filename = null, bool $inline = false): array
326: {
327: list($extension, $mime_type) = $this->getFileType();
328: return array(
329: 'Content-Type' => $mime_type,
330: 'Content-Disposition' => \sprintf('%s; filename="%s"',
331: ($inline) ? "inline" : "attachment",
332: $this->fixFilename($filename, $extension)
333: ),
334: );
335: }
336:
337: protected function getFileType(string $type = "PDF"): ?array
338: {
339: if (! isset(self::MIME_TYPES[$type])) $type = "PDF";
340: return self::MIME_TYPES[$type];
341: }
342:
343: }
344: