vendor/knplabs/knp-snappy/src/Knp/Snappy/AbstractGenerator.php line 68

Open in your IDE?
  1. <?php
  2. namespace Knp\Snappy;
  3. use Knp\Snappy\Exception\FileAlreadyExistsException;
  4. use Psr\Log\LoggerAwareInterface;
  5. use Psr\Log\LoggerAwareTrait;
  6. use Symfony\Component\Process\Process;
  7. use Exception;
  8. use LogicException;
  9. use RuntimeException;
  10. use InvalidArgumentException;
  11. /**
  12. * Base generator class for medias.
  13. *
  14. * @author Matthieu Bontemps <matthieu.bontemps@knplabs.com>
  15. * @author Antoine Hérault <antoine.herault@knplabs.com>
  16. */
  17. abstract class AbstractGenerator implements GeneratorInterface, LoggerAwareInterface
  18. {
  19. use LoggerAwareTrait;
  20. protected const ALLOWED_PROTOCOLS = ['file'];
  21. protected const WINDOWS_LOCAL_FILENAME_REGEX = '/^[a-z]:(?:[\\\\\/]?(?:[\w\s!#()-]+|[\.]{1,2})+)*[\\\\\/]?/i';
  22. /**
  23. * @var array
  24. */
  25. public $temporaryFiles = [];
  26. /**
  27. * @var string
  28. */
  29. protected $temporaryFolder;
  30. /**
  31. * @var null|string
  32. */
  33. private $binary;
  34. /**
  35. * @var array
  36. */
  37. private $options = [];
  38. /**
  39. * @var null|array
  40. */
  41. private $env;
  42. /**
  43. * @var null|int
  44. */
  45. private $timeout;
  46. /**
  47. * @var string
  48. */
  49. private $defaultExtension;
  50. /**
  51. * @param null|string $binary
  52. * @param array $options
  53. * @param null|array $env
  54. */
  55. public function __construct($binary, array $options = [], array $env = null)
  56. {
  57. $this->configure();
  58. $this->setBinary($binary);
  59. $this->setOptions($options);
  60. $this->env = empty($env) ? null : $env;
  61. if (\is_callable([$this, 'removeTemporaryFiles'])) {
  62. \register_shutdown_function([$this, 'removeTemporaryFiles']);
  63. }
  64. }
  65. public function __destruct()
  66. {
  67. $this->removeTemporaryFiles();
  68. }
  69. /**
  70. * Sets the default extension.
  71. * Useful when letting Snappy deal with file creation.
  72. *
  73. * @param string $defaultExtension
  74. *
  75. * @return $this
  76. */
  77. public function setDefaultExtension($defaultExtension)
  78. {
  79. $this->defaultExtension = $defaultExtension;
  80. return $this;
  81. }
  82. /**
  83. * Gets the default extension.
  84. *
  85. * @return string
  86. */
  87. public function getDefaultExtension(): string
  88. {
  89. return $this->defaultExtension;
  90. }
  91. /**
  92. * Sets an option. Be aware that option values are NOT validated and that
  93. * it is your responsibility to validate user inputs.
  94. *
  95. * @param string $name The option to set
  96. * @param mixed $value The value (NULL to unset)
  97. *
  98. * @throws InvalidArgumentException
  99. *
  100. * @return $this
  101. */
  102. public function setOption($name, $value)
  103. {
  104. if (!\array_key_exists($name, $this->options)) {
  105. throw new InvalidArgumentException(\sprintf('The option \'%s\' does not exist.', $name));
  106. }
  107. $this->options[$name] = $value;
  108. if (null !== $this->logger) {
  109. $this->logger->debug(\sprintf('Set option "%s".', $name), ['value' => $value]);
  110. }
  111. return $this;
  112. }
  113. /**
  114. * Sets the timeout.
  115. *
  116. * @param null|int $timeout The timeout to set
  117. *
  118. * @return $this
  119. */
  120. public function setTimeout($timeout)
  121. {
  122. $this->timeout = $timeout;
  123. return $this;
  124. }
  125. /**
  126. * Sets an array of options.
  127. *
  128. * @param array $options An associative array of options as name/value
  129. *
  130. * @return $this
  131. */
  132. public function setOptions(array $options)
  133. {
  134. foreach ($options as $name => $value) {
  135. $this->setOption($name, $value);
  136. }
  137. return $this;
  138. }
  139. /**
  140. * Returns all the options.
  141. *
  142. * @return array
  143. */
  144. public function getOptions()
  145. {
  146. return $this->options;
  147. }
  148. /**
  149. * {@inheritdoc}
  150. */
  151. public function generate($input, $output, array $options = [], $overwrite = false)
  152. {
  153. $this->prepareOutput($output, $overwrite);
  154. $command = $this->getCommand($input, $output, $options);
  155. $inputFiles = \is_array($input) ? \implode('", "', $input) : $input;
  156. if (null !== $this->logger) {
  157. $this->logger->info(\sprintf('Generate from file(s) "%s" to file "%s".', $inputFiles, $output), [
  158. 'command' => $command,
  159. 'env' => $this->env,
  160. 'timeout' => $this->timeout,
  161. ]);
  162. }
  163. try {
  164. list($status, $stdout, $stderr) = $this->executeCommand($command);
  165. $this->checkProcessStatus($status, $stdout, $stderr, $command);
  166. $this->checkOutput($output, $command);
  167. } catch (Exception $e) {
  168. if (null !== $this->logger) {
  169. $this->logger->error(\sprintf('An error happened while generating "%s".', $output), [
  170. 'command' => $command,
  171. 'status' => $status ?? null,
  172. 'stdout' => $stdout ?? null,
  173. 'stderr' => $stderr ?? null,
  174. ]);
  175. }
  176. throw $e;
  177. }
  178. if (null !== $this->logger) {
  179. $this->logger->info(\sprintf('File "%s" has been successfully generated.', $output), [
  180. 'command' => $command,
  181. 'stdout' => $stdout,
  182. 'stderr' => $stderr,
  183. ]);
  184. }
  185. }
  186. /**
  187. * {@inheritdoc}
  188. */
  189. public function generateFromHtml($html, $output, array $options = [], $overwrite = false)
  190. {
  191. $fileNames = [];
  192. if (\is_array($html)) {
  193. foreach ($html as $htmlInput) {
  194. $fileNames[] = $this->createTemporaryFile($htmlInput, 'html');
  195. }
  196. } else {
  197. $fileNames[] = $this->createTemporaryFile($html, 'html');
  198. }
  199. $this->generate($fileNames, $output, $options, $overwrite);
  200. }
  201. /**
  202. * {@inheritdoc}
  203. */
  204. public function getOutput($input, array $options = [])
  205. {
  206. $filename = $this->createTemporaryFile(null, $this->getDefaultExtension());
  207. $this->generate($input, $filename, $options);
  208. return $this->getFileContents($filename);
  209. }
  210. /**
  211. * {@inheritdoc}
  212. */
  213. public function getOutputFromHtml($html, array $options = [])
  214. {
  215. $fileNames = [];
  216. if (\is_array($html)) {
  217. foreach ($html as $htmlInput) {
  218. $fileNames[] = $this->createTemporaryFile($htmlInput, 'html');
  219. }
  220. } else {
  221. $fileNames[] = $this->createTemporaryFile($html, 'html');
  222. }
  223. return $this->getOutput($fileNames, $options);
  224. }
  225. /**
  226. * Defines the binary.
  227. *
  228. * @param null|string $binary The path/name of the binary
  229. *
  230. * @return $this
  231. */
  232. public function setBinary($binary)
  233. {
  234. $this->binary = $binary;
  235. return $this;
  236. }
  237. /**
  238. * Returns the binary.
  239. *
  240. * @return null|string
  241. */
  242. public function getBinary()
  243. {
  244. return $this->binary;
  245. }
  246. /**
  247. * Returns the command for the given input and output files.
  248. *
  249. * @param array|string $input The input file
  250. * @param string $output The ouput file
  251. * @param array $options An optional array of options that will be used
  252. * only for this command
  253. *
  254. * @return string
  255. */
  256. public function getCommand($input, $output, array $options = [])
  257. {
  258. if (null === $this->binary) {
  259. throw new LogicException('You must define a binary prior to conversion.');
  260. }
  261. $options = $this->mergeOptions($options);
  262. return $this->buildCommand($this->binary, $input, $output, $options);
  263. }
  264. /**
  265. * Removes all temporary files.
  266. *
  267. * @return void
  268. */
  269. public function removeTemporaryFiles()
  270. {
  271. foreach ($this->temporaryFiles as $file) {
  272. $this->unlink($file);
  273. }
  274. }
  275. /**
  276. * Get TemporaryFolder.
  277. *
  278. * @return string
  279. */
  280. public function getTemporaryFolder()
  281. {
  282. if ($this->temporaryFolder === null) {
  283. return \sys_get_temp_dir();
  284. }
  285. return $this->temporaryFolder;
  286. }
  287. /**
  288. * Set temporaryFolder.
  289. *
  290. * @param string $temporaryFolder
  291. *
  292. * @return $this
  293. */
  294. public function setTemporaryFolder($temporaryFolder)
  295. {
  296. $this->temporaryFolder = $temporaryFolder;
  297. return $this;
  298. }
  299. /**
  300. * Reset all options to their initial values.
  301. *
  302. * @return void
  303. */
  304. public function resetOptions()
  305. {
  306. $this->options = [];
  307. $this->configure();
  308. }
  309. /**
  310. * This method must configure the media options.
  311. *
  312. * @return void
  313. *
  314. * @see AbstractGenerator::addOption()
  315. */
  316. abstract protected function configure();
  317. /**
  318. * Adds an option.
  319. *
  320. * @param string $name The name
  321. * @param mixed $default An optional default value
  322. *
  323. * @throws InvalidArgumentException
  324. *
  325. * @return $this
  326. */
  327. protected function addOption($name, $default = null)
  328. {
  329. if (\array_key_exists($name, $this->options)) {
  330. throw new InvalidArgumentException(\sprintf('The option \'%s\' already exists.', $name));
  331. }
  332. $this->options[$name] = $default;
  333. return $this;
  334. }
  335. /**
  336. * Adds an array of options.
  337. *
  338. * @param array $options
  339. *
  340. * @return $this
  341. */
  342. protected function addOptions(array $options)
  343. {
  344. foreach ($options as $name => $default) {
  345. $this->addOption($name, $default);
  346. }
  347. return $this;
  348. }
  349. /**
  350. * Merges the given array of options to the instance options and returns
  351. * the result options array. It does NOT change the instance options.
  352. *
  353. * @param array $options
  354. *
  355. * @throws InvalidArgumentException
  356. *
  357. * @return array
  358. */
  359. protected function mergeOptions(array $options)
  360. {
  361. $mergedOptions = $this->options;
  362. foreach ($options as $name => $value) {
  363. if (!\array_key_exists($name, $mergedOptions)) {
  364. throw new InvalidArgumentException(\sprintf('The option \'%s\' does not exist.', $name));
  365. }
  366. $mergedOptions[$name] = $value;
  367. }
  368. return $mergedOptions;
  369. }
  370. /**
  371. * Checks the specified output.
  372. *
  373. * @param string $output The output filename
  374. * @param string $command The generation command
  375. *
  376. * @throws RuntimeException if the output file generation failed
  377. *
  378. * @return void
  379. */
  380. protected function checkOutput($output, $command)
  381. {
  382. // the output file must exist
  383. if (!$this->fileExists($output)) {
  384. throw new RuntimeException(\sprintf('The file \'%s\' was not created (command: %s).', $output, $command));
  385. }
  386. // the output file must not be empty
  387. if (0 === $this->filesize($output)) {
  388. throw new RuntimeException(\sprintf('The file \'%s\' was created but is empty (command: %s).', $output, $command));
  389. }
  390. }
  391. /**
  392. * Checks the process return status.
  393. *
  394. * @param int $status The exit status code
  395. * @param string $stdout The stdout content
  396. * @param string $stderr The stderr content
  397. * @param string $command The run command
  398. *
  399. * @throws RuntimeException if the output file generation failed
  400. *
  401. * @return void
  402. */
  403. protected function checkProcessStatus($status, $stdout, $stderr, $command)
  404. {
  405. if (0 !== $status && '' !== $stderr) {
  406. throw new RuntimeException(\sprintf('The exit status code \'%s\' says something went wrong:' . "\n" . 'stderr: "%s"' . "\n" . 'stdout: "%s"' . "\n" . 'command: %s.', $status, $stderr, $stdout, $command), $status);
  407. }
  408. }
  409. /**
  410. * Creates a temporary file.
  411. * The file is not created if the $content argument is null.
  412. *
  413. * @param null|string $content Optional content for the temporary file
  414. * @param null|string $extension An optional extension for the filename
  415. *
  416. * @return string The filename
  417. */
  418. protected function createTemporaryFile($content = null, $extension = null)
  419. {
  420. $dir = \rtrim($this->getTemporaryFolder(), \DIRECTORY_SEPARATOR);
  421. if (!\is_dir($dir)) {
  422. if (false === @\mkdir($dir, 0777, true) && !\is_dir($dir)) {
  423. throw new RuntimeException(\sprintf("Unable to create directory: %s\n", $dir));
  424. }
  425. } elseif (!\is_writable($dir)) {
  426. throw new RuntimeException(\sprintf("Unable to write in directory: %s\n", $dir));
  427. }
  428. $filename = $dir . \DIRECTORY_SEPARATOR . \uniqid('knp_snappy', true);
  429. if (null !== $extension) {
  430. $filename .= '.' . $extension;
  431. }
  432. if (null !== $content) {
  433. \file_put_contents($filename, $content);
  434. }
  435. $this->temporaryFiles[] = $filename;
  436. return $filename;
  437. }
  438. /**
  439. * Builds the command string.
  440. *
  441. * @param string $binary The binary path/name
  442. * @param array|string $input Url(s) or file location(s) of the page(s) to process
  443. * @param string $output File location to the image-to-be
  444. * @param array $options An array of options
  445. *
  446. * @return string
  447. */
  448. protected function buildCommand($binary, $input, $output, array $options = [])
  449. {
  450. $command = $binary;
  451. $escapedBinary = \escapeshellarg($binary);
  452. if (\is_executable($escapedBinary)) {
  453. $command = $escapedBinary;
  454. }
  455. foreach ($options as $key => $option) {
  456. if (null !== $option && false !== $option) {
  457. if (true === $option) {
  458. // Dont't put '--' if option is 'toc'.
  459. if ($key === 'toc') {
  460. $command .= ' ' . $key;
  461. } else {
  462. $command .= ' --' . $key;
  463. }
  464. } elseif (\is_array($option)) {
  465. if ($this->isAssociativeArray($option)) {
  466. foreach ($option as $k => $v) {
  467. $command .= ' --' . $key . ' ' . \escapeshellarg($k) . ' ' . \escapeshellarg($v);
  468. }
  469. } else {
  470. foreach ($option as $v) {
  471. $command .= ' --' . $key . ' ' . \escapeshellarg($v);
  472. }
  473. }
  474. } else {
  475. // Dont't add '--' if option is "cover" or "toc".
  476. if (\in_array($key, ['toc', 'cover'])) {
  477. $command .= ' ' . $key . ' ' . \escapeshellarg($option);
  478. } elseif (\in_array($key, ['image-dpi', 'image-quality'])) {
  479. $command .= ' --' . $key . ' ' . (int) $option;
  480. } else {
  481. $command .= ' --' . $key . ' ' . \escapeshellarg($option);
  482. }
  483. }
  484. }
  485. }
  486. if (\is_array($input)) {
  487. foreach ($input as $i) {
  488. $command .= ' ' . \escapeshellarg($i) . ' ';
  489. }
  490. $command .= \escapeshellarg($output);
  491. } else {
  492. $command .= ' ' . \escapeshellarg($input) . ' ' . \escapeshellarg($output);
  493. }
  494. return $command;
  495. }
  496. /**
  497. * Return true if the array is an associative array
  498. * and not an indexed array.
  499. *
  500. * @param array $array
  501. *
  502. * @return bool
  503. */
  504. protected function isAssociativeArray(array $array)
  505. {
  506. return (bool) \count(\array_filter(\array_keys($array), 'is_string'));
  507. }
  508. /**
  509. * Executes the given command via shell and returns the complete output as
  510. * a string.
  511. *
  512. * @param string $command
  513. *
  514. * @return array [status, stdout, stderr]
  515. */
  516. protected function executeCommand($command)
  517. {
  518. if (\method_exists(Process::class, 'fromShellCommandline')) {
  519. $process = Process::fromShellCommandline($command, null, $this->env);
  520. } else {
  521. $process = new Process($command, null, $this->env);
  522. }
  523. if (null !== $this->timeout) {
  524. $process->setTimeout($this->timeout);
  525. }
  526. $process->run();
  527. return [
  528. $process->getExitCode(),
  529. $process->getOutput(),
  530. $process->getErrorOutput(),
  531. ];
  532. }
  533. /**
  534. * Prepares the specified output.
  535. *
  536. * @param string $filename The output filename
  537. * @param bool $overwrite Whether to overwrite the file if it already
  538. * exist
  539. *
  540. * @throws FileAlreadyExistsException
  541. * @throws RuntimeException
  542. * @throws InvalidArgumentException
  543. *
  544. * @return void
  545. */
  546. protected function prepareOutput($filename, $overwrite)
  547. {
  548. if (!$this->isProtocolAllowed($filename)) {
  549. throw new InvalidArgumentException(\sprintf('The output file scheme is not supported. Expected one of [\'%s\'].', \implode('\', \'', self::ALLOWED_PROTOCOLS)));
  550. }
  551. $directory = \dirname($filename);
  552. if ($this->fileExists($filename)) {
  553. if (!$this->isFile($filename)) {
  554. throw new InvalidArgumentException(\sprintf('The output file \'%s\' already exists and it is a %s.', $filename, $this->isDir($filename) ? 'directory' : 'link'));
  555. }
  556. if (false === $overwrite) {
  557. throw new FileAlreadyExistsException(\sprintf('The output file \'%s\' already exists.', $filename));
  558. }
  559. if (!$this->unlink($filename)) {
  560. throw new RuntimeException(\sprintf('Could not delete already existing output file \'%s\'.', $filename));
  561. }
  562. } elseif (!$this->isDir($directory) && !$this->mkdir($directory)) {
  563. throw new RuntimeException(\sprintf('The output file\'s directory \'%s\' could not be created.', $directory));
  564. }
  565. }
  566. /**
  567. * Verifies if the given filename has a supported protocol.
  568. *
  569. * @param string $filename
  570. *
  571. * @throws InvalidArgumentException
  572. *
  573. * @return bool
  574. */
  575. protected function isProtocolAllowed($filename)
  576. {
  577. if (false === $parsedFilename = \parse_url($filename)) {
  578. throw new InvalidArgumentException('The filename is not valid.');
  579. }
  580. $protocol = isset($parsedFilename['scheme']) ? \mb_strtolower($parsedFilename['scheme']) : 'file';
  581. if (
  582. \PHP_OS_FAMILY === 'Windows'
  583. && \strlen($protocol) === 1
  584. && \preg_match(self::WINDOWS_LOCAL_FILENAME_REGEX, $filename)
  585. ) {
  586. $protocol = 'file';
  587. }
  588. return \in_array($protocol, self::ALLOWED_PROTOCOLS, true);
  589. }
  590. /**
  591. * Wrapper for the "file_get_contents" function.
  592. *
  593. * @param string $filename
  594. *
  595. * @return string
  596. */
  597. protected function getFileContents($filename)
  598. {
  599. $fileContent = \file_get_contents($filename);
  600. if (false === $fileContent) {
  601. throw new RuntimeException(\sprintf('Could not read file \'%s\' content.', $filename));
  602. }
  603. return $fileContent;
  604. }
  605. /**
  606. * Wrapper for the "file_exists" function.
  607. *
  608. * @param string $filename
  609. *
  610. * @return bool
  611. */
  612. protected function fileExists($filename)
  613. {
  614. return \file_exists($filename);
  615. }
  616. /**
  617. * Wrapper for the "is_file" method.
  618. *
  619. * @param string $filename
  620. *
  621. * @return bool
  622. */
  623. protected function isFile($filename)
  624. {
  625. return \strlen($filename) <= \PHP_MAXPATHLEN && \is_file($filename);
  626. }
  627. /**
  628. * Wrapper for the "filesize" function.
  629. *
  630. * @param string $filename
  631. *
  632. * @return int
  633. */
  634. protected function filesize($filename)
  635. {
  636. $filesize = \filesize($filename);
  637. if (false === $filesize) {
  638. throw new RuntimeException(\sprintf('Could not read file \'%s\' size.', $filename));
  639. }
  640. return $filesize;
  641. }
  642. /**
  643. * Wrapper for the "unlink" function.
  644. *
  645. * @param string $filename
  646. *
  647. * @return bool
  648. */
  649. protected function unlink($filename)
  650. {
  651. return $this->fileExists($filename) ? \unlink($filename) : false;
  652. }
  653. /**
  654. * Wrapper for the "is_dir" function.
  655. *
  656. * @param string $filename
  657. *
  658. * @return bool
  659. */
  660. protected function isDir($filename)
  661. {
  662. return \is_dir($filename);
  663. }
  664. /**
  665. * Wrapper for the mkdir function.
  666. *
  667. * @param string $pathname
  668. *
  669. * @return bool
  670. */
  671. protected function mkdir($pathname)
  672. {
  673. return \mkdir($pathname, 0777, true);
  674. }
  675. }