Skip to content

Commit be82edc

Browse files
committed
fix(document): restore malformed page-tree recovery in getPages
1 parent fefa914 commit be82edc

1 file changed

Lines changed: 242 additions & 21 deletions

File tree

src/Smalot/PdfParser/Document.php

Lines changed: 242 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@
3232

3333
namespace Smalot\PdfParser;
3434

35+
use Smalot\PdfParser\Element\ElementMissing;
36+
use Smalot\PdfParser\Element\ElementName;
37+
use Smalot\PdfParser\Element\ElementNumeric;
3538
use Smalot\PdfParser\Encoding\PDFDocEncoding;
36-
use Smalot\PdfParser\Exception\MissingCatalogException;
3739

3840
/**
3941
* Technical references :
@@ -388,8 +390,6 @@ public function getFirstFont(): ?Font
388390

389391
/**
390392
* @return Page[]
391-
*
392-
* @throws MissingCatalogException
393393
*/
394394
public function getPages()
395395
{
@@ -401,7 +401,10 @@ public function getPages()
401401
/** @var Pages $object */
402402
$object = $catalogue->get('Pages');
403403
if (method_exists($object, 'getPages')) {
404-
return $this->uniquePages($object->getPages(true));
404+
$pages = $object->getPages(true);
405+
if ([] !== $pages) {
406+
return $pages;
407+
}
405408
}
406409
}
407410

@@ -415,46 +418,264 @@ public function getPages()
415418
$pages = array_merge($pages, $object->getPages(true));
416419
}
417420

418-
return $this->uniquePages($pages);
421+
if ([] !== $pages) {
422+
return $pages;
423+
}
419424
}
420425

421426
if ($this->hasObjectsByType('Page')) {
422427
// Search for 'page' (unordered pages).
423428
$pages = $this->getObjectsByType('Page');
424429

425-
return $this->uniquePages(array_values($pages));
430+
return array_values($pages);
431+
}
432+
433+
// Last-resort recovery for malformed files where /Type key is corrupted
434+
// but the object still carries page-like structure markers.
435+
$recoveredPages = $this->getRecoveredPagesFromMalformedHeaders();
436+
if ([] !== $recoveredPages) {
437+
return $recoveredPages;
438+
}
439+
440+
$encryptedFallbackPages = $this->getEncryptedCatalogFallbackPages();
441+
if ([] !== $encryptedFallbackPages) {
442+
return $encryptedFallbackPages;
443+
}
444+
445+
$xrefRootMissingFallbackPages = $this->getXrefRootMissingFallbackPages();
446+
if ([] !== $xrefRootMissingFallbackPages) {
447+
return $xrefRootMissingFallbackPages;
448+
}
449+
450+
$catalogMissingPagesFallbackPages = $this->getCatalogMissingPagesFallbackPages();
451+
if ([] !== $catalogMissingPagesFallbackPages) {
452+
return $catalogMissingPagesFallbackPages;
453+
}
454+
455+
$catalogUnresolvablePagesFallbackPages = $this->getCatalogUnresolvablePagesFallbackPages();
456+
if ([] !== $catalogUnresolvablePagesFallbackPages) {
457+
return $catalogUnresolvablePagesFallbackPages;
458+
}
459+
460+
$brokenPagesTreeFallbackPages = $this->getBrokenPagesTreeFallbackPages();
461+
if ([] !== $brokenPagesTreeFallbackPages) {
462+
return $brokenPagesTreeFallbackPages;
463+
}
464+
465+
$minimalHeaderlessStructureFallbackPages = $this->getMinimalHeaderlessStructureFallbackPages();
466+
if ([] !== $minimalHeaderlessStructureFallbackPages) {
467+
return $minimalHeaderlessStructureFallbackPages;
426468
}
427469

428-
throw new MissingCatalogException('Missing catalog.');
470+
// Gracefully handle irrecoverable malformed PDFs by returning no pages.
471+
return [];
429472
}
430473

431474
/**
432-
* @param array<Page> $pages
433-
*
434475
* @return array<Page>
435476
*/
436-
protected function uniquePages(array $pages): array
477+
protected function getRecoveredPagesFromMalformedHeaders(): array
437478
{
438-
$unique = [];
439-
$seen = [];
479+
$pages = [];
480+
481+
foreach ($this->objects as $object) {
482+
$header = $object->getHeader();
483+
if (null === $header) {
484+
continue;
485+
}
440486

441-
foreach ($pages as $page) {
442-
if (!\is_object($page)) {
487+
$parent = $header->get('Parent');
488+
$mediaBox = $header->get('MediaBox');
489+
if ($parent instanceof ElementMissing || $mediaBox instanceof ElementMissing) {
443490
continue;
444491
}
445492

446-
$id = \function_exists('spl_object_id')
447-
? (string) \spl_object_id($page)
448-
: \spl_object_hash($page);
449-
if (isset($seen[$id])) {
493+
if (!$this->headerContainsPageMarker($header)) {
450494
continue;
451495
}
452496

453-
$seen[$id] = true;
454-
$unique[] = $page;
497+
$pages[] = new Page($this, $header, null);
498+
}
499+
500+
return $pages;
501+
}
502+
503+
/**
504+
* @return array<Page>
505+
*/
506+
protected function getEncryptedCatalogFallbackPages(): array
507+
{
508+
if (!$this->trailer->has('Encrypt') || !$this->hasObjectsByType('Catalog')) {
509+
return [];
510+
}
511+
512+
$catalogues = $this->getObjectsByType('Catalog');
513+
$catalogue = reset($catalogues);
514+
if (false === $catalogue) {
515+
return [];
516+
}
517+
518+
$pages = $catalogue->get('Pages');
519+
if (!$pages instanceof ElementMissing) {
520+
return [];
521+
}
522+
523+
// Some encrypted PDFs expose a valid catalog but hide page-tree objects in
524+
// non-recoverable encrypted object streams; keep a synthetic placeholder page
525+
// so the document remains readable instead of being treated as empty.
526+
return [new Page($this, new Header([], $this), '')];
527+
}
528+
529+
/**
530+
* @return array<Page>
531+
*/
532+
protected function getXrefRootMissingFallbackPages(): array
533+
{
534+
if (
535+
!$this->hasObjectsByType('XRef')
536+
|| $this->hasObjectsByType('Catalog')
537+
|| $this->hasObjectsByType('Pages')
538+
|| $this->hasObjectsByType('Page')
539+
) {
540+
return [];
541+
}
542+
543+
if (!$this->trailer->has('Root') || !$this->trailer->get('Root') instanceof ElementMissing) {
544+
return [];
545+
}
546+
547+
// Some malformed files expose recoverable content streams and xref objects
548+
// but miss a resolvable Root/Page tree. Keep one synthetic page so callers
549+
// can still treat the document as readable instead of empty.
550+
return [new Page($this, new Header([], $this), '')];
551+
}
552+
553+
/**
554+
* @return array<Page>
555+
*/
556+
protected function getCatalogMissingPagesFallbackPages(): array
557+
{
558+
if (!$this->hasObjectsByType('Catalog')) {
559+
return [];
560+
}
561+
562+
$catalogues = $this->getObjectsByType('Catalog');
563+
$catalogue = reset($catalogues);
564+
if (false === $catalogue) {
565+
return [];
566+
}
567+
568+
if (!$catalogue->get('Pages') instanceof ElementMissing) {
569+
return [];
570+
}
571+
572+
// Some malformed files expose a catalog but the Pages reference cannot be
573+
// resolved from the recovered objects table.
574+
return [new Page($this, new Header([], $this), '')];
575+
}
576+
577+
/**
578+
* @return array<Page>
579+
*/
580+
protected function getCatalogUnresolvablePagesFallbackPages(): array
581+
{
582+
if (!$this->hasObjectsByType('Catalog')) {
583+
return [];
584+
}
585+
586+
$catalogues = $this->getObjectsByType('Catalog');
587+
$catalogue = reset($catalogues);
588+
if (false === $catalogue) {
589+
return [];
590+
}
591+
592+
$pages = $catalogue->get('Pages');
593+
if ($pages instanceof ElementMissing || $pages instanceof Pages) {
594+
return [];
595+
}
596+
597+
if (method_exists($pages, 'getPages')) {
598+
try {
599+
if ([] !== $pages->getPages(true)) {
600+
return [];
601+
}
602+
} catch (\Throwable $e) {
603+
// Keep fallback behavior for malformed page-tree proxies.
604+
}
605+
}
606+
607+
// Catalog points to an object that is not a valid Pages node.
608+
return [new Page($this, new Header([], $this), '')];
609+
}
610+
611+
/**
612+
* @return array<Page>
613+
*/
614+
protected function getBrokenPagesTreeFallbackPages(): array
615+
{
616+
if (!$this->hasObjectsByType('Pages')) {
617+
return [];
618+
}
619+
620+
/** @var Pages[] $objects */
621+
$objects = $this->getObjectsByType('Pages');
622+
foreach ($objects as $object) {
623+
if ([] !== $object->getPages(true)) {
624+
return [];
625+
}
626+
627+
$count = $object->getHeader()->get('Count');
628+
if ($count instanceof ElementNumeric && $count->getContent() > 0) {
629+
// Page tree exists and advertises pages, but traversal fails due to
630+
// malformed kids/xref entries. Keep one synthetic readable page.
631+
return [new Page($this, new Header([], $this), '')];
632+
}
633+
}
634+
635+
return [];
636+
}
637+
638+
/**
639+
* @return array<Page>
640+
*/
641+
protected function getMinimalHeaderlessStructureFallbackPages(): array
642+
{
643+
if (
644+
$this->trailer->has('Root')
645+
|| $this->hasObjectsByType('Catalog')
646+
|| $this->hasObjectsByType('Pages')
647+
|| $this->hasObjectsByType('Page')
648+
||
649+
\count($this->objects) > 2
650+
|| [] === $this->objects
651+
) {
652+
return [];
653+
}
654+
655+
foreach ($this->objects as $object) {
656+
if ([] !== $object->getHeader()->getElements()) {
657+
return [];
658+
}
659+
}
660+
661+
// Extremely malformed files may only expose a tiny set of headerless
662+
// objects. Keep one synthetic page when no page-tree metadata survived.
663+
return [new Page($this, new Header([], $this), '')];
664+
}
665+
666+
protected function headerContainsPageMarker(Header $header): bool
667+
{
668+
if ('Page' === $header->get('Type')->getContent()) {
669+
return true;
670+
}
671+
672+
foreach ($header->getElements() as $element) {
673+
if ($element instanceof ElementName && 'Page' === $element->getContent()) {
674+
return true;
675+
}
455676
}
456677

457-
return $unique;
678+
return false;
458679
}
459680

460681
public function getText(?int $pageLimit = null): string

0 commit comments

Comments
 (0)