Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
89 / 89
100.00% covered (success)
100.00%
19 / 19
CRAP
100.00% covered (success)
100.00%
1 / 1
Pdf
100.00% covered (success)
100.00%
89 / 89
100.00% covered (success)
100.00%
19 / 19
48
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
 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
 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%
17 / 17
100.00% covered (success)
100.00%
1 / 1
10
 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%
14 / 14
100.00% covered (success)
100.00%
1 / 1
9
 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.0.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     * @return AbstractDocument
83     */
84    public static function importFromHtml(string $html, Document $document = new Document()): AbstractDocument
85    {
86        $parser = new Build\Html\Parser($document);
87        $parser->parseHtml($html)->process();
88
89        return $parser->document();
90    }
91
92    /**
93     * Import from an HTML file
94     *
95     * @param  string   $htmlFile
96     * @param  Document $document
97     * @return AbstractDocument
98     */
99    public static function importFromHtmlFile(string $htmlFile, Document $document = new Document()): AbstractDocument
100    {
101        $parser = new Build\Html\Parser($document);
102        $parser->parseHtmlFile($htmlFile)->process();
103
104        return $parser->document();
105    }
106
107    /**
108     * Import from an existing PDF file
109     *
110     * @param  string $file
111     * @param  mixed  $pages
112     * @return AbstractDocument
113     */
114    public static function importFromFile(string $file, mixed $pages = null): AbstractDocument
115    {
116        $parser = new Build\Parser();
117        return $parser->parseFile($file, $pages);
118    }
119
120    /**
121     * Import from raw data stream
122     *
123     * @param  string $data
124     * @param  mixed  $pages
125     * @return AbstractDocument
126     */
127    public static function importRawData(string $data, mixed $pages = null): AbstractDocument
128    {
129        $parser = new Build\Parser();
130        return $parser->parseData($data, $pages);
131    }
132
133    /**
134     * Merge PDF files into one document
135     *
136     * @param  array $files
137     * @return AbstractDocument
138     */
139    public static function merge(array $files): AbstractDocument
140    {
141        $merger = new Build\Merger();
142        return $merger->mergeFiles($files);
143    }
144
145    /**
146     * Merge raw PDF data streams into one document
147     *
148     * @param  array $data
149     * @return AbstractDocument
150     */
151    public static function mergeRawData(array $data): AbstractDocument
152    {
153        $merger = new Build\Merger();
154        return $merger->mergeData($data);
155    }
156
157    /**
158     * Import from an existing PDF file
159     *
160     * @param  string|array $images
161     * @param  int          $quality
162     * @throws Exception
163     * @return AbstractDocument
164     */
165    public static function importFromImages(string|array $images, int $quality = 70): AbstractDocument
166    {
167        if (!is_array($images)) {
168            $images = [$images];
169        }
170
171        $document = new Document();
172
173        foreach ($images as $image) {
174            $document->addPage(Document\Page::createFromImage($image, $quality));
175        }
176
177        return $document;
178    }
179
180    /**
181     * Extract text from file
182     *
183     * @param  string $file
184     * @param  mixed  $pages
185     * @param  ?int   $pageLimit
186     * @return string
187     */
188    public static function extractTextFromFile(string $file, mixed $pages = null, ?int $pageLimit = null): string
189    {
190        $doc = Extract\Document::fromFile($file);
191        return self::extractTextFromDocument($doc, $pages, $pageLimit);
192    }
193
194    /**
195     * Extract text from raw data stream
196     *
197     * @param  string $data
198     * @param  mixed  $pages
199     * @param  ?int   $pageLimit
200     * @return string
201     */
202    public static function extractTextFromData(string $data, mixed $pages = null, ?int $pageLimit = null): string
203    {
204        $doc = new Extract\Document($data);
205        return self::extractTextFromDocument($doc, $pages, $pageLimit);
206    }
207
208    /**
209     * Extract text from a parsed Extract\Document, joining pages/runs
210     *
211     * @param  Extract\Document $doc
212     * @param  mixed            $pages
213     * @param  ?int             $pageLimit
214     * @return string
215     */
216    protected static function extractTextFromDocument(Extract\Document $doc, mixed $pages, ?int $pageLimit): string
217    {
218        $pages    = ($pages !== null) ? ((!is_array($pages)) ? [$pages] : $pages) : null;
219        $docPages = Extract\Content\PageWalker::walk($doc, $pages, $pageLimit);
220
221        if ($pages !== null) {
222            $selected = [];
223            foreach ($docPages as $i => $docPage) {
224                if (in_array(($i + 1), $pages)) {
225                    $selected[] = $docPage;
226                }
227            }
228            $docPages = $selected;
229        } elseif (is_int($pageLimit) && ($pageLimit > 0)) {
230            $docPages = array_slice($docPages, 0, $pageLimit);
231        }
232
233        $texts = [];
234
235        foreach ($docPages as $docPage) {
236            $pageText = self::extractTextFromPage($doc, $docPage);
237            $pageText = trim($pageText);
238
239            if ($pageText !== '') {
240                $texts[] = $pageText;
241            }
242        }
243
244        return implode("\n\n", $texts);
245    }
246
247    /**
248     * Extract text from a single page, joining runs by their separator
249     *
250     * @param  Extract\Document         $doc
251     * @param  Extract\Content\PageInfo $page
252     * @return string
253     */
254    protected static function extractTextFromPage(Extract\Document $doc, Extract\Content\PageInfo $page): string
255    {
256        $interpreter = new Extract\Content\Interpreter();
257        $runs        = $interpreter->run($doc, $page->content, $page->resources);
258
259        $text = '';
260
261        foreach ($runs as $run) {
262            $text .= match ($run->separator) {
263                Extract\Content\TextRun::SEPARATOR_SPACE   => ' ',
264                Extract\Content\TextRun::SEPARATOR_TAB     => "\t",
265                Extract\Content\TextRun::SEPARATOR_NEWLINE => "\n",
266                default                                    => '',
267            };
268
269            $decoded = Extract\Font\Resolver::decodeRun($run, $doc);
270
271            if ($run->reversed) {
272                // /ReversedChars marked content (Interpreter::isReversedActive())
273                // stores its character stream in reversed logical order - the
274                // decoder faithfully decodes byte-for-byte, so the CONSUMER
275                // (here) is responsible for restoring logical reading order.
276                // mb_str_split(), not strrev(), since the decoded text is
277                // UTF-8 and a byte-level reverse would corrupt multi-byte
278                // sequences.
279                $decoded = implode('', array_reverse(mb_str_split($decoded)));
280            }
281
282            $text .= $decoded;
283        }
284
285        return $text;
286    }
287
288    /**
289     * Determine if every page of a PDF file is nothing but a single scanned/drawn image
290     *
291     * @param  string $file
292     * @param  mixed  $pages
293     * @param  ?int   $pageLimit
294     * @return bool
295     */
296    public static function isImageOnlyDocument(string $file, mixed $pages = null, ?int $pageLimit = null): bool
297    {
298        return self::allPagesImageOnly(self::getImageOnlyPages($file, $pages, $pageLimit));
299    }
300
301    /**
302     * Determine if every page of raw PDF data is nothing but a single scanned/drawn image
303     *
304     * @param  string $data
305     * @param  mixed  $pages
306     * @param  ?int   $pageLimit
307     * @return bool
308     */
309    public static function isImageOnlyData(string $data, mixed $pages = null, ?int $pageLimit = null): bool
310    {
311        return self::allPagesImageOnly(self::getImageOnlyPagesFromData($data, $pages, $pageLimit));
312    }
313
314    /**
315     * Get a per-page image-only classification for a PDF file
316     *
317     * @param  string $file
318     * @param  mixed  $pages
319     * @param  ?int   $pageLimit
320     * @return array
321     */
322    public static function getImageOnlyPages(string $file, mixed $pages = null, ?int $pageLimit = null): array
323    {
324        $doc = Extract\Document::fromFile($file);
325        return self::classifyPages($doc, $pages, $pageLimit);
326    }
327
328    /**
329     * Get a per-page image-only classification for raw PDF data
330     *
331     * @param  string $data
332     * @param  mixed  $pages
333     * @param  ?int   $pageLimit
334     * @return array
335     */
336    public static function getImageOnlyPagesFromData(string $data, mixed $pages = null, ?int $pageLimit = null): array
337    {
338        $doc = new Extract\Document($data);
339        return self::classifyPages($doc, $pages, $pageLimit);
340    }
341
342    /**
343     * Classify every page of a parsed Extract\Document as image-only or not
344     *
345     * @param  Extract\Document $doc
346     * @param  mixed            $pages
347     * @param  ?int             $pageLimit
348     * @return array
349     */
350    protected static function classifyPages(Extract\Document $doc, mixed $pages = null, ?int $pageLimit = null): array
351    {
352        $pages    = ($pages !== null) ? ((!is_array($pages)) ? [$pages] : $pages) : null;
353        $docPages = Extract\Content\PageWalker::walk($doc, $pages, $pageLimit);
354
355        if ($pages !== null) {
356            $selected = [];
357            foreach ($docPages as $i => $docPage) {
358                if (in_array(($i + 1), $pages)) {
359                    $selected[] = $docPage;
360                }
361            }
362            $docPages = $selected;
363        } elseif (is_int($pageLimit) && ($pageLimit > 0)) {
364            $docPages = array_slice($docPages, 0, $pageLimit);
365        }
366
367        $result = [];
368
369        foreach ($docPages as $i => $docPage) {
370            $result[$i] = Extract\Content\PageClassifier::isImageOnly($doc, $docPage);
371        }
372
373        return $result;
374    }
375
376    /**
377     * Determine if a set of per-page image-only results means the whole document is image-only
378     *
379     * @param  array $pages
380     * @return bool
381     */
382    protected static function allPagesImageOnly(array $pages): bool
383    {
384        return (!empty($pages)) && !in_array(false, $pages, true);
385    }
386
387}