Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
94 / 94
100.00% covered (success)
100.00%
23 / 23
CRAP
100.00% covered (success)
100.00%
1 / 1
Pdf
100.00% covered (success)
100.00%
94 / 94
100.00% covered (success)
100.00%
23 / 23
46
100.00% covered (success)
100.00%
1 / 1
 writeToFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 outputToHttp
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 importFromHtml
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 importFromHtmlFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 importFromHtmlUri
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 importFromFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 importRawData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 merge
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 mergeRawData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 importFromImages
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 extractAsImages
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 extractTextFromFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 extractTextFromData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 extractTextFromDocument
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 extractTextFromPage
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 isImageOnlyDocument
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isImageOnlyData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getImageOnlyPages
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getImageOnlyPagesFromData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 classifyPages
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 normalizePages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 selectByPageFilter
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 allPagesImageOnly
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Pdf;
16
17use Pop\Pdf\Document\AbstractDocument;
18use Pop\Pdf\Document\Exception;
19
20/**
21 * Pop Pdf class
22 *
23 * @category   Pop
24 * @package    Pop\Pdf
25 * @author     Nick Sagona, III <nick@popphp.org>
26 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    6.2.0
29 */
30class Pdf
31{
32
33    /**
34     * Write document to file
35     *
36     * @param  Document $document
37     * @param  string   $filename
38     * @return void
39     */
40    public static function writeToFile(Document $document, string $filename = 'pop.pdf'): void
41    {
42        $compiler = new Build\Compiler();
43        $compiler->finalize($document);
44        file_put_contents($filename, $compiler->getOutput());
45    }
46
47    /**
48     * Output to HTTP response
49     *
50     * @param  Document $document
51     * @param  string   $filename
52     * @param  bool     $forceDownload
53     * @param  array    $headers
54     * @return void
55     */
56    public static function outputToHttp(
57        Document $document, string $filename = 'pop.pdf', bool $forceDownload = false, array $headers = []
58    ): void
59    {
60        $headers['Content-type']        = 'application/pdf';
61        $headers['Content-disposition'] = (($forceDownload) ? 'attachment; ' : null) . 'filename=' . $filename;
62
63        $compiler = new Build\Compiler();
64        $compiler->finalize($document);
65
66        // Send the headers and output the PDF
67        if (!headers_sent()) {
68            header('HTTP/1.1 200 OK');
69            foreach ($headers as $name => $value) {
70                header($name . ': ' . $value);
71            }
72        }
73
74        echo $compiler->getOutput();
75    }
76
77    /**
78     * Import from an HTML string
79     *
80     * @param  string            $html
81     * @param  Document          $document
82     * @param  string|array|null $pageSize
83     * @return AbstractDocument
84     */
85    public static function importFromHtml(
86        string $html, Document $document = new Document(), string|array|null $pageSize = null
87    ): AbstractDocument
88    {
89        $parser = new Build\Html\Parser($document, $pageSize);
90        $parser->parseHtml($html)->process();
91
92        return $parser->document();
93    }
94
95    /**
96     * Import from an HTML file
97     *
98     * @param  string            $htmlFile
99     * @param  Document          $document
100     * @param  string|array|null $pageSize
101     * @return AbstractDocument
102     */
103    public static function importFromHtmlFile(
104        string $htmlFile, Document $document = new Document(), string|array|null $pageSize = null
105    ): AbstractDocument
106    {
107        $parser = new Build\Html\Parser($document, $pageSize);
108        $parser->parseHtmlFile($htmlFile)->process();
109
110        return $parser->document();
111    }
112
113    /**
114     * Import from an HTML URI
115     *
116     * @param  string            $htmlUri
117     * @param  Document          $document
118     * @param  string|array|null $pageSize
119     * @return AbstractDocument
120     */
121    public static function importFromHtmlUri(
122        string $htmlUri, Document $document = new Document(), string|array|null $pageSize = null
123    ): AbstractDocument
124    {
125        $parser = new Build\Html\Parser($document, $pageSize);
126        $parser->parseHtmlUri($htmlUri)->process();
127
128        return $parser->document();
129    }
130
131    /**
132     * Import from an existing PDF file
133     *
134     * @param  string  $file
135     * @param  mixed   $pages
136     * @param  ?string $password
137     * @return AbstractDocument
138     */
139    public static function importFromFile(string $file, mixed $pages = null, ?string $password = null): AbstractDocument
140    {
141        $parser = new Build\Parser();
142        return $parser->parseFile($file, $pages, $password);
143    }
144
145    /**
146     * Import from raw data stream
147     *
148     * @param  string  $data
149     * @param  mixed   $pages
150     * @param  ?string $password
151     * @return AbstractDocument
152     */
153    public static function importRawData(string $data, mixed $pages = null, ?string $password = null): AbstractDocument
154    {
155        $parser = new Build\Parser();
156        return $parser->parseData($data, $pages, $password);
157    }
158
159    /**
160     * Merge PDF files into one document
161     *
162     * @param  array    $files
163     * @param  Document $document
164     * @param  array    $passwords per-source decryption passwords, keyed the same way as $files
165     *                             (e.g. [1 => 'secret'] to supply a password only for $files[1]);
166     *                             a source with no entry (or a null entry) is opened with no password.
167     * @return AbstractDocument
168     */
169    public static function merge(array $files, Document $document = new Document(), array $passwords = []): AbstractDocument
170    {
171        $merger = new Build\Merger();
172        return $merger->mergeFiles($files, $document, $passwords);
173    }
174
175    /**
176     * Merge raw PDF data streams into one document
177     *
178     * @param  array    $data
179     * @param  Document $document
180     * @param  array    $passwords per-source decryption passwords, keyed the same way as $data
181     *                             (e.g. [1 => 'secret'] to supply a password only for $data[1]);
182     *                             a source with no entry (or a null entry) is opened with no password.
183     * @return AbstractDocument
184     */
185    public static function mergeRawData(array $data, Document $document = new Document(), array $passwords = []): AbstractDocument
186    {
187        $merger = new Build\Merger();
188        return $merger->mergeData($data, $document, $passwords);
189    }
190
191    /**
192     * Import from an existing PDF file
193     *
194     * @param  string|array $images
195     * @param  int          $quality
196     * @throws Exception
197     * @return AbstractDocument
198     */
199    public static function importFromImages(string|array $images, int $quality = 70): AbstractDocument
200    {
201        if (!is_array($images)) {
202            $images = [$images];
203        }
204
205        $document = new Document();
206
207        foreach ($images as $image) {
208            $document->addPage(Document\Page::createFromImage($image, $quality));
209        }
210
211        return $document;
212    }
213
214    /**
215     * Extract as images
216     *
217     * @param  string $file
218     * @param  string $location
219     * @param  string $format
220     * @param  int    $resolution
221     * @param  string $filenameFormat sprintf() format string, given the file's basename and the 1-indexed
222     *                                page number (e.g. '%1$s-%2$02d' => 'document-01', 'page-%2$02d' => 'page-01')
223     * @param  mixed  $pages
224     * @param  ?int   $pageLimit
225     * @return array
226     *@throws Build\Exception
227     */
228    public static function extractAsImages(
229        string $file, string $location, string $format = 'jpg', int $resolution = 300,
230        string $filenameFormat = '%1$s-%2$02d', mixed $pages = null, ?int $pageLimit = null
231    ): array
232    {
233        $extractor  = new Build\Image\Extractor();
234        $totalPages = $extractor->countPages($file);
235        $allPages   = ($totalPages > 0) ? range(1, $totalPages) : [];
236
237        $pages       = self::normalizePages($pages);
238        $pageNumbers = self::selectByPageFilter($allPages, $pages, $pageLimit);
239
240        return $extractor->extract($file, $location, $format, $resolution, $filenameFormat, $pageNumbers);
241    }
242
243    /**
244     * Extract text from file
245     *
246     * @param  string  $file
247     * @param  mixed   $pages
248     * @param  ?int    $pageLimit
249     * @param  ?string $password
250     * @return string
251     */
252    public static function extractTextFromFile(
253        string $file, mixed $pages = null, ?int $pageLimit = null, ?string $password = null
254    ): string
255    {
256        $doc = Extract\Document::fromFile($file, $password);
257        return self::extractTextFromDocument($doc, $pages, $pageLimit);
258    }
259
260    /**
261     * Extract text from raw data stream
262     *
263     * @param  string  $data
264     * @param  mixed   $pages
265     * @param  ?int    $pageLimit
266     * @param  ?string $password
267     * @return string
268     */
269    public static function extractTextFromData(
270        string $data, mixed $pages = null, ?int $pageLimit = null, ?string $password = null
271    ): string
272    {
273        $doc = new Extract\Document($data, $password);
274        return self::extractTextFromDocument($doc, $pages, $pageLimit);
275    }
276
277    /**
278     * Extract text from a parsed Extract\Document, joining pages/runs
279     *
280     * @param  Extract\Document $doc
281     * @param  mixed            $pages
282     * @param  ?int             $pageLimit
283     * @return string
284     */
285    protected static function extractTextFromDocument(Extract\Document $doc, mixed $pages, ?int $pageLimit): string
286    {
287        $pages    = self::normalizePages($pages);
288        $docPages = Extract\Content\PageWalker::walk($doc, $pages, $pageLimit);
289        $docPages = self::selectByPageFilter($docPages, $pages, $pageLimit);
290
291        $texts = [];
292
293        foreach ($docPages as $docPage) {
294            $pageText = self::extractTextFromPage($doc, $docPage);
295            $pageText = trim($pageText);
296
297            if ($pageText !== '') {
298                $texts[] = $pageText;
299            }
300        }
301
302        return implode("\n\n", $texts);
303    }
304
305    /**
306     * Extract text from a single page, joining runs by their separator
307     *
308     * @param  Extract\Document         $doc
309     * @param  Extract\Content\PageInfo $page
310     * @return string
311     */
312    protected static function extractTextFromPage(Extract\Document $doc, Extract\Content\PageInfo $page): string
313    {
314        $interpreter = new Extract\Content\Interpreter();
315        $runs        = $interpreter->run($doc, $page->content, $page->resources);
316
317        $text = '';
318
319        foreach ($runs as $run) {
320            $text .= match ($run->separator) {
321                Extract\Content\TextRun::SEPARATOR_SPACE   => ' ',
322                Extract\Content\TextRun::SEPARATOR_TAB     => "\t",
323                Extract\Content\TextRun::SEPARATOR_NEWLINE => "\n",
324                default                                    => '',
325            };
326
327            $decoded = Extract\Font\Resolver::decodeRun($run, $doc);
328
329            if ($run->reversed) {
330                // /ReversedChars marked content (Interpreter::isReversedActive())
331                // stores its character stream in reversed logical order - the
332                // decoder faithfully decodes byte-for-byte, so the CONSUMER
333                // (here) is responsible for restoring logical reading order.
334                // mb_str_split(), not strrev(), since the decoded text is
335                // UTF-8 and a byte-level reverse would corrupt multi-byte
336                // sequences.
337                $decoded = implode('', array_reverse(mb_str_split($decoded)));
338            }
339
340            $text .= $decoded;
341        }
342
343        return $text;
344    }
345
346    /**
347     * Determine if every page of a PDF file is nothing but a single scanned/drawn image
348     *
349     * @param  string  $file
350     * @param  mixed   $pages
351     * @param  ?int    $pageLimit
352     * @param  ?string $password
353     * @return bool
354     */
355    public static function isImageOnlyDocument(
356        string $file, mixed $pages = null, ?int $pageLimit = null, ?string $password = null
357    ): bool
358    {
359        return self::allPagesImageOnly(self::getImageOnlyPages($file, $pages, $pageLimit, $password));
360    }
361
362    /**
363     * Determine if every page of raw PDF data is nothing but a single scanned/drawn image
364     *
365     * @param  string  $data
366     * @param  mixed   $pages
367     * @param  ?int    $pageLimit
368     * @param  ?string $password
369     * @return bool
370     */
371    public static function isImageOnlyData(
372        string $data, mixed $pages = null, ?int $pageLimit = null, ?string $password = null
373    ): bool
374    {
375        return self::allPagesImageOnly(self::getImageOnlyPagesFromData($data, $pages, $pageLimit, $password));
376    }
377
378    /**
379     * Get a per-page image-only classification for a PDF file
380     *
381     * @param  string  $file
382     * @param  mixed   $pages
383     * @param  ?int    $pageLimit
384     * @param  ?string $password
385     * @return array
386     */
387    public static function getImageOnlyPages(
388        string $file, mixed $pages = null, ?int $pageLimit = null, ?string $password = null
389    ): array
390    {
391        $doc = Extract\Document::fromFile($file, $password);
392        return self::classifyPages($doc, $pages, $pageLimit);
393    }
394
395    /**
396     * Get a per-page image-only classification for raw PDF data
397     *
398     * @param  string  $data
399     * @param  mixed   $pages
400     * @param  ?int    $pageLimit
401     * @param  ?string $password
402     * @return array
403     */
404    public static function getImageOnlyPagesFromData(
405        string $data, mixed $pages = null, ?int $pageLimit = null, ?string $password = null
406    ): array
407    {
408        $doc = new Extract\Document($data, $password);
409        return self::classifyPages($doc, $pages, $pageLimit);
410    }
411
412    /**
413     * Classify every page of a parsed Extract\Document as image-only or not
414     *
415     * @param  Extract\Document $doc
416     * @param  mixed            $pages
417     * @param  ?int             $pageLimit
418     * @return array
419     */
420    protected static function classifyPages(Extract\Document $doc, mixed $pages = null, ?int $pageLimit = null): array
421    {
422        $pages    = self::normalizePages($pages);
423        $docPages = Extract\Content\PageWalker::walk($doc, $pages, $pageLimit);
424        $docPages = self::selectByPageFilter($docPages, $pages, $pageLimit);
425
426        $result = [];
427
428        foreach ($docPages as $i => $docPage) {
429            $result[$i] = Extract\Content\PageClassifier::isImageOnly($doc, $docPage);
430        }
431
432        return $result;
433    }
434
435    /**
436     * Normalize a $pages argument (int, array, or null) into an array or null
437     *
438     * @param  mixed $pages
439     * @return ?array
440     */
441    protected static function normalizePages(mixed $pages): ?array
442    {
443        return ($pages !== null) ? ((!is_array($pages)) ? [$pages] : $pages) : null;
444    }
445
446    /**
447     * Select items by 1-indexed position, either an explicit page list or a page limit
448     *
449     * An explicit $pages list wins outright; $pageLimit only applies when
450     * $pages is null. Shared by every extract-style method that filters a
451     * per-page result set the same way (text extraction, image-only
452     * classification, image extraction).
453     *
454     * @param  array  $items
455     * @param  ?array $pages
456     * @param  ?int   $pageLimit
457     * @return array
458     */
459    protected static function selectByPageFilter(array $items, ?array $pages, ?int $pageLimit): array
460    {
461        if ($pages !== null) {
462            $selected = [];
463            foreach ($items as $i => $item) {
464                if (in_array(($i + 1), $pages)) {
465                    $selected[] = $item;
466                }
467            }
468            return $selected;
469        } elseif (is_int($pageLimit) && ($pageLimit > 0)) {
470            return array_slice($items, 0, $pageLimit);
471        }
472
473        return $items;
474    }
475
476    /**
477     * Determine if a set of per-page image-only results means the whole document is image-only
478     *
479     * @param  array $pages
480     * @return bool
481     */
482    protected static function allPagesImageOnly(array $pages): bool
483    {
484        return (!empty($pages)) && !in_array(false, $pages, true);
485    }
486
487}