1<?php 2 3/** 4 * This file is part of FPDI 5 * 6 * @package setasign\Fpdi 7 * @copyright Copyright (c) 2024 Setasign GmbH & Co. KG (https://www.setasign.com) 8 * @license http://opensource.org/licenses/mit-license The MIT License 9 */ 10 11namespace setasign\Fpdi\PdfReader; 12 13use setasign\Fpdi\PdfParser\CrossReference\CrossReferenceException; 14use setasign\Fpdi\PdfParser\PdfParser; 15use setasign\Fpdi\PdfParser\PdfParserException; 16use setasign\Fpdi\PdfParser\Type\PdfArray; 17use setasign\Fpdi\PdfParser\Type\PdfDictionary; 18use setasign\Fpdi\PdfParser\Type\PdfIndirectObject; 19use setasign\Fpdi\PdfParser\Type\PdfIndirectObjectReference; 20use setasign\Fpdi\PdfParser\Type\PdfNumeric; 21use setasign\Fpdi\PdfParser\Type\PdfType; 22use setasign\Fpdi\PdfParser\Type\PdfTypeException; 23 24/** 25 * A PDF reader class 26 */ 27class PdfReader 28{ 29 /** 30 * @var PdfParser 31 */ 32 protected $parser; 33 34 /** 35 * @var int 36 */ 37 protected $pageCount; 38 39 /** 40 * Indirect objects of resolved pages. 41 * 42 * @var PdfIndirectObjectReference[]|PdfIndirectObject[] 43 */ 44 protected $pages = []; 45 46 /** 47 * PdfReader constructor. 48 * 49 * @param PdfParser $parser 50 */ 51 public function __construct(PdfParser $parser) 52 { 53 $this->parser = $parser; 54 } 55 56 /** 57 * PdfReader destructor. 58 */ 59 public function __destruct() 60 { 61 if ($this->parser !== null) { 62 $this->parser->cleanUp(); 63 } 64 } 65 66 /** 67 * Get the pdf parser instance. 68 * 69 * @return PdfParser 70 */ 71 public function getParser() 72 { 73 return $this->parser; 74 } 75 76 /** 77 * Get the PDF version. 78 * 79 * @return string 80 * @throws PdfParserException 81 */ 82 public function getPdfVersion() 83 { 84 return \implode('.', $this->parser->getPdfVersion()); 85 } 86 87 /** 88 * Get the page count. 89 * 90 * @return int 91 * @throws PdfTypeException 92 * @throws CrossReferenceException 93 * @throws PdfParserException 94 */ 95 public function getPageCount() 96 { 97 if ($this->pageCount === null) { 98 $catalog = $this->parser->getCatalog(); 99 100 $pages = PdfType::resolve(PdfDictionary::get($catalog, 'Pages'), $this->parser); 101 $count = PdfType::resolve(PdfDictionary::get($pages, 'Count'), $this->parser); 102 103 $this->pageCount = PdfNumeric::ensure($count)->value; 104 } 105 106 return $this->pageCount; 107 } 108 109 /** 110 * Get a page instance. 111 * 112 * @param int|numeric-string $pageNumber 113 * @return Page 114 * @throws PdfTypeException 115 * @throws CrossReferenceException 116 * @throws PdfParserException 117 * @throws \InvalidArgumentException 118 */ 119 public function getPage($pageNumber) 120 { 121 if (!\is_numeric($pageNumber)) { 122 throw new \InvalidArgumentException( 123 'Page number needs to be a number.' 124 ); 125 } 126 127 if ($pageNumber < 1 || $pageNumber > $this->getPageCount()) { 128 throw new \InvalidArgumentException( 129 \sprintf( 130 'Page number "%s" out of available page range (1 - %s)', 131 $pageNumber, 132 $this->getPageCount() 133 ) 134 ); 135 } 136 137 $this->readPages(); 138 139 $page = $this->pages[$pageNumber - 1]; 140 141 if ($page instanceof PdfIndirectObjectReference) { 142 $alreadyReadKids = []; 143 $readPages = function ($kids) use (&$readPages, &$alreadyReadKids) { 144 $kids = PdfArray::ensure($kids); 145 146 /** @noinspection LoopWhichDoesNotLoopInspection */ 147 foreach ($kids->value as $reference) { 148 $reference = PdfIndirectObjectReference::ensure($reference); 149 150 if (\in_array($reference->value, $alreadyReadKids, true)) { 151 throw new PdfReaderException('Recursive pages dictionary detected.'); 152 } 153 $alreadyReadKids[] = $reference->value; 154 155 $object = $this->parser->getIndirectObject($reference->value); 156 $type = PdfDictionary::get($object->value, 'Type'); 157 158 if ($type->value === 'Pages') { 159 return $readPages(PdfDictionary::get($object->value, 'Kids')); 160 } 161 162 return $object; 163 } 164 165 throw new PdfReaderException( 166 'Kids array cannot be empty.', 167 PdfReaderException::KIDS_EMPTY 168 ); 169 }; 170 171 $page = $this->parser->getIndirectObject($page->value); 172 $dict = PdfType::resolve($page, $this->parser); 173 $type = PdfDictionary::get($dict, 'Type'); 174 175 if ($type->value === 'Pages') { 176 $kids = PdfType::resolve(PdfDictionary::get($dict, 'Kids'), $this->parser); 177 try { 178 $alreadyReadKids[] = $page->objectNumber; 179 $page = $this->pages[$pageNumber - 1] = $readPages($kids); 180 } catch (PdfReaderException $e) { 181 if ($e->getCode() !== PdfReaderException::KIDS_EMPTY) { 182 throw $e; 183 } 184 185 // let's reset the pages array and read all page objects 186 $this->pages = []; 187 $this->readPages(true); 188 // @phpstan-ignore-next-line 189 $page = $this->pages[$pageNumber - 1]; 190 } 191 } else { 192 $this->pages[$pageNumber - 1] = $page; 193 } 194 } 195 196 return new Page($page, $this->parser); 197 } 198 199 /** 200 * Walk the page tree and resolve all indirect objects of all pages. 201 * 202 * @param bool $readAll 203 * @throws CrossReferenceException 204 * @throws PdfParserException 205 * @throws PdfTypeException 206 */ 207 protected function readPages($readAll = false) 208 { 209 if (\count($this->pages) > 0) { 210 return; 211 } 212 213 $expectedPageCount = $this->getPageCount(); 214 $alreadyReadKids = []; 215 $readPages = function ($kids, $count) use (&$readPages, &$alreadyReadKids, $readAll, $expectedPageCount) { 216 $kids = PdfArray::ensure($kids); 217 $isLeaf = ($count->value === \count($kids->value)); 218 219 foreach ($kids->value as $reference) { 220 $reference = PdfIndirectObjectReference::ensure($reference); 221 222 if (!$readAll && $isLeaf) { 223 $this->pages[] = $reference; 224 continue; 225 } 226 227 if (\in_array($reference->value, $alreadyReadKids, true)) { 228 throw new PdfReaderException('Recursive pages dictionary detected.'); 229 } 230 $alreadyReadKids[] = $reference->value; 231 232 $object = $this->parser->getIndirectObject($reference->value); 233 $type = PdfDictionary::get($object->value, 'Type'); 234 235 if ($type->value === 'Pages') { 236 $readPages( 237 PdfType::resolve(PdfDictionary::get($object->value, 'Kids'), $this->parser), 238 PdfType::resolve(PdfDictionary::get($object->value, 'Count'), $this->parser) 239 ); 240 } else { 241 $this->pages[] = $object; 242 } 243 244 // stop if all pages are read - faulty documents exists with additional entries with invalid data. 245 if (count($this->pages) === $expectedPageCount) { 246 break; 247 } 248 } 249 }; 250 251 $catalog = $this->parser->getCatalog(); 252 $pages = PdfType::resolve(PdfDictionary::get($catalog, 'Pages'), $this->parser); 253 $count = PdfType::resolve(PdfDictionary::get($pages, 'Count'), $this->parser); 254 $kids = PdfType::resolve(PdfDictionary::get($pages, 'Kids'), $this->parser); 255 $readPages($kids, $count); 256 } 257} 258