Passed
Push — master ( 218c08...7bac60 )
by
unknown
52s queued 11s
created

AbstractGenerator::checkOutput()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 20
rs 9.6
c 0
b 0
f 0
cc 3
nc 3
nop 2
1
<?php
2
3
namespace Knp\Snappy;
4
5
use Knp\Snappy\Exception as Exceptions;
6
use Psr\Log\LoggerAwareInterface;
7
use Psr\Log\LoggerInterface;
8
use Psr\Log\NullLogger;
9
use Symfony\Component\Process\Process;
10
11
/**
12
 * Base generator class for medias.
13
 *
14
 *
15
 * @author  Matthieu Bontemps <[email protected]>
16
 * @author  Antoine Hérault <[email protected]>
17
 */
18
abstract class AbstractGenerator implements GeneratorInterface, LoggerAwareInterface
19
{
20
    private $binary;
21
    private $options = [];
22
    private $env;
23
    private $timeout = false;
24
    private $defaultExtension;
25
26
    /**
27
     * @var string
28
     */
29
    protected $temporaryFolder;
30
31
    /**
32
     * @var array
33
     */
34
    public $temporaryFiles = [];
35
36
    /**
37
     * @var LoggerInterface
38
     */
39
    private $logger;
40
41
    /**
42
     * @param string $binary
43
     * @param array  $options
44
     * @param array  $env
0 ignored issues
show
Documentation introduced by
Should the type for parameter $env not be null|array? Also, consider making the array more specific, something like array<String>, or String[].

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive. In addition it looks for parameters that have the generic type array and suggests a stricter type like array<String>.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
45
     */
46
    public function __construct($binary, array $options = [], array $env = null)
47
    {
48
        $this->configure();
49
50
        $this->logger = new NullLogger();
51
        $this->setBinary($binary);
52
        $this->setOptions($options);
53
        $this->env = empty($env) ? null : $env;
54
55
        register_shutdown_function([$this, 'removeTemporaryFiles']);
56
    }
57
58
    public function __destruct()
59
    {
60
        $this->removeTemporaryFiles();
61
    }
62
63
    /**
64
     * Set the logger to use to log debugging data.
65
     *
66
     * @param LoggerInterface $logger
67
     */
68
    public function setLogger(LoggerInterface $logger)
69
    {
70
        $this->logger = $logger;
71
    }
72
73
    /**
74
     * This method must configure the media options.
75
     *
76
     * @see AbstractGenerator::addOption()
77
     */
78
    abstract protected function configure();
0 ignored issues
show
Documentation introduced by
For interfaces and abstract methods it is generally a good practice to add a @return annotation even if it is just @return void or @return null, so that implementors know what to do in the overridden method.

For interface and abstract methods, it is impossible to infer the return type from the immediate code. In these cases, it is generally advisible to explicitly annotate these methods with a @return doc comment to communicate to implementors of these methods what they are expected to return.

Loading history...
79
80
    /**
81
     * Sets the default extension.
82
     * Useful when letting Snappy deal with file creation.
83
     *
84
     * @param string $defaultExtension
85
     */
86
    public function setDefaultExtension($defaultExtension)
87
    {
88
        $this->defaultExtension = $defaultExtension;
89
    }
90
91
    /**
92
     * Gets the default extension.
93
     *
94
     * @return $string
0 ignored issues
show
Documentation introduced by
The doc-type $string could not be parsed: Unknown type name "$string" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
95
     */
96
    public function getDefaultExtension()
97
    {
98
        return $this->defaultExtension;
99
    }
100
101
    /**
102
     * Sets an option. Be aware that option values are NOT validated and that
103
     * it is your responsibility to validate user inputs.
104
     *
105
     * @param string $name  The option to set
106
     * @param mixed  $value The value (NULL to unset)
107
     *
108
     * @throws \InvalidArgumentException
109
     */
110
    public function setOption($name, $value)
111
    {
112
        if (!array_key_exists($name, $this->options)) {
113
            throw new \InvalidArgumentException(sprintf('The option \'%s\' does not exist.', $name));
114
        }
115
116
        $this->options[$name] = $value;
117
118
        $this->logger->debug(sprintf('Set option "%s".', $name), ['value' => $value]);
119
    }
120
121
    /**
122
     * Sets the timeout.
123
     *
124
     * @param int $timeout The timeout to set
125
     */
126
    public function setTimeout($timeout)
127
    {
128
        $this->timeout = $timeout;
0 ignored issues
show
Documentation Bug introduced by
The property $timeout was declared of type boolean, but $timeout is of type integer. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
129
    }
130
131
    /**
132
     * Sets an array of options.
133
     *
134
     * @param array $options An associative array of options as name/value
135
     */
136
    public function setOptions(array $options)
137
    {
138
        foreach ($options as $name => $value) {
139
            $this->setOption($name, $value);
140
        }
141
    }
142
143
    /**
144
     * Returns all the options.
145
     *
146
     * @return array
147
     */
148
    public function getOptions()
149
    {
150
        return $this->options;
151
    }
152
153
    /**
154
     * {@inheritdoc}
155
     */
156
    public function generate($input, $output, array $options = [], $overwrite = false)
157
    {
158
        if (null === $this->binary) {
159
            throw new \LogicException(
160
                'You must define a binary prior to conversion.'
161
            );
162
        }
163
164
        $this->prepareOutput($output, $overwrite);
165
166
        $command = $this->getCommand($input, $output, $options);
167
168
        $inputFiles = is_array($input) ? implode('", "', $input) : $input;
169
170
        $this->logger->info(sprintf('Generate from file(s) "%s" to file "%s".', $inputFiles, $output), [
171
            'command' => $command,
172
            'env'     => $this->env,
173
            'timeout' => $this->timeout,
174
        ]);
175
176
        try {
177
            list($status, $stdout, $stderr) = $this->executeCommand($command);
178
            $this->checkProcessStatus($status, $stdout, $stderr, $command);
179
            $this->checkOutput($output, $command);
180
        } catch (\Exception $e) {
181
            $this->logger->error(sprintf('An error happened while generating "%s".', $output), [
182
                'command' => $command,
183
                'status'  => isset($status) ? $status : null,
184
                'stdout'  => isset($stdout) ? $stdout : null,
185
                'stderr'  => isset($stderr) ? $stderr : null,
186
            ]);
187
188
            throw $e;
189
        }
190
191
        $this->logger->info(sprintf('File "%s" has been successfully generated.', $output), [
192
            'command' => $command,
193
            'stdout'  => $stdout,
194
            'stderr'  => $stderr,
195
        ]);
196
    }
197
198
    /**
199
     * {@inheritdoc}
200
     */
201
    public function generateFromHtml($html, $output, array $options = [], $overwrite = false)
202
    {
203
        $fileNames = [];
204
        if (is_array($html)) {
205
            foreach ($html as $htmlInput) {
206
                $fileNames[] = $this->createTemporaryFile($htmlInput, 'html');
207
            }
208
        } else {
209
            $fileNames[] = $this->createTemporaryFile($html, 'html');
210
        }
211
212
        $this->generate($fileNames, $output, $options, $overwrite);
213
    }
214
215
    /**
216
     * {@inheritdoc}
217
     */
218
    public function getOutput($input, array $options = [])
219
    {
220
        $filename = $this->createTemporaryFile(null, $this->getDefaultExtension());
221
222
        $this->generate($input, $filename, $options);
223
224
        $result = $this->getFileContents($filename);
225
226
        return $result;
227
    }
228
229
    /**
230
     * {@inheritdoc}
231
     */
232
    public function getOutputFromHtml($html, array $options = [])
233
    {
234
        $fileNames = [];
235
        if (is_array($html)) {
236
            foreach ($html as $htmlInput) {
237
                $fileNames[] = $this->createTemporaryFile($htmlInput, 'html');
238
            }
239
        } else {
240
            $fileNames[] = $this->createTemporaryFile($html, 'html');
241
        }
242
243
        $result = $this->getOutput($fileNames, $options);
244
245
        return $result;
246
    }
247
248
    /**
249
     * Defines the binary.
250
     *
251
     * @param string $binary The path/name of the binary
252
     */
253
    public function setBinary($binary)
254
    {
255
        $this->binary = $binary;
256
    }
257
258
    /**
259
     * Returns the binary.
260
     *
261
     * @return string
262
     */
263
    public function getBinary()
264
    {
265
        return $this->binary;
266
    }
267
268
    /**
269
     * Returns the command for the given input and output files.
270
     *
271
     * @param array|string $input   The input file
272
     * @param string       $output  The ouput file
273
     * @param array        $options An optional array of options that will be used
274
     *                              only for this command
275
     *
276
     * @return string
277
     */
278
    public function getCommand($input, $output, array $options = [])
279
    {
280
        $options = $this->mergeOptions($options);
0 ignored issues
show
Coding Style introduced by
Consider using a different name than the parameter $options. This often makes code more readable.
Loading history...
281
282
        return $this->buildCommand($this->binary, $input, $output, $options);
283
    }
284
285
    /**
286
     * Adds an option.
287
     *
288
     * @param string $name    The name
289
     * @param mixed  $default An optional default value
290
     *
291
     * @throws \InvalidArgumentException
292
     */
293
    protected function addOption($name, $default = null)
294
    {
295
        if (array_key_exists($name, $this->options)) {
296
            throw new \InvalidArgumentException(sprintf('The option \'%s\' already exists.', $name));
297
        }
298
299
        $this->options[$name] = $default;
300
    }
301
302
    /**
303
     * Adds an array of options.
304
     *
305
     * @param array $options
306
     */
307
    protected function addOptions(array $options)
308
    {
309
        foreach ($options as $name => $default) {
310
            $this->addOption($name, $default);
311
        }
312
    }
313
314
    /**
315
     * Merges the given array of options to the instance options and returns
316
     * the result options array. It does NOT change the instance options.
317
     *
318
     * @param array $options
319
     *
320
     * @throws \InvalidArgumentException
321
     *
322
     * @return array
323
     */
324
    protected function mergeOptions(array $options)
325
    {
326
        $mergedOptions = $this->options;
327
328
        foreach ($options as $name => $value) {
329
            if (!array_key_exists($name, $mergedOptions)) {
330
                throw new \InvalidArgumentException(sprintf('The option \'%s\' does not exist.', $name));
331
            }
332
333
            $mergedOptions[$name] = $value;
334
        }
335
336
        return $mergedOptions;
337
    }
338
339
    /**
340
     * Checks the specified output.
341
     *
342
     * @param string $output  The output filename
343
     * @param string $command The generation command
344
     *
345
     * @throws \RuntimeException if the output file generation failed
346
     */
347
    protected function checkOutput($output, $command)
348
    {
349
        // the output file must exist
350
        if (!$this->fileExists($output)) {
351
            throw new \RuntimeException(sprintf(
352
                'The file \'%s\' was not created (command: %s).',
353
                $output,
354
                $command
355
            ));
356
        }
357
358
        // the output file must not be empty
359
        if (0 === $this->filesize($output)) {
360
            throw new \RuntimeException(sprintf(
361
                'The file \'%s\' was created but is empty (command: %s).',
362
                $output,
363
                $command
364
            ));
365
        }
366
    }
367
368
    /**
369
     * Checks the process return status.
370
     *
371
     * @param int    $status  The exit status code
372
     * @param string $stdout  The stdout content
373
     * @param string $stderr  The stderr content
374
     * @param string $command The run command
375
     *
376
     * @throws \RuntimeException if the output file generation failed
377
     */
378
    protected function checkProcessStatus($status, $stdout, $stderr, $command)
379
    {
380
        if (0 !== $status and '' !== $stderr) {
381
            throw new \RuntimeException(sprintf(
382
                'The exit status code \'%s\' says something went wrong:' . "\n"
383
                . 'stderr: "%s"' . "\n"
384
                . 'stdout: "%s"' . "\n"
385
                . 'command: %s.',
386
                $status,
387
                $stderr,
388
                $stdout,
389
                $command
390
            ), $status);
391
        }
392
    }
393
394
    /**
395
     * Creates a temporary file.
396
     * The file is not created if the $content argument is null.
397
     *
398
     * @param string $content   Optional content for the temporary file
0 ignored issues
show
Documentation introduced by
Should the type for parameter $content not be string|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
399
     * @param string $extension An optional extension for the filename
0 ignored issues
show
Documentation introduced by
Should the type for parameter $extension not be string|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
400
     *
401
     * @return string The filename
402
     */
403
    protected function createTemporaryFile($content = null, $extension = null)
404
    {
405
        $dir = rtrim($this->getTemporaryFolder(), DIRECTORY_SEPARATOR);
406
407
        if (!is_dir($dir)) {
408
            if (false === @mkdir($dir, 0777, true) && !is_dir($dir)) {
409
                throw new \RuntimeException(sprintf("Unable to create directory: %s\n", $dir));
410
            }
411
        } elseif (!is_writable($dir)) {
412
            throw new \RuntimeException(sprintf("Unable to write in directory: %s\n", $dir));
413
        }
414
415
        $filename = $dir . DIRECTORY_SEPARATOR . uniqid('knp_snappy', true);
416
417
        if (null !== $extension) {
418
            $filename .= '.' . $extension;
419
        }
420
421
        if (null !== $content) {
422
            file_put_contents($filename, $content);
423
        }
424
425
        $this->temporaryFiles[] = $filename;
426
427
        return $filename;
428
    }
429
430
    /**
431
     * Removes all temporary files.
432
     */
433
    public function removeTemporaryFiles()
434
    {
435
        foreach ($this->temporaryFiles as $file) {
436
            $this->unlink($file);
437
        }
438
    }
439
440
    /**
441
     * Builds the command string.
442
     *
443
     * @param string       $binary  The binary path/name
444
     * @param string/array $input   Url(s) or file location(s) of the page(s) to process
0 ignored issues
show
Documentation introduced by
The doc-type string/array could not be parsed: Unknown type name "string/array" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
445
     * @param string       $output  File location to the image-to-be
446
     * @param array        $options An array of options
447
     *
448
     * @return string
449
     */
450
    protected function buildCommand($binary, $input, $output, array $options = [])
451
    {
452
        $command = $binary;
453
        $escapedBinary = escapeshellarg($binary);
454
        if (is_executable($escapedBinary)) {
455
            $command = $escapedBinary;
456
        }
457
458
        foreach ($options as $key => $option) {
459
            if (null !== $option && false !== $option) {
460
                if (true === $option) {
461
                    // Dont't put '--' if option is 'toc'.
462
                    if ($key == 'toc') {
463
                        $command .= ' ' . $key;
464
                    } else {
465
                        $command .= ' --' . $key;
466
                    }
467
                } elseif (is_array($option)) {
468
                    if ($this->isAssociativeArray($option)) {
469
                        foreach ($option as $k => $v) {
470
                            $command .= ' --' . $key . ' ' . escapeshellarg($k) . ' ' . escapeshellarg($v);
471
                        }
472
                    } else {
473
                        foreach ($option as $v) {
474
                            $command .= ' --' . $key . ' ' . escapeshellarg($v);
475
                        }
476
                    }
477
                } else {
478
                    // Dont't add '--' if option is "cover"  or "toc".
479
                    if (in_array($key, ['toc', 'cover'])) {
480
                        $command .= ' ' . $key . ' ' . escapeshellarg($option);
481
                    } elseif (in_array($key, ['image-dpi', 'image-quality'])) {
482
                        $command .= ' --' . $key . ' ' . (int) $option;
483
                    } else {
484
                        $command .= ' --' . $key . ' ' . escapeshellarg($option);
485
                    }
486
                }
487
            }
488
        }
489
490
        if (is_array($input)) {
491
            foreach ($input as $i) {
492
                $command .= ' ' . escapeshellarg($i) . ' ';
493
            }
494
            $command .= escapeshellarg($output);
495
        } else {
496
            $command .= ' ' . escapeshellarg($input) . ' ' . escapeshellarg($output);
497
        }
498
499
        return $command;
500
    }
501
502
    /**
503
     * Return true if the array is an associative array
504
     * and not an indexed array.
505
     *
506
     * @param array $array
507
     *
508
     * @return bool
509
     */
510
    protected function isAssociativeArray(array $array)
511
    {
512
        return (bool) count(array_filter(array_keys($array), 'is_string'));
513
    }
514
515
    /**
516
     * Executes the given command via shell and returns the complete output as
517
     * a string.
518
     *
519
     * @param string $command
520
     *
521
     * @return array(status, stdout, stderr)
0 ignored issues
show
Documentation introduced by
The doc-type array(status, could not be parsed: Expected "|" or "end of type", but got "(" at position 5. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
522
     */
523
    protected function executeCommand($command)
524
    {
525
        if (method_exists(Process::class, 'fromShellCommandline')) {
526
            $process = Process::fromShellCommandline($command, null, $this->env);
527
        } else {
528
            $process = new Process($command, null, $this->env);
0 ignored issues
show
Documentation introduced by
$command is of type string, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
529
        }
530
531
        if (false !== $this->timeout) {
532
            $process->setTimeout($this->timeout);
533
        }
534
535
        $process->run();
536
537
        return [
538
            $process->getExitCode(),
539
            $process->getOutput(),
540
            $process->getErrorOutput(),
541
        ];
542
    }
543
544
    /**
545
     * Prepares the specified output.
546
     *
547
     * @param string $filename  The output filename
548
     * @param bool   $overwrite Whether to overwrite the file if it already
549
     *                          exist
550
     *
551
     * @throws Exception\FileAlreadyExistsException
552
     * @throws \RuntimeException
553
     * @throws \InvalidArgumentException
554
     */
555
    protected function prepareOutput($filename, $overwrite)
556
    {
557
        $directory = dirname($filename);
558
559
        if ($this->fileExists($filename)) {
560
            if (!$this->isFile($filename)) {
561
                throw new \InvalidArgumentException(sprintf(
562
                    'The output file \'%s\' already exists and it is a %s.',
563
                    $filename,
564
                    $this->isDir($filename) ? 'directory' : 'link'
565
                ));
566
            } elseif (false === $overwrite) {
567
                throw new Exceptions\FileAlreadyExistsException(sprintf(
568
                    'The output file \'%s\' already exists.',
569
                    $filename
570
                ));
571
            } elseif (!$this->unlink($filename)) {
572
                throw new \RuntimeException(sprintf(
573
                    'Could not delete already existing output file \'%s\'.',
574
                    $filename
575
                ));
576
            }
577
        } elseif (!$this->isDir($directory) && !$this->mkdir($directory)) {
578
            throw new \RuntimeException(sprintf(
579
                'The output file\'s directory \'%s\' could not be created.',
580
                $directory
581
            ));
582
        }
583
    }
584
585
    /**
586
     * Get TemporaryFolder.
587
     *
588
     * @return string
589
     */
590
    public function getTemporaryFolder()
591
    {
592
        if ($this->temporaryFolder === null) {
593
            return sys_get_temp_dir();
594
        }
595
596
        return $this->temporaryFolder;
597
    }
598
599
    /**
600
     * Set temporaryFolder.
601
     *
602
     * @param string $temporaryFolder
603
     *
604
     * @return $this
605
     */
606
    public function setTemporaryFolder($temporaryFolder)
607
    {
608
        $this->temporaryFolder = $temporaryFolder;
609
610
        return $this;
611
    }
612
613
    /**
614
     * Wrapper for the "file_get_contents" function.
615
     *
616
     * @param string $filename
617
     *
618
     * @return string
619
     */
620
    protected function getFileContents($filename)
621
    {
622
        return file_get_contents($filename);
623
    }
624
625
    /**
626
     * Wrapper for the "file_exists" function.
627
     *
628
     * @param string $filename
629
     *
630
     * @return bool
631
     */
632
    protected function fileExists($filename)
633
    {
634
        return file_exists($filename);
635
    }
636
637
    /**
638
     * Wrapper for the "is_file" method.
639
     *
640
     * @param string $filename
641
     *
642
     * @return bool
643
     */
644
    protected function isFile($filename)
645
    {
646
        return strlen($filename) <= PHP_MAXPATHLEN && is_file($filename);
647
    }
648
649
    /**
650
     * Wrapper for the "filesize" function.
651
     *
652
     * @param string $filename
653
     *
654
     * @return int or FALSE on failure
655
     */
656
    protected function filesize($filename)
657
    {
658
        return filesize($filename);
659
    }
660
661
    /**
662
     * Wrapper for the "unlink" function.
663
     *
664
     * @param string $filename
665
     *
666
     * @return bool
667
     */
668
    protected function unlink($filename)
669
    {
670
        return $this->fileExists($filename) ? unlink($filename) : false;
671
    }
672
673
    /**
674
     * Wrapper for the "is_dir" function.
675
     *
676
     * @param string $filename
677
     *
678
     * @return bool
679
     */
680
    protected function isDir($filename)
681
    {
682
        return is_dir($filename);
683
    }
684
685
    /**
686
     * Wrapper for the mkdir function.
687
     *
688
     * @param string $pathname
689
     *
690
     * @return bool
691
     */
692
    protected function mkdir($pathname)
693
    {
694
        return mkdir($pathname, 0777, true);
695
    }
696
697
    /**
698
     * Reset all options to their initial values.
699
     *
700
     * @return void
701
     */
702
    public function resetOptions()
703
    {
704
        $this->options = [];
705
        $this->configure();
706
    }
707
}
708