xref: /plugin/totext/vendor/prinsfrank/pdfparser/src/Document/Document.php (revision b4ecfc82b02c9a76205381a28b47006474ccf569) !
1<?php
2declare(strict_types=1);
3
4namespace PrinsFrank\PdfParser\Document;
5
6use PrinsFrank\PdfParser\Document\CrossReference\Source\CrossReferenceSource;
7use PrinsFrank\PdfParser\Document\CrossReference\Source\Section\SubSection\Entry\CrossReferenceEntryCompressed;
8use PrinsFrank\PdfParser\Document\Dictionary\Dictionary;
9use PrinsFrank\PdfParser\Document\Dictionary\DictionaryKey\DictionaryKey;
10use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Reference\ReferenceValue;
11use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Reference\ReferenceValueArray;
12use PrinsFrank\PdfParser\Document\Generic\Character\WhitespaceCharacter;
13use PrinsFrank\PdfParser\Document\Object\Decorator\Catalog;
14use PrinsFrank\PdfParser\Document\Object\Decorator\DecoratedObject;
15use PrinsFrank\PdfParser\Document\Object\Decorator\DecoratedObjectFactory;
16use PrinsFrank\PdfParser\Document\Object\Decorator\EncryptDictionary;
17use PrinsFrank\PdfParser\Document\Object\Decorator\InformationDictionary;
18use PrinsFrank\PdfParser\Document\Object\Decorator\Page;
19use PrinsFrank\PdfParser\Document\Object\Decorator\XObject;
20use PrinsFrank\PdfParser\Document\Object\Item\UncompressedObject\UncompressedObject;
21use PrinsFrank\PdfParser\Document\Object\Item\UncompressedObject\UncompressedObjectParser;
22use PrinsFrank\PdfParser\Document\Security\FileEncryptionKey;
23use PrinsFrank\PdfParser\Document\Security\StandardSecurity;
24use PrinsFrank\PdfParser\Document\Version\Version;
25use PrinsFrank\PdfParser\Exception\AuthenticationFailedException;
26use PrinsFrank\PdfParser\Exception\NotImplementedException;
27use PrinsFrank\PdfParser\Exception\ParseFailureException;
28use PrinsFrank\PdfParser\Exception\PdfParserException;
29use PrinsFrank\PdfParser\Exception\RuntimeException;
30use PrinsFrank\PdfParser\Stream\Stream;
31
32/** @api */
33class Document {
34    /** @var list<Page> */
35    private readonly array $pages;
36
37    /** @var array<int, DecoratedObject|null> */
38    private array $objectCache = [];
39    public readonly ?FileEncryptionKey $fileEncryptionKey;
40
41    public function __construct(
42        public readonly Stream $stream,
43        public readonly Version $version,
44        public readonly CrossReferenceSource $crossReferenceSource,
45        public readonly ?StandardSecurity $security,
46    ) {
47        $this->fileEncryptionKey = $this->getFileEncryptionKeyFromSecurity($security);
48    }
49
50    private function getFileEncryptionKeyFromSecurity(?StandardSecurity $security): ?FileEncryptionKey {
51        if (($encryptDictionary = $this->getEncryptDictionary()) === null) {
52            return null;
53        }
54
55        if ($encryptDictionary->getSecurityHandler() === null) {
56            throw new NotImplementedException('Empty security handler is not supported');
57        }
58
59        return ($security ?? new StandardSecurity())
60            ->getFileEncryptionKey($encryptDictionary, $this->crossReferenceSource->getFirstId())
61            ?? throw new AuthenticationFailedException(
62                $security === null
63                    ? 'Document could not be decrypted using default credentials, please supply an owner or user password'
64                    : 'User and owner password are invalid, please supply valid credentials',
65            );
66    }
67
68    /** @throws PdfParserException */
69    public function getInformationDictionary(): ?InformationDictionary {
70        $infoReference = $this->crossReferenceSource->getReferenceForKey(DictionaryKey::INFO);
71        if ($infoReference === null) {
72            return null;
73        }
74
75        return $this->getObject($infoReference->objectNumber, InformationDictionary::class);
76    }
77
78    public function getEncryptDictionary(): ?EncryptDictionary {
79        $infoReference = $this->crossReferenceSource->getReferenceForKey(DictionaryKey::ENCRYPT);
80        if ($infoReference === null) {
81            return null;
82        }
83
84        return $this->getObject($infoReference->objectNumber, EncryptDictionary::class);
85    }
86
87    /** @throws PdfParserException */
88    public function getCatalog(): Catalog {
89        $rootReference = $this->crossReferenceSource->getReferenceForKey(DictionaryKey::ROOT)
90            ?? throw new ParseFailureException('Unable to locate root for document.');
91        $catalog = $this->getObject($rootReference->objectNumber, Catalog::class)
92            ?? throw new ParseFailureException(sprintf('Document references object %d as root, but object couln\'t be located', $rootReference->objectNumber));
93        if (!$catalog instanceof Catalog) {
94            throw new RuntimeException('Catalog should be a catalog item');
95        }
96
97        return $catalog;
98    }
99
100    /**
101     * @template T of DecoratedObject
102     * @param class-string<T>|null $expectedDecoratorFQN
103     * @throws PdfParserException
104     * @return ($expectedDecoratorFQN is null ? list<DecoratedObject> : list<T>)
105     */
106    public function getObjectsByDictionaryKey(Dictionary $dictionary, DictionaryKey $dictionaryKey, ?string $expectedDecoratorFQN = null): array {
107        $dictionaryValueType = $dictionary->getTypeForKey($dictionaryKey);
108        if ($dictionaryValueType === ReferenceValue::class) {
109            return [$this->getObject($dictionary->getValueForKey($this, $dictionaryKey, ReferenceValue::class)->objectNumber ?? throw new ParseFailureException(), $expectedDecoratorFQN) ?? throw new ParseFailureException()];
110        } elseif ($dictionaryValueType === ReferenceValueArray::class) {
111            return array_map(
112                fn(ReferenceValue $referenceValue) => $this->getObject($referenceValue->objectNumber, $expectedDecoratorFQN) ?? throw new ParseFailureException(),
113                $dictionary->getValueForKey($this, $dictionaryKey, ReferenceValueArray::class)->referenceValues ?? throw new ParseFailureException(),
114            );
115        }
116
117        throw new ParseFailureException(sprintf('Dictionary value with key "%s" is of type "%s", expected referencevalue(array)', $dictionaryKey->name, $dictionaryValueType ?? 'null'));
118    }
119
120    /**
121     * @template T of DecoratedObject
122     * @param class-string<T>|null $expectedDecoratorFQN
123     * @throws PdfParserException
124     * @return ($expectedDecoratorFQN is null ? DecoratedObject : T)
125     */
126    public function getObject(int $objectNumber, ?string $expectedDecoratorFQN = null): ?DecoratedObject {
127        if (array_key_exists($objectNumber, $this->objectCache)) {
128            return $this->objectCache[$objectNumber];
129        }
130
131        $crossReferenceEntry = $this->crossReferenceSource->getCrossReferenceEntry($objectNumber);
132        if ($crossReferenceEntry === null) {
133            return null;
134        }
135
136        if ($crossReferenceEntry instanceof CrossReferenceEntryCompressed) {
137            $parentObject = $this->getObject($crossReferenceEntry->storedInStreamWithObjectNumber)
138                ?? throw new RuntimeException(sprintf('Parent object for %d with number %d doesn\'t exist', $objectNumber, $crossReferenceEntry->storedInStreamWithObjectNumber));
139
140            if (!$parentObject->objectItem instanceof UncompressedObject) {
141                throw new RuntimeException('Parents for stream items shouldn\'t be stream items themselves');
142            }
143
144            $objectItem = $parentObject->objectItem->getCompressedObject($objectNumber, $this);
145        } else {
146            $objectItem = UncompressedObjectParser::parseObject($crossReferenceEntry, $objectNumber, $this);
147        }
148
149        return $this->objectCache[$objectNumber] = DecoratedObjectFactory::forItem($objectItem, $this, $expectedDecoratorFQN);
150    }
151
152    /** @throws PdfParserException */
153    public function getPage(int $pageNumber): ?Page {
154        return $this->getPages()[$pageNumber - 1] ?? null;
155    }
156
157    /** @throws PdfParserException */
158    public function getNumberOfPages(): int {
159        return count($this->getPages());
160    }
161
162    /**
163     * @throws PdfParserException
164     * @return list<Page>
165     */
166    public function getPages(): array {
167        return $this->pages ??= $this->getCatalog()
168            ->getPagesRoot()
169            ->getPageItems();
170    }
171
172    /**
173     * @param ?string $pageSeparator an optional string to put between text of different pages
174     * @throws PdfParserException
175     */
176    public function getText(?string $pageSeparator = null): string {
177        $text = '';
178        foreach ($this->getPages() as $i => $page) {
179            if ($i !== 0) {
180                $text .= $pageSeparator !== null ? $pageSeparator : WhitespaceCharacter::LINE_FEED->value;
181            }
182
183            $text .= $page->getText();
184        }
185
186        return $text;
187    }
188
189    /**
190     * @throws PdfParserException
191     * @return list<XObject>
192     */
193    public function getImages(): array {
194        $images = [];
195        foreach ($this->getPages() as $page) {
196            $images = [... $images, ...$page->getImages()];
197        }
198
199        return $images;
200    }
201}
202