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