* @copyright 2002-2026 Nicola Asuni - Tecnick.com LTD * @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE) * @link https://github.com/tecnickcom/tc-lib-pdf * * This file is part of tc-lib-pdf software library. */ namespace Test; use Com\Tecnick\Pdf\Import\ImportPageOutOfRangeException; use Com\Tecnick\Pdf\Import\ImportSourceNotFoundException; use Com\Tecnick\Pdf\Import\ImportUnsupportedFeatureException; use Com\Tecnick\Pdf\Import\PageTemplate; use Com\Tecnick\Pdf\Tcpdf; use PHPUnit\Framework\TestCase; /** * Integration tests for the PDF import facade methods on Tcpdf. */ class TcpdfImporterFacadeTest extends TestCase { /** Path to the single-page test fixture. */ private string $simplePdf; /** Path to the two-page test fixture with a shared font. */ private string $multipagePdf; /** Path to a fixture with explicit Media/Crop/Bleed/Trim/Art boxes. */ private string $boxOptionsPdf; /** Path to a fixture with /Rotate 90 on the page dictionary. */ private string $rotatedPdf; /** Path to a fixture containing alpha/blend ExtGState content. */ private string $transparencyPdf; /** Path to a fixture with an /Encrypt trailer entry. */ private string $encryptedPdf; /** * @throws \Throwable */ protected function setUp(): void { if (!\defined('K_PATH_FONTS')) { $fonts = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts'); \define('K_PATH_FONTS', $fonts); } $this->simplePdf = __DIR__ . '/fixtures/simple_import.pdf'; $this->multipagePdf = __DIR__ . '/fixtures/multipage_import.pdf'; $this->boxOptionsPdf = __DIR__ . '/fixtures/box_options_import.pdf'; $this->rotatedPdf = __DIR__ . '/fixtures/rotated_import.pdf'; $this->transparencyPdf = __DIR__ . '/fixtures/transparency_import.pdf'; $this->encryptedPdf = __DIR__ . '/fixtures/encrypted_import_stub.pdf'; } // ------------------------------------------------------------------ helpers /** * @throws \Throwable */ private function makePdf(): Tcpdf { $pdf = new Tcpdf(); // A default font must be inserted before any addPage call so that // setPageContext / getOutCurrentFont does not receive a null font key. $pdf->font->insert($pdf->pon, 'helvetica', '', 12); return $pdf; } /** * @throws \Throwable */ private function makePdfWithMode(string $mode): Tcpdf { $pdf = new Tcpdf(mode: $mode); $pdf->font->insert($pdf->pon, 'helvetica', '', 12); return $pdf; } /** * @throws \Throwable */ private function getPdfVersion(Tcpdf $pdf): string { $ref = new \ReflectionClass($pdf); while ($ref !== false) { if ($ref->hasProperty('pdfver')) { $prop = $ref->getProperty('pdfver'); return $this->stringValue($prop->getValue($pdf)); } $ref = $ref->getParentClass(); } return ''; } private function stringValue(mixed $value): string { return \is_string($value) ? $value : ''; } /** * Build a minimal one-page PDF whose /Contents is an array of two Flate streams. */ private function buildMultiContentFlatePdf(): string { $streamA = \gzcompress("BT /F1 12 Tf 20 160 Td (A) Tj ET\n"); $streamB = \gzcompress("BT /F1 12 Tf 20 140 Td (B) Tj ET\n"); self::assertNotFalse($streamA); self::assertNotFalse($streamB); $objects = []; $objects[] = '1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj' . "\n"; $objects[] = '2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj' . "\n"; $objects[] = '3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] ' . '/Resources << /Font << /F1 6 0 R >> >> /Contents [4 0 R 5 0 R] >> endobj' . "\n"; $objects[] = '4 0 obj << /Length ' . \strlen($streamA) . ' /Filter /FlateDecode >> stream' . "\n" . $streamA . "\n" . 'endstream endobj' . "\n"; $objects[] = '5 0 obj << /Length ' . \strlen($streamB) . ' /Filter /FlateDecode >> stream' . "\n" . $streamB . "\n" . 'endstream endobj' . "\n"; $objects[] = '6 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj' . "\n"; $pdf = "%PDF-1.4\n"; $offsets = [0 => 0]; foreach ($objects as $idx => $obj) { $objNum = $idx + 1; $offsets[$objNum] = \strlen($pdf); $pdf .= $obj; } $xrefOffset = \strlen($pdf); $pdf .= 'xref' . "\n"; $pdf .= '0 7' . "\n"; $pdf .= '0000000000 65535 f ' . "\n"; for ($objNum = 1; $objNum <= 6; ++$objNum) { $offset = $offsets[$objNum] ?? 0; $pdf .= \sprintf('%010d 00000 n ' . "\n", $offset); } $pdf .= 'trailer << /Size 7 /Root 1 0 R >>' . "\n"; $pdf .= 'startxref' . "\n"; $pdf .= $xrefOffset . "\n"; $pdf .= '%%EOF' . "\n"; return $pdf; } // ------------------------------------------------------------------ setImportSourceFile / setImportSourceData /** * @throws \Throwable */ public function testSetImportSourceFileReturnsNonEmptyId(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->simplePdf); $this->assertNotEmpty($srcId); } /** * @throws \Throwable */ public function testSetImportSourceDataReturnsNonEmptyId(): void { $pdf = $this->makePdf(); $data = (string) \file_get_contents($this->simplePdf); $srcId = $pdf->setImportSourceData($data); $this->assertNotEmpty($srcId); } /** * @throws \Throwable */ public function testSetImportSourceFileThrowsForMissingFile(): void { $pdf = $this->makePdf(); $this->expectException(ImportSourceNotFoundException::class); $pdf->setImportSourceFile('/nonexistent/path.pdf'); } /** * @throws \Throwable */ public function testSetImportSourceFileThrowsForEncryptedPdf(): void { $pdf = $this->makePdf(); $this->expectException(ImportUnsupportedFeatureException::class); $this->expectExceptionMessageMatches('/' . preg_quote('encrypted PDF', '/') . '/'); $pdf->setImportSourceFile($this->encryptedPdf); } /** * @throws \Throwable */ public function testSetImportSourceFileWithPasswordThrowsActionableEncryptedError(): void { $pdf = $this->makePdf(); $this->expectException(ImportUnsupportedFeatureException::class); $this->expectExceptionMessageMatches('/' . preg_quote('password-based import is not supported', '/') . '/'); $pdf->setImportSourceFile($this->encryptedPdf, ['password' => 'secret']); } // ------------------------------------------------------------------ getSourcePageCount /** * @throws \Throwable */ public function testGetSourcePageCountSimple(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->simplePdf); $this->assertSame(1, $pdf->getSourcePageCount($srcId)); } /** * @throws \Throwable */ public function testGetSourcePageCountMultipage(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->multipagePdf); $this->assertSame(2, $pdf->getSourcePageCount($srcId)); } // ------------------------------------------------------------------ importPage / importPages /** * @throws \Throwable */ public function testImportPageReturnsPageTemplate(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->simplePdf); $tpl = $pdf->importPage($srcId, 1); $this->assertInstanceOf(PageTemplate::class, $tpl); $this->assertGreaterThan(0.0, $tpl->getWidth()); $this->assertGreaterThan(0.0, $tpl->getHeight()); } /** * @throws \Throwable */ public function testImportPageThrowsForOutOfRange(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->simplePdf); $this->expectException(ImportPageOutOfRangeException::class); $pdf->importPage($srcId, 99); } /** * @throws \Throwable */ public function testImportPageUsesTrimBoxWhenRequested(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->boxOptionsPdf); $tpl = $pdf->importPage($srcId, 1, ['box' => 'TrimBox']); $this->assertEqualsWithDelta(460.0, $tpl->getWidth(), 0.01); $this->assertEqualsWithDelta(660.0, $tpl->getHeight(), 0.01); } /** * @throws \Throwable */ public function testImportPageUsesArtBoxWhenRequested(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->boxOptionsPdf); $tpl = $pdf->importPage($srcId, 1, ['box' => 'ArtBox']); $this->assertEqualsWithDelta(440.0, $tpl->getWidth(), 0.01); $this->assertEqualsWithDelta(640.0, $tpl->getHeight(), 0.01); } /** * @throws \Throwable */ public function testImportPageRespectsRotationByDefault(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->rotatedPdf); $tpl = $pdf->importPage($srcId, 1); $this->assertSame(90, $tpl->getRotation()); $this->assertGreaterThan($tpl->getHeight(), $tpl->getWidth()); $this->assertEqualsWithDelta(500.0, $tpl->getWidth(), 0.01); $this->assertEqualsWithDelta(300.0, $tpl->getHeight(), 0.01); } /** * @throws \Throwable */ public function testImportPageCanDisableRotationRespect(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->rotatedPdf); $tpl = $pdf->importPage($srcId, 1, ['respectRotation' => false]); $this->assertSame(0, $tpl->getRotation()); $this->assertGreaterThan($tpl->getWidth(), $tpl->getHeight()); $this->assertEqualsWithDelta(300.0, $tpl->getWidth(), 0.01); $this->assertEqualsWithDelta(500.0, $tpl->getHeight(), 0.01); } /** * @throws \Throwable */ public function testImportPageWithGroupXObjectBumpsPdfVersionTo14Minimum(): void { $pdf = $this->makePdf(); $pdf->setPDFVersion('1.3'); $srcId = $pdf->setImportSourceFile($this->transparencyPdf); $pdf->importPage($srcId, 1, ['groupXObject' => true]); $this->assertSame('1.4', $this->getPdfVersion($pdf)); } /** * @throws \Throwable */ public function testImportPageWithGroupXObjectDisabledKeepsVersion(): void { $pdf = $this->makePdf(); $pdf->setPDFVersion('1.3'); $srcId = $pdf->setImportSourceFile($this->transparencyPdf); $pdf->importPage($srcId, 1, ['groupXObject' => false]); $this->assertSame('1.3', $this->getPdfVersion($pdf)); } /** * @throws \Throwable */ public function testImportPageEmitsTransparencyGroupByDefault(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->transparencyPdf); $tpl = $pdf->importPage($srcId, 1); $pdf->addPage(); $pdf->useImportedPage($tpl, 10, 10, 120, 80, ['keepAspectRatio' => false]); $raw = $pdf->getOutPDFString(); $this->assertStringContainsString('/Group << /Type /Group /S /Transparency >>', $raw); } /** * @throws \Throwable */ public function testImportPageSuppressesTransparencyGroupInPdfx3(): void { $pdf = $this->makePdfWithMode('pdfx3'); $srcId = $pdf->setImportSourceFile($this->transparencyPdf); $tpl = $pdf->importPage($srcId, 1, ['groupXObject' => true]); $pdf->addPage(); $pdf->useImportedPage($tpl, 10, 10, 120, 80, ['keepAspectRatio' => false]); $raw = $pdf->getOutPDFString(); $this->assertStringNotContainsString('/Group << /Type /Group /S /Transparency >>', $raw); } /** * @throws \Throwable */ public function testImportPagesNullRangeImportsAll(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->multipagePdf); $tpls = $pdf->importPages($srcId); $this->assertCount(2, $tpls); foreach ($tpls as $tpl) { $this->assertInstanceOf(PageTemplate::class, $tpl); } } /** * @throws \Throwable */ public function testImportPagesExplicitRange(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->multipagePdf); $tpls = $pdf->importPages($srcId, [1]); $this->assertCount(1, $tpls); assert(isset($tpls[0]), "\$tpls[0] must be set"); $this->assertInstanceOf(PageTemplate::class, $tpls[0]); } /** * @throws \Throwable */ public function testImportPagesThrowsForOutOfRange(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->multipagePdf); $this->expectException(ImportPageOutOfRangeException::class); $pdf->importPages($srcId, [1, 99]); } // ------------------------------------------------------------------ useImportedPage /** * @throws \Throwable */ public function testUseImportedPageReturnsPlacementDimensions(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->simplePdf); $tpl = $pdf->importPage($srcId, 1); $pdf->addPage(); $placed = $pdf->useImportedPage($tpl, 10.0, 10.0, 100.0, null, []); $this->assertArrayHasKey('x', $placed); $this->assertArrayHasKey('y', $placed); $this->assertArrayHasKey('width', $placed); $this->assertArrayHasKey('height', $placed); $this->assertEqualsWithDelta(100.0, $placed['width'], 0.01); } /** * @throws \Throwable */ public function testUseImportedPageAlignCenterCentersInsideRequestedBox(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->simplePdf); $tpl = $pdf->importPage($srcId, 1); $pdf->addPage(); $placed = $pdf->useImportedPage($tpl, 10.0, 20.0, 100.0, 200.0, ['keepAspectRatio' => true, 'align' => 'CC']); // In a 100x200 box with source ratio 612:792, width is the limiting axis. $this->assertEqualsWithDelta(100.0, $placed['width'], 0.01); $this->assertEqualsWithDelta(129.41, $placed['height'], 0.05); $this->assertEqualsWithDelta(10.0, $placed['x'], 0.01); $this->assertEqualsWithDelta(55.29, $placed['y'], 0.05); } /** * @throws \Throwable */ public function testUseImportedPageWithClipAddsClipOperatorToPageContent(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->simplePdf); $tpl = $pdf->importPage($srcId, 1); $pdf->addPage(); $pdf->useImportedPage($tpl, 15.0, 25.0, 80.0, 60.0, ['clip' => true, 'keepAspectRatio' => false]); $page = $pdf->page->getPage(); $content = \implode('', $page['content']); $this->assertStringContainsString(' re W n ', $content); } // ------------------------------------------------------------------ addPageFromImport /** * @throws \Throwable */ public function testAddPageFromImportCreatesPageAndReturnsTemplate(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->simplePdf); $tpl = $pdf->addPageFromImport($srcId, 1); $this->assertInstanceOf(PageTemplate::class, $tpl); // The first page has pid 0; subsequent pages have positive pids. $pageId = $pdf->page->getPageID(); $this->assertGreaterThanOrEqual(0, $pageId); } /** * @throws \Throwable */ public function testAddPageFromImportPageDimensionsMatchTemplate(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->simplePdf); $tpl = $pdf->addPageFromImport($srcId, 1); $pageId = $pdf->page->getPageID(); $page = $pdf->page->getPage($pageId); $pageW = $page['width']; $pageH = $page['height']; $this->assertGreaterThan(0.0, $pageW); $this->assertGreaterThan(0.0, $pageH); // The aspect ratio of the page must match the template. $this->assertEqualsWithDelta($tpl->getWidth() / $tpl->getHeight(), $pageW / $pageH, 0.01); } /** * @throws \Throwable */ public function testAddPageFromImportPlacesXObject(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->simplePdf); $tpl = $pdf->addPageFromImport($srcId, 1); $pageId = $pdf->page->getPageID(); $pageContent = $pdf->page->getPage($pageId); // The page content should reference the XObject. $content = \implode('', $pageContent['content']); $this->assertStringContainsString($tpl->getXobjId(), $content); } // ------------------------------------------------------------------ appendDocument /** * @throws \Throwable */ public function testAppendDocumentCreatesOnePagePerSourcePage(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->multipagePdf); $tpls = $pdf->appendDocument($srcId); $this->assertCount(2, $tpls); } /** * @throws \Throwable */ public function testAppendDocumentConcatenatesDecodedMultiStreamContents(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceData($this->buildMultiContentFlatePdf()); $tpls = $pdf->appendDocument($srcId); $this->assertCount(1, $tpls); $raw = $pdf->getOutPDFString(); $this->assertStringContainsString('(A) Tj ET', $raw); $this->assertStringContainsString('(B) Tj ET', $raw); } /** * @throws \Throwable */ public function testAppendDocumentWithRangeCreatesOnlyRequestedPages(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->multipagePdf); $tpls = $pdf->appendDocument($srcId, [2]); $this->assertCount(1, $tpls); assert(isset($tpls[0]), "\$tpls[0] must be set"); $this->assertSame(2, $tpls[0]->getSourcePage()); } /** * @throws \Throwable */ public function testAppendDocumentRestoresCallerPageContext(): void { $pdf = $this->makePdf(); // Create an initial page. $callerPage = $pdf->addPage(); if (!isset($callerPage['pid']) || !\is_int($callerPage['pid'])) { $this->fail('Expected integer page id.'); } $callerPid = $callerPage['pid']; // Append pages from a multi-page source. $srcId = $pdf->setImportSourceFile($this->multipagePdf); $pdf->appendDocument($srcId); // Current page ID should be restored to the caller's page. $this->assertSame($callerPid, $pdf->page->getPageID()); } /** * @throws \Throwable */ public function testAppendDocumentWithNoPriorPageLeavesCurrentOnLastAppended(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->multipagePdf); $tpls = $pdf->appendDocument($srcId); // No prior page (pid is -1), so restore does not run; // the current page is the last appended one. $finalPid = $pdf->page->getPageID(); $this->assertGreaterThan(0, $finalPid); // Both appended pages should be reachable. $this->assertCount(2, $tpls); } /** * @throws \Throwable */ public function testAppendDocumentThrowsForOutOfRangePage(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->multipagePdf); $this->expectException(ImportPageOutOfRangeException::class); $pdf->appendDocument($srcId, [5]); } /** * @throws \Throwable */ public function testAppendDocumentXObjectsRegistered(): void { $pdf = $this->makePdf(); $srcId = $pdf->setImportSourceFile($this->multipagePdf); $tpls = $pdf->appendDocument($srcId); $pages = $pdf->page->getPages(); $pageContent = ''; foreach ($pages as $page) { $content = $page['content']; if ($content === []) { continue; } $pageContent .= \implode('', $content); } foreach ($tpls as $tpl) { $this->assertStringContainsString($tpl->getXobjId(), $pageContent); } } }